Monorepo for Tangled tangled.org

Compare changes

Choose any two refs to compare.

Changed files
+14491 -6093
.air
api
appview
commitverify
config
db
email
indexer
issues
knots
labels
mentions
middleware
models
notifications
notify
oauth
ogcard
pages
markup
repoinfo
templates
pipelines
pulls
repo
reporesolver
serververify
settings
spindles
state
strings
validator
cmd
dolly
crypto
docs
guard
hook
ico
idresolver
jetstream
knotserver
lexicons
nix
orm
patchutil
rbac
sets
spindle
types
workflow
+8 -6
.air/appview.toml
··· 1 - [build] 2 - cmd = "tailwindcss -i input.css -o ./appview/pages/static/tw.css && go build -o .bin/app ./cmd/appview/main.go" 3 - bin = ";set -o allexport && source .env && set +o allexport; .bin/app" 4 1 root = "." 2 + tmp_dir = "out" 5 3 6 - exclude_regex = [".*_templ.go"] 7 - include_ext = ["go", "templ", "html", "css"] 8 - exclude_dir = ["target", "atrium", "nix"] 4 + [build] 5 + cmd = "go build -o out/appview.out cmd/appview/main.go" 6 + bin = "out/appview.out" 7 + 8 + include_ext = ["go"] 9 + exclude_dir = ["avatar", "camo", "indexes", "nix", "tmp"] 10 + stop_on_error = true
+11
.air/knot.toml
··· 1 + root = "." 2 + tmp_dir = "out" 3 + 4 + [build] 5 + cmd = 'go build -ldflags "-X tangled.org/core/knotserver.version=$(git describe --tags --long)" -o out/knot.out cmd/knot/main.go' 6 + bin = "out/knot.out" 7 + args_bin = ["server"] 8 + 9 + include_ext = ["go"] 10 + exclude_dir = ["avatar", "camo", "indexes", "nix", "tmp"] 11 + stop_on_error = true
-7
.air/knotserver.toml
··· 1 - [build] 2 - cmd = 'go build -ldflags "-X tangled.org/core/knotserver.version=$(git describe --tags --long)" -o .bin/knot ./cmd/knot/' 3 - bin = ".bin/knot server" 4 - root = "." 5 - 6 - exclude_regex = [""] 7 - include_ext = ["go", "templ"]
+10
.air/spindle.toml
··· 1 + root = "." 2 + tmp_dir = "out" 3 + 4 + [build] 5 + cmd = "go build -o out/spindle.out cmd/spindle/main.go" 6 + bin = "out/spindle.out" 7 + 8 + include_ext = ["go"] 9 + exclude_dir = ["avatar", "camo", "indexes", "nix", "tmp"] 10 + stop_on_error = true
+13
.editorconfig
··· 1 + root = true 2 + 3 + [*.html] 4 + indent_size = 2 5 + 6 + [*.json] 7 + indent_size = 2 8 + 9 + [*.nix] 10 + indent_size = 2 11 + 12 + [*.yml] 13 + indent_size = 2
+1 -1
.gitignore
··· 15 15 .env 16 16 *.rdb 17 17 .envrc 18 - *.bleve 18 + **/*.bleve 19 19 # Created if following hacking.md 20 20 genjwks.out 21 21 /nix/vm-data
+3 -1
api/tangled/actorprofile.go
··· 27 27 Location *string `json:"location,omitempty" cborgen:"location,omitempty"` 28 28 // pinnedRepositories: Any ATURI, it is up to appviews to validate these fields. 29 29 PinnedRepositories []string `json:"pinnedRepositories,omitempty" cborgen:"pinnedRepositories,omitempty"` 30 - Stats []string `json:"stats,omitempty" cborgen:"stats,omitempty"` 30 + // pronouns: Preferred gender pronouns. 31 + Pronouns *string `json:"pronouns,omitempty" cborgen:"pronouns,omitempty"` 32 + Stats []string `json:"stats,omitempty" cborgen:"stats,omitempty"` 31 33 }
+923 -29
api/tangled/cbor_gen.go
··· 26 26 } 27 27 28 28 cw := cbg.NewCborWriter(w) 29 - fieldCount := 7 29 + fieldCount := 8 30 30 31 31 if t.Description == nil { 32 32 fieldCount-- ··· 41 41 } 42 42 43 43 if t.PinnedRepositories == nil { 44 + fieldCount-- 45 + } 46 + 47 + if t.Pronouns == nil { 44 48 fieldCount-- 45 49 } 46 50 ··· 186 190 return err 187 191 } 188 192 if _, err := cw.WriteString(string(*t.Location)); err != nil { 193 + return err 194 + } 195 + } 196 + } 197 + 198 + // t.Pronouns (string) (string) 199 + if t.Pronouns != nil { 200 + 201 + if len("pronouns") > 1000000 { 202 + return xerrors.Errorf("Value in field \"pronouns\" was too long") 203 + } 204 + 205 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("pronouns"))); err != nil { 206 + return err 207 + } 208 + if _, err := cw.WriteString(string("pronouns")); err != nil { 209 + return err 210 + } 211 + 212 + if t.Pronouns == nil { 213 + if _, err := cw.Write(cbg.CborNull); err != nil { 214 + return err 215 + } 216 + } else { 217 + if len(*t.Pronouns) > 1000000 { 218 + return xerrors.Errorf("Value in field t.Pronouns was too long") 219 + } 220 + 221 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Pronouns))); err != nil { 222 + return err 223 + } 224 + if _, err := cw.WriteString(string(*t.Pronouns)); err != nil { 189 225 return err 190 226 } 191 227 } ··· 430 466 } 431 467 432 468 t.Location = (*string)(&sval) 469 + } 470 + } 471 + // t.Pronouns (string) (string) 472 + case "pronouns": 473 + 474 + { 475 + b, err := cr.ReadByte() 476 + if err != nil { 477 + return err 478 + } 479 + if b != cbg.CborNull[0] { 480 + if err := cr.UnreadByte(); err != nil { 481 + return err 482 + } 483 + 484 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 485 + if err != nil { 486 + return err 487 + } 488 + 489 + t.Pronouns = (*string)(&sval) 433 490 } 434 491 } 435 492 // t.Description (string) (string) ··· 5806 5863 } 5807 5864 5808 5865 cw := cbg.NewCborWriter(w) 5809 - fieldCount := 8 5866 + fieldCount := 10 5810 5867 5811 5868 if t.Description == nil { 5812 5869 fieldCount-- ··· 5821 5878 } 5822 5879 5823 5880 if t.Spindle == nil { 5881 + fieldCount-- 5882 + } 5883 + 5884 + if t.Topics == nil { 5885 + fieldCount-- 5886 + } 5887 + 5888 + if t.Website == nil { 5824 5889 fieldCount-- 5825 5890 } 5826 5891 ··· 5961 6026 } 5962 6027 } 5963 6028 6029 + // t.Topics ([]string) (slice) 6030 + if t.Topics != nil { 6031 + 6032 + if len("topics") > 1000000 { 6033 + return xerrors.Errorf("Value in field \"topics\" was too long") 6034 + } 6035 + 6036 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("topics"))); err != nil { 6037 + return err 6038 + } 6039 + if _, err := cw.WriteString(string("topics")); err != nil { 6040 + return err 6041 + } 6042 + 6043 + if len(t.Topics) > 8192 { 6044 + return xerrors.Errorf("Slice value in field t.Topics was too long") 6045 + } 6046 + 6047 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Topics))); err != nil { 6048 + return err 6049 + } 6050 + for _, v := range t.Topics { 6051 + if len(v) > 1000000 { 6052 + return xerrors.Errorf("Value in field v was too long") 6053 + } 6054 + 6055 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 6056 + return err 6057 + } 6058 + if _, err := cw.WriteString(string(v)); err != nil { 6059 + return err 6060 + } 6061 + 6062 + } 6063 + } 6064 + 5964 6065 // t.Spindle (string) (string) 5965 6066 if t.Spindle != nil { 5966 6067 ··· 5993 6094 } 5994 6095 } 5995 6096 6097 + // t.Website (string) (string) 6098 + if t.Website != nil { 6099 + 6100 + if len("website") > 1000000 { 6101 + return xerrors.Errorf("Value in field \"website\" was too long") 6102 + } 6103 + 6104 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("website"))); err != nil { 6105 + return err 6106 + } 6107 + if _, err := cw.WriteString(string("website")); err != nil { 6108 + return err 6109 + } 6110 + 6111 + if t.Website == nil { 6112 + if _, err := cw.Write(cbg.CborNull); err != nil { 6113 + return err 6114 + } 6115 + } else { 6116 + if len(*t.Website) > 1000000 { 6117 + return xerrors.Errorf("Value in field t.Website was too long") 6118 + } 6119 + 6120 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Website))); err != nil { 6121 + return err 6122 + } 6123 + if _, err := cw.WriteString(string(*t.Website)); err != nil { 6124 + return err 6125 + } 6126 + } 6127 + } 6128 + 5996 6129 // t.CreatedAt (string) (string) 5997 6130 if len("createdAt") > 1000000 { 5998 6131 return xerrors.Errorf("Value in field \"createdAt\" was too long") ··· 6185 6318 t.Source = (*string)(&sval) 6186 6319 } 6187 6320 } 6321 + // t.Topics ([]string) (slice) 6322 + case "topics": 6323 + 6324 + maj, extra, err = cr.ReadHeader() 6325 + if err != nil { 6326 + return err 6327 + } 6328 + 6329 + if extra > 8192 { 6330 + return fmt.Errorf("t.Topics: array too large (%d)", extra) 6331 + } 6332 + 6333 + if maj != cbg.MajArray { 6334 + return fmt.Errorf("expected cbor array") 6335 + } 6336 + 6337 + if extra > 0 { 6338 + t.Topics = make([]string, extra) 6339 + } 6340 + 6341 + for i := 0; i < int(extra); i++ { 6342 + { 6343 + var maj byte 6344 + var extra uint64 6345 + var err error 6346 + _ = maj 6347 + _ = extra 6348 + _ = err 6349 + 6350 + { 6351 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 6352 + if err != nil { 6353 + return err 6354 + } 6355 + 6356 + t.Topics[i] = string(sval) 6357 + } 6358 + 6359 + } 6360 + } 6188 6361 // t.Spindle (string) (string) 6189 6362 case "spindle": 6190 6363 ··· 6204 6377 } 6205 6378 6206 6379 t.Spindle = (*string)(&sval) 6380 + } 6381 + } 6382 + // t.Website (string) (string) 6383 + case "website": 6384 + 6385 + { 6386 + b, err := cr.ReadByte() 6387 + if err != nil { 6388 + return err 6389 + } 6390 + if b != cbg.CborNull[0] { 6391 + if err := cr.UnreadByte(); err != nil { 6392 + return err 6393 + } 6394 + 6395 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 6396 + if err != nil { 6397 + return err 6398 + } 6399 + 6400 + t.Website = (*string)(&sval) 6207 6401 } 6208 6402 } 6209 6403 // t.CreatedAt (string) (string) ··· 6744 6938 } 6745 6939 6746 6940 cw := cbg.NewCborWriter(w) 6747 - fieldCount := 5 6941 + fieldCount := 7 6748 6942 6749 6943 if t.Body == nil { 6944 + fieldCount-- 6945 + } 6946 + 6947 + if t.Mentions == nil { 6948 + fieldCount-- 6949 + } 6950 + 6951 + if t.References == nil { 6750 6952 fieldCount-- 6751 6953 } 6752 6954 ··· 6851 7053 return err 6852 7054 } 6853 7055 7056 + // t.Mentions ([]string) (slice) 7057 + if t.Mentions != nil { 7058 + 7059 + if len("mentions") > 1000000 { 7060 + return xerrors.Errorf("Value in field \"mentions\" was too long") 7061 + } 7062 + 7063 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("mentions"))); err != nil { 7064 + return err 7065 + } 7066 + if _, err := cw.WriteString(string("mentions")); err != nil { 7067 + return err 7068 + } 7069 + 7070 + if len(t.Mentions) > 8192 { 7071 + return xerrors.Errorf("Slice value in field t.Mentions was too long") 7072 + } 7073 + 7074 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Mentions))); err != nil { 7075 + return err 7076 + } 7077 + for _, v := range t.Mentions { 7078 + if len(v) > 1000000 { 7079 + return xerrors.Errorf("Value in field v was too long") 7080 + } 7081 + 7082 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 7083 + return err 7084 + } 7085 + if _, err := cw.WriteString(string(v)); err != nil { 7086 + return err 7087 + } 7088 + 7089 + } 7090 + } 7091 + 6854 7092 // t.CreatedAt (string) (string) 6855 7093 if len("createdAt") > 1000000 { 6856 7094 return xerrors.Errorf("Value in field \"createdAt\" was too long") ··· 6873 7111 if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 6874 7112 return err 6875 7113 } 7114 + 7115 + // t.References ([]string) (slice) 7116 + if t.References != nil { 7117 + 7118 + if len("references") > 1000000 { 7119 + return xerrors.Errorf("Value in field \"references\" was too long") 7120 + } 7121 + 7122 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("references"))); err != nil { 7123 + return err 7124 + } 7125 + if _, err := cw.WriteString(string("references")); err != nil { 7126 + return err 7127 + } 7128 + 7129 + if len(t.References) > 8192 { 7130 + return xerrors.Errorf("Slice value in field t.References was too long") 7131 + } 7132 + 7133 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.References))); err != nil { 7134 + return err 7135 + } 7136 + for _, v := range t.References { 7137 + if len(v) > 1000000 { 7138 + return xerrors.Errorf("Value in field v was too long") 7139 + } 7140 + 7141 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 7142 + return err 7143 + } 7144 + if _, err := cw.WriteString(string(v)); err != nil { 7145 + return err 7146 + } 7147 + 7148 + } 7149 + } 6876 7150 return nil 6877 7151 } 6878 7152 ··· 6901 7175 6902 7176 n := extra 6903 7177 6904 - nameBuf := make([]byte, 9) 7178 + nameBuf := make([]byte, 10) 6905 7179 for i := uint64(0); i < n; i++ { 6906 7180 nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 6907 7181 if err != nil { ··· 6971 7245 6972 7246 t.Title = string(sval) 6973 7247 } 7248 + // t.Mentions ([]string) (slice) 7249 + case "mentions": 7250 + 7251 + maj, extra, err = cr.ReadHeader() 7252 + if err != nil { 7253 + return err 7254 + } 7255 + 7256 + if extra > 8192 { 7257 + return fmt.Errorf("t.Mentions: array too large (%d)", extra) 7258 + } 7259 + 7260 + if maj != cbg.MajArray { 7261 + return fmt.Errorf("expected cbor array") 7262 + } 7263 + 7264 + if extra > 0 { 7265 + t.Mentions = make([]string, extra) 7266 + } 7267 + 7268 + for i := 0; i < int(extra); i++ { 7269 + { 7270 + var maj byte 7271 + var extra uint64 7272 + var err error 7273 + _ = maj 7274 + _ = extra 7275 + _ = err 7276 + 7277 + { 7278 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 7279 + if err != nil { 7280 + return err 7281 + } 7282 + 7283 + t.Mentions[i] = string(sval) 7284 + } 7285 + 7286 + } 7287 + } 6974 7288 // t.CreatedAt (string) (string) 6975 7289 case "createdAt": 6976 7290 ··· 6982 7296 6983 7297 t.CreatedAt = string(sval) 6984 7298 } 7299 + // t.References ([]string) (slice) 7300 + case "references": 7301 + 7302 + maj, extra, err = cr.ReadHeader() 7303 + if err != nil { 7304 + return err 7305 + } 7306 + 7307 + if extra > 8192 { 7308 + return fmt.Errorf("t.References: array too large (%d)", extra) 7309 + } 7310 + 7311 + if maj != cbg.MajArray { 7312 + return fmt.Errorf("expected cbor array") 7313 + } 7314 + 7315 + if extra > 0 { 7316 + t.References = make([]string, extra) 7317 + } 7318 + 7319 + for i := 0; i < int(extra); i++ { 7320 + { 7321 + var maj byte 7322 + var extra uint64 7323 + var err error 7324 + _ = maj 7325 + _ = extra 7326 + _ = err 7327 + 7328 + { 7329 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 7330 + if err != nil { 7331 + return err 7332 + } 7333 + 7334 + t.References[i] = string(sval) 7335 + } 7336 + 7337 + } 7338 + } 6985 7339 6986 7340 default: 6987 7341 // Field doesn't exist on this type, so ignore it ··· 7000 7354 } 7001 7355 7002 7356 cw := cbg.NewCborWriter(w) 7003 - fieldCount := 5 7357 + fieldCount := 7 7358 + 7359 + if t.Mentions == nil { 7360 + fieldCount-- 7361 + } 7362 + 7363 + if t.References == nil { 7364 + fieldCount-- 7365 + } 7004 7366 7005 7367 if t.ReplyTo == nil { 7006 7368 fieldCount-- ··· 7107 7469 } 7108 7470 } 7109 7471 7472 + // t.Mentions ([]string) (slice) 7473 + if t.Mentions != nil { 7474 + 7475 + if len("mentions") > 1000000 { 7476 + return xerrors.Errorf("Value in field \"mentions\" was too long") 7477 + } 7478 + 7479 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("mentions"))); err != nil { 7480 + return err 7481 + } 7482 + if _, err := cw.WriteString(string("mentions")); err != nil { 7483 + return err 7484 + } 7485 + 7486 + if len(t.Mentions) > 8192 { 7487 + return xerrors.Errorf("Slice value in field t.Mentions was too long") 7488 + } 7489 + 7490 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Mentions))); err != nil { 7491 + return err 7492 + } 7493 + for _, v := range t.Mentions { 7494 + if len(v) > 1000000 { 7495 + return xerrors.Errorf("Value in field v was too long") 7496 + } 7497 + 7498 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 7499 + return err 7500 + } 7501 + if _, err := cw.WriteString(string(v)); err != nil { 7502 + return err 7503 + } 7504 + 7505 + } 7506 + } 7507 + 7110 7508 // t.CreatedAt (string) (string) 7111 7509 if len("createdAt") > 1000000 { 7112 7510 return xerrors.Errorf("Value in field \"createdAt\" was too long") ··· 7129 7527 if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 7130 7528 return err 7131 7529 } 7530 + 7531 + // t.References ([]string) (slice) 7532 + if t.References != nil { 7533 + 7534 + if len("references") > 1000000 { 7535 + return xerrors.Errorf("Value in field \"references\" was too long") 7536 + } 7537 + 7538 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("references"))); err != nil { 7539 + return err 7540 + } 7541 + if _, err := cw.WriteString(string("references")); err != nil { 7542 + return err 7543 + } 7544 + 7545 + if len(t.References) > 8192 { 7546 + return xerrors.Errorf("Slice value in field t.References was too long") 7547 + } 7548 + 7549 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.References))); err != nil { 7550 + return err 7551 + } 7552 + for _, v := range t.References { 7553 + if len(v) > 1000000 { 7554 + return xerrors.Errorf("Value in field v was too long") 7555 + } 7556 + 7557 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 7558 + return err 7559 + } 7560 + if _, err := cw.WriteString(string(v)); err != nil { 7561 + return err 7562 + } 7563 + 7564 + } 7565 + } 7132 7566 return nil 7133 7567 } 7134 7568 ··· 7157 7591 7158 7592 n := extra 7159 7593 7160 - nameBuf := make([]byte, 9) 7594 + nameBuf := make([]byte, 10) 7161 7595 for i := uint64(0); i < n; i++ { 7162 7596 nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 7163 7597 if err != nil { ··· 7227 7661 t.ReplyTo = (*string)(&sval) 7228 7662 } 7229 7663 } 7664 + // t.Mentions ([]string) (slice) 7665 + case "mentions": 7666 + 7667 + maj, extra, err = cr.ReadHeader() 7668 + if err != nil { 7669 + return err 7670 + } 7671 + 7672 + if extra > 8192 { 7673 + return fmt.Errorf("t.Mentions: array too large (%d)", extra) 7674 + } 7675 + 7676 + if maj != cbg.MajArray { 7677 + return fmt.Errorf("expected cbor array") 7678 + } 7679 + 7680 + if extra > 0 { 7681 + t.Mentions = make([]string, extra) 7682 + } 7683 + 7684 + for i := 0; i < int(extra); i++ { 7685 + { 7686 + var maj byte 7687 + var extra uint64 7688 + var err error 7689 + _ = maj 7690 + _ = extra 7691 + _ = err 7692 + 7693 + { 7694 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 7695 + if err != nil { 7696 + return err 7697 + } 7698 + 7699 + t.Mentions[i] = string(sval) 7700 + } 7701 + 7702 + } 7703 + } 7230 7704 // t.CreatedAt (string) (string) 7231 7705 case "createdAt": 7232 7706 ··· 7237 7711 } 7238 7712 7239 7713 t.CreatedAt = string(sval) 7714 + } 7715 + // t.References ([]string) (slice) 7716 + case "references": 7717 + 7718 + maj, extra, err = cr.ReadHeader() 7719 + if err != nil { 7720 + return err 7721 + } 7722 + 7723 + if extra > 8192 { 7724 + return fmt.Errorf("t.References: array too large (%d)", extra) 7725 + } 7726 + 7727 + if maj != cbg.MajArray { 7728 + return fmt.Errorf("expected cbor array") 7729 + } 7730 + 7731 + if extra > 0 { 7732 + t.References = make([]string, extra) 7733 + } 7734 + 7735 + for i := 0; i < int(extra); i++ { 7736 + { 7737 + var maj byte 7738 + var extra uint64 7739 + var err error 7740 + _ = maj 7741 + _ = extra 7742 + _ = err 7743 + 7744 + { 7745 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 7746 + if err != nil { 7747 + return err 7748 + } 7749 + 7750 + t.References[i] = string(sval) 7751 + } 7752 + 7753 + } 7240 7754 } 7241 7755 7242 7756 default: ··· 7420 7934 } 7421 7935 7422 7936 cw := cbg.NewCborWriter(w) 7423 - fieldCount := 7 7937 + fieldCount := 10 7424 7938 7425 7939 if t.Body == nil { 7940 + fieldCount-- 7941 + } 7942 + 7943 + if t.Mentions == nil { 7944 + fieldCount-- 7945 + } 7946 + 7947 + if t.Patch == nil { 7948 + fieldCount-- 7949 + } 7950 + 7951 + if t.References == nil { 7426 7952 fieldCount-- 7427 7953 } 7428 7954 ··· 7486 8012 } 7487 8013 7488 8014 // t.Patch (string) (string) 7489 - if len("patch") > 1000000 { 7490 - return xerrors.Errorf("Value in field \"patch\" was too long") 7491 - } 8015 + if t.Patch != nil { 7492 8016 7493 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("patch"))); err != nil { 7494 - return err 7495 - } 7496 - if _, err := cw.WriteString(string("patch")); err != nil { 7497 - return err 7498 - } 8017 + if len("patch") > 1000000 { 8018 + return xerrors.Errorf("Value in field \"patch\" was too long") 8019 + } 7499 8020 7500 - if len(t.Patch) > 1000000 { 7501 - return xerrors.Errorf("Value in field t.Patch was too long") 7502 - } 8021 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("patch"))); err != nil { 8022 + return err 8023 + } 8024 + if _, err := cw.WriteString(string("patch")); err != nil { 8025 + return err 8026 + } 7503 8027 7504 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Patch))); err != nil { 7505 - return err 7506 - } 7507 - if _, err := cw.WriteString(string(t.Patch)); err != nil { 7508 - return err 8028 + if t.Patch == nil { 8029 + if _, err := cw.Write(cbg.CborNull); err != nil { 8030 + return err 8031 + } 8032 + } else { 8033 + if len(*t.Patch) > 1000000 { 8034 + return xerrors.Errorf("Value in field t.Patch was too long") 8035 + } 8036 + 8037 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Patch))); err != nil { 8038 + return err 8039 + } 8040 + if _, err := cw.WriteString(string(*t.Patch)); err != nil { 8041 + return err 8042 + } 8043 + } 7509 8044 } 7510 8045 7511 8046 // t.Title (string) (string) ··· 7566 8101 return err 7567 8102 } 7568 8103 8104 + // t.Mentions ([]string) (slice) 8105 + if t.Mentions != nil { 8106 + 8107 + if len("mentions") > 1000000 { 8108 + return xerrors.Errorf("Value in field \"mentions\" was too long") 8109 + } 8110 + 8111 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("mentions"))); err != nil { 8112 + return err 8113 + } 8114 + if _, err := cw.WriteString(string("mentions")); err != nil { 8115 + return err 8116 + } 8117 + 8118 + if len(t.Mentions) > 8192 { 8119 + return xerrors.Errorf("Slice value in field t.Mentions was too long") 8120 + } 8121 + 8122 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Mentions))); err != nil { 8123 + return err 8124 + } 8125 + for _, v := range t.Mentions { 8126 + if len(v) > 1000000 { 8127 + return xerrors.Errorf("Value in field v was too long") 8128 + } 8129 + 8130 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 8131 + return err 8132 + } 8133 + if _, err := cw.WriteString(string(v)); err != nil { 8134 + return err 8135 + } 8136 + 8137 + } 8138 + } 8139 + 7569 8140 // t.CreatedAt (string) (string) 7570 8141 if len("createdAt") > 1000000 { 7571 8142 return xerrors.Errorf("Value in field \"createdAt\" was too long") ··· 7588 8159 if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 7589 8160 return err 7590 8161 } 8162 + 8163 + // t.PatchBlob (util.LexBlob) (struct) 8164 + if len("patchBlob") > 1000000 { 8165 + return xerrors.Errorf("Value in field \"patchBlob\" was too long") 8166 + } 8167 + 8168 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("patchBlob"))); err != nil { 8169 + return err 8170 + } 8171 + if _, err := cw.WriteString(string("patchBlob")); err != nil { 8172 + return err 8173 + } 8174 + 8175 + if err := t.PatchBlob.MarshalCBOR(cw); err != nil { 8176 + return err 8177 + } 8178 + 8179 + // t.References ([]string) (slice) 8180 + if t.References != nil { 8181 + 8182 + if len("references") > 1000000 { 8183 + return xerrors.Errorf("Value in field \"references\" was too long") 8184 + } 8185 + 8186 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("references"))); err != nil { 8187 + return err 8188 + } 8189 + if _, err := cw.WriteString(string("references")); err != nil { 8190 + return err 8191 + } 8192 + 8193 + if len(t.References) > 8192 { 8194 + return xerrors.Errorf("Slice value in field t.References was too long") 8195 + } 8196 + 8197 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.References))); err != nil { 8198 + return err 8199 + } 8200 + for _, v := range t.References { 8201 + if len(v) > 1000000 { 8202 + return xerrors.Errorf("Value in field v was too long") 8203 + } 8204 + 8205 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 8206 + return err 8207 + } 8208 + if _, err := cw.WriteString(string(v)); err != nil { 8209 + return err 8210 + } 8211 + 8212 + } 8213 + } 7591 8214 return nil 7592 8215 } 7593 8216 ··· 7616 8239 7617 8240 n := extra 7618 8241 7619 - nameBuf := make([]byte, 9) 8242 + nameBuf := make([]byte, 10) 7620 8243 for i := uint64(0); i < n; i++ { 7621 8244 nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 7622 8245 if err != nil { ··· 7668 8291 case "patch": 7669 8292 7670 8293 { 7671 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 8294 + b, err := cr.ReadByte() 7672 8295 if err != nil { 7673 8296 return err 7674 8297 } 8298 + if b != cbg.CborNull[0] { 8299 + if err := cr.UnreadByte(); err != nil { 8300 + return err 8301 + } 7675 8302 7676 - t.Patch = string(sval) 8303 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8304 + if err != nil { 8305 + return err 8306 + } 8307 + 8308 + t.Patch = (*string)(&sval) 8309 + } 7677 8310 } 7678 8311 // t.Title (string) (string) 7679 8312 case "title": ··· 7726 8359 } 7727 8360 7728 8361 } 8362 + // t.Mentions ([]string) (slice) 8363 + case "mentions": 8364 + 8365 + maj, extra, err = cr.ReadHeader() 8366 + if err != nil { 8367 + return err 8368 + } 8369 + 8370 + if extra > 8192 { 8371 + return fmt.Errorf("t.Mentions: array too large (%d)", extra) 8372 + } 8373 + 8374 + if maj != cbg.MajArray { 8375 + return fmt.Errorf("expected cbor array") 8376 + } 8377 + 8378 + if extra > 0 { 8379 + t.Mentions = make([]string, extra) 8380 + } 8381 + 8382 + for i := 0; i < int(extra); i++ { 8383 + { 8384 + var maj byte 8385 + var extra uint64 8386 + var err error 8387 + _ = maj 8388 + _ = extra 8389 + _ = err 8390 + 8391 + { 8392 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8393 + if err != nil { 8394 + return err 8395 + } 8396 + 8397 + t.Mentions[i] = string(sval) 8398 + } 8399 + 8400 + } 8401 + } 7729 8402 // t.CreatedAt (string) (string) 7730 8403 case "createdAt": 7731 8404 ··· 7737 8410 7738 8411 t.CreatedAt = string(sval) 7739 8412 } 8413 + // t.PatchBlob (util.LexBlob) (struct) 8414 + case "patchBlob": 8415 + 8416 + { 8417 + 8418 + b, err := cr.ReadByte() 8419 + if err != nil { 8420 + return err 8421 + } 8422 + if b != cbg.CborNull[0] { 8423 + if err := cr.UnreadByte(); err != nil { 8424 + return err 8425 + } 8426 + t.PatchBlob = new(util.LexBlob) 8427 + if err := t.PatchBlob.UnmarshalCBOR(cr); err != nil { 8428 + return xerrors.Errorf("unmarshaling t.PatchBlob pointer: %w", err) 8429 + } 8430 + } 8431 + 8432 + } 8433 + // t.References ([]string) (slice) 8434 + case "references": 8435 + 8436 + maj, extra, err = cr.ReadHeader() 8437 + if err != nil { 8438 + return err 8439 + } 8440 + 8441 + if extra > 8192 { 8442 + return fmt.Errorf("t.References: array too large (%d)", extra) 8443 + } 8444 + 8445 + if maj != cbg.MajArray { 8446 + return fmt.Errorf("expected cbor array") 8447 + } 8448 + 8449 + if extra > 0 { 8450 + t.References = make([]string, extra) 8451 + } 8452 + 8453 + for i := 0; i < int(extra); i++ { 8454 + { 8455 + var maj byte 8456 + var extra uint64 8457 + var err error 8458 + _ = maj 8459 + _ = extra 8460 + _ = err 8461 + 8462 + { 8463 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8464 + if err != nil { 8465 + return err 8466 + } 8467 + 8468 + t.References[i] = string(sval) 8469 + } 8470 + 8471 + } 8472 + } 7740 8473 7741 8474 default: 7742 8475 // Field doesn't exist on this type, so ignore it ··· 7755 8488 } 7756 8489 7757 8490 cw := cbg.NewCborWriter(w) 8491 + fieldCount := 6 7758 8492 7759 - if _, err := cw.Write([]byte{164}); err != nil { 8493 + if t.Mentions == nil { 8494 + fieldCount-- 8495 + } 8496 + 8497 + if t.References == nil { 8498 + fieldCount-- 8499 + } 8500 + 8501 + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 7760 8502 return err 7761 8503 } 7762 8504 ··· 7825 8567 return err 7826 8568 } 7827 8569 8570 + // t.Mentions ([]string) (slice) 8571 + if t.Mentions != nil { 8572 + 8573 + if len("mentions") > 1000000 { 8574 + return xerrors.Errorf("Value in field \"mentions\" was too long") 8575 + } 8576 + 8577 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("mentions"))); err != nil { 8578 + return err 8579 + } 8580 + if _, err := cw.WriteString(string("mentions")); err != nil { 8581 + return err 8582 + } 8583 + 8584 + if len(t.Mentions) > 8192 { 8585 + return xerrors.Errorf("Slice value in field t.Mentions was too long") 8586 + } 8587 + 8588 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Mentions))); err != nil { 8589 + return err 8590 + } 8591 + for _, v := range t.Mentions { 8592 + if len(v) > 1000000 { 8593 + return xerrors.Errorf("Value in field v was too long") 8594 + } 8595 + 8596 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 8597 + return err 8598 + } 8599 + if _, err := cw.WriteString(string(v)); err != nil { 8600 + return err 8601 + } 8602 + 8603 + } 8604 + } 8605 + 7828 8606 // t.CreatedAt (string) (string) 7829 8607 if len("createdAt") > 1000000 { 7830 8608 return xerrors.Errorf("Value in field \"createdAt\" was too long") ··· 7847 8625 if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 7848 8626 return err 7849 8627 } 8628 + 8629 + // t.References ([]string) (slice) 8630 + if t.References != nil { 8631 + 8632 + if len("references") > 1000000 { 8633 + return xerrors.Errorf("Value in field \"references\" was too long") 8634 + } 8635 + 8636 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("references"))); err != nil { 8637 + return err 8638 + } 8639 + if _, err := cw.WriteString(string("references")); err != nil { 8640 + return err 8641 + } 8642 + 8643 + if len(t.References) > 8192 { 8644 + return xerrors.Errorf("Slice value in field t.References was too long") 8645 + } 8646 + 8647 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.References))); err != nil { 8648 + return err 8649 + } 8650 + for _, v := range t.References { 8651 + if len(v) > 1000000 { 8652 + return xerrors.Errorf("Value in field v was too long") 8653 + } 8654 + 8655 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 8656 + return err 8657 + } 8658 + if _, err := cw.WriteString(string(v)); err != nil { 8659 + return err 8660 + } 8661 + 8662 + } 8663 + } 7850 8664 return nil 7851 8665 } 7852 8666 ··· 7875 8689 7876 8690 n := extra 7877 8691 7878 - nameBuf := make([]byte, 9) 8692 + nameBuf := make([]byte, 10) 7879 8693 for i := uint64(0); i < n; i++ { 7880 8694 nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 7881 8695 if err != nil { ··· 7924 8738 7925 8739 t.LexiconTypeID = string(sval) 7926 8740 } 8741 + // t.Mentions ([]string) (slice) 8742 + case "mentions": 8743 + 8744 + maj, extra, err = cr.ReadHeader() 8745 + if err != nil { 8746 + return err 8747 + } 8748 + 8749 + if extra > 8192 { 8750 + return fmt.Errorf("t.Mentions: array too large (%d)", extra) 8751 + } 8752 + 8753 + if maj != cbg.MajArray { 8754 + return fmt.Errorf("expected cbor array") 8755 + } 8756 + 8757 + if extra > 0 { 8758 + t.Mentions = make([]string, extra) 8759 + } 8760 + 8761 + for i := 0; i < int(extra); i++ { 8762 + { 8763 + var maj byte 8764 + var extra uint64 8765 + var err error 8766 + _ = maj 8767 + _ = extra 8768 + _ = err 8769 + 8770 + { 8771 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8772 + if err != nil { 8773 + return err 8774 + } 8775 + 8776 + t.Mentions[i] = string(sval) 8777 + } 8778 + 8779 + } 8780 + } 7927 8781 // t.CreatedAt (string) (string) 7928 8782 case "createdAt": 7929 8783 ··· 7934 8788 } 7935 8789 7936 8790 t.CreatedAt = string(sval) 8791 + } 8792 + // t.References ([]string) (slice) 8793 + case "references": 8794 + 8795 + maj, extra, err = cr.ReadHeader() 8796 + if err != nil { 8797 + return err 8798 + } 8799 + 8800 + if extra > 8192 { 8801 + return fmt.Errorf("t.References: array too large (%d)", extra) 8802 + } 8803 + 8804 + if maj != cbg.MajArray { 8805 + return fmt.Errorf("expected cbor array") 8806 + } 8807 + 8808 + if extra > 0 { 8809 + t.References = make([]string, extra) 8810 + } 8811 + 8812 + for i := 0; i < int(extra); i++ { 8813 + { 8814 + var maj byte 8815 + var extra uint64 8816 + var err error 8817 + _ = maj 8818 + _ = extra 8819 + _ = err 8820 + 8821 + { 8822 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8823 + if err != nil { 8824 + return err 8825 + } 8826 + 8827 + t.References[i] = string(sval) 8828 + } 8829 + 8830 + } 7937 8831 } 7938 8832 7939 8833 default:
+7 -5
api/tangled/issuecomment.go
··· 17 17 } // 18 18 // RECORDTYPE: RepoIssueComment 19 19 type RepoIssueComment struct { 20 - LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue.comment" cborgen:"$type,const=sh.tangled.repo.issue.comment"` 21 - Body string `json:"body" cborgen:"body"` 22 - CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 - Issue string `json:"issue" cborgen:"issue"` 24 - ReplyTo *string `json:"replyTo,omitempty" cborgen:"replyTo,omitempty"` 20 + LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue.comment" cborgen:"$type,const=sh.tangled.repo.issue.comment"` 21 + Body string `json:"body" cborgen:"body"` 22 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 + Issue string `json:"issue" cborgen:"issue"` 24 + Mentions []string `json:"mentions,omitempty" cborgen:"mentions,omitempty"` 25 + References []string `json:"references,omitempty" cborgen:"references,omitempty"` 26 + ReplyTo *string `json:"replyTo,omitempty" cborgen:"replyTo,omitempty"` 25 27 }
+6 -4
api/tangled/pullcomment.go
··· 17 17 } // 18 18 // RECORDTYPE: RepoPullComment 19 19 type RepoPullComment struct { 20 - LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull.comment" cborgen:"$type,const=sh.tangled.repo.pull.comment"` 21 - Body string `json:"body" cborgen:"body"` 22 - CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 - Pull string `json:"pull" cborgen:"pull"` 20 + LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull.comment" cborgen:"$type,const=sh.tangled.repo.pull.comment"` 21 + Body string `json:"body" cborgen:"body"` 22 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 + Mentions []string `json:"mentions,omitempty" cborgen:"mentions,omitempty"` 24 + Pull string `json:"pull" cborgen:"pull"` 25 + References []string `json:"references,omitempty" cborgen:"references,omitempty"` 24 26 }
+13 -1
api/tangled/repoblob.go
··· 30 30 // RepoBlob_Output is the output of a sh.tangled.repo.blob call. 31 31 type RepoBlob_Output struct { 32 32 // content: File content (base64 encoded for binary files) 33 - Content string `json:"content" cborgen:"content"` 33 + Content *string `json:"content,omitempty" cborgen:"content,omitempty"` 34 34 // encoding: Content encoding 35 35 Encoding *string `json:"encoding,omitempty" cborgen:"encoding,omitempty"` 36 36 // isBinary: Whether the file is binary ··· 44 44 Ref string `json:"ref" cborgen:"ref"` 45 45 // size: File size in bytes 46 46 Size *int64 `json:"size,omitempty" cborgen:"size,omitempty"` 47 + // submodule: Submodule information if path is a submodule 48 + Submodule *RepoBlob_Submodule `json:"submodule,omitempty" cborgen:"submodule,omitempty"` 47 49 } 48 50 49 51 // RepoBlob_Signature is a "signature" in the sh.tangled.repo.blob schema. ··· 54 56 Name string `json:"name" cborgen:"name"` 55 57 // when: Author timestamp 56 58 When string `json:"when" cborgen:"when"` 59 + } 60 + 61 + // RepoBlob_Submodule is a "submodule" in the sh.tangled.repo.blob schema. 62 + type RepoBlob_Submodule struct { 63 + // branch: Branch to track in the submodule 64 + Branch *string `json:"branch,omitempty" cborgen:"branch,omitempty"` 65 + // name: Submodule name 66 + Name string `json:"name" cborgen:"name"` 67 + // url: Submodule repository URL 68 + Url string `json:"url" cborgen:"url"` 57 69 } 58 70 59 71 // RepoBlob calls the XRPC method "sh.tangled.repo.blob".
+7 -5
api/tangled/repoissue.go
··· 17 17 } // 18 18 // RECORDTYPE: RepoIssue 19 19 type RepoIssue struct { 20 - LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue" cborgen:"$type,const=sh.tangled.repo.issue"` 21 - Body *string `json:"body,omitempty" cborgen:"body,omitempty"` 22 - CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 - Repo string `json:"repo" cborgen:"repo"` 24 - Title string `json:"title" cborgen:"title"` 20 + LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue" cborgen:"$type,const=sh.tangled.repo.issue"` 21 + Body *string `json:"body,omitempty" cborgen:"body,omitempty"` 22 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 + Mentions []string `json:"mentions,omitempty" cborgen:"mentions,omitempty"` 24 + References []string `json:"references,omitempty" cborgen:"references,omitempty"` 25 + Repo string `json:"repo" cborgen:"repo"` 26 + Title string `json:"title" cborgen:"title"` 25 27 }
+12 -7
api/tangled/repopull.go
··· 17 17 } // 18 18 // RECORDTYPE: RepoPull 19 19 type RepoPull struct { 20 - LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull" cborgen:"$type,const=sh.tangled.repo.pull"` 21 - Body *string `json:"body,omitempty" cborgen:"body,omitempty"` 22 - CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 - Patch string `json:"patch" cborgen:"patch"` 24 - Source *RepoPull_Source `json:"source,omitempty" cborgen:"source,omitempty"` 25 - Target *RepoPull_Target `json:"target" cborgen:"target"` 26 - Title string `json:"title" cborgen:"title"` 20 + LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull" cborgen:"$type,const=sh.tangled.repo.pull"` 21 + Body *string `json:"body,omitempty" cborgen:"body,omitempty"` 22 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 + Mentions []string `json:"mentions,omitempty" cborgen:"mentions,omitempty"` 24 + // patch: (deprecated) use patchBlob instead 25 + Patch *string `json:"patch,omitempty" cborgen:"patch,omitempty"` 26 + // patchBlob: patch content 27 + PatchBlob *util.LexBlob `json:"patchBlob" cborgen:"patchBlob"` 28 + References []string `json:"references,omitempty" cborgen:"references,omitempty"` 29 + Source *RepoPull_Source `json:"source,omitempty" cborgen:"source,omitempty"` 30 + Target *RepoPull_Target `json:"target" cborgen:"target"` 31 + Title string `json:"title" cborgen:"title"` 27 32 } 28 33 29 34 // RepoPull_Source is a "source" in the sh.tangled.repo.pull schema.
-4
api/tangled/repotree.go
··· 47 47 48 48 // RepoTree_TreeEntry is a "treeEntry" in the sh.tangled.repo.tree schema. 49 49 type RepoTree_TreeEntry struct { 50 - // is_file: Whether this entry is a file 51 - Is_file bool `json:"is_file" cborgen:"is_file"` 52 - // is_subtree: Whether this entry is a directory/subtree 53 - Is_subtree bool `json:"is_subtree" cborgen:"is_subtree"` 54 50 Last_commit *RepoTree_LastCommit `json:"last_commit,omitempty" cborgen:"last_commit,omitempty"` 55 51 // mode: File mode 56 52 Mode string `json:"mode" cborgen:"mode"`
+4
api/tangled/tangledrepo.go
··· 30 30 Source *string `json:"source,omitempty" cborgen:"source,omitempty"` 31 31 // spindle: CI runner to send jobs to and receive results from 32 32 Spindle *string `json:"spindle,omitempty" cborgen:"spindle,omitempty"` 33 + // topics: Topics related to the repo 34 + Topics []string `json:"topics,omitempty" cborgen:"topics,omitempty"` 35 + // website: Any URI related to the repo 36 + Website *string `json:"website,omitempty" cborgen:"website,omitempty"` 33 37 }
+6 -45
appview/commitverify/verify.go
··· 3 3 import ( 4 4 "log" 5 5 6 - "github.com/go-git/go-git/v5/plumbing/object" 7 6 "tangled.org/core/appview/db" 8 7 "tangled.org/core/appview/models" 9 8 "tangled.org/core/crypto" ··· 35 34 return "" 36 35 } 37 36 38 - func GetVerifiedObjectCommits(e db.Execer, emailToDid map[string]string, commits []*object.Commit) (VerifiedCommits, error) { 39 - ndCommits := []types.NiceDiff{} 40 - for _, commit := range commits { 41 - ndCommits = append(ndCommits, ObjectCommitToNiceDiff(commit)) 42 - } 43 - return GetVerifiedCommits(e, emailToDid, ndCommits) 44 - } 45 - 46 - func GetVerifiedCommits(e db.Execer, emailToDid map[string]string, ndCommits []types.NiceDiff) (VerifiedCommits, error) { 37 + func GetVerifiedCommits(e db.Execer, emailToDid map[string]string, ndCommits []types.Commit) (VerifiedCommits, error) { 47 38 vcs := VerifiedCommits{} 48 39 49 40 didPubkeyCache := make(map[string][]models.PublicKey) 50 41 51 42 for _, commit := range ndCommits { 52 - c := commit.Commit 53 - 54 - committerEmail := c.Committer.Email 43 + committerEmail := commit.Committer.Email 55 44 if did, exists := emailToDid[committerEmail]; exists { 56 45 // check if we've already fetched public keys for this did 57 46 pubKeys, ok := didPubkeyCache[did] ··· 67 56 } 68 57 69 58 // try to verify with any associated pubkeys 59 + payload := commit.Payload() 60 + signature := commit.PGPSignature 70 61 for _, pk := range pubKeys { 71 - if _, ok := crypto.VerifyCommitSignature(pk.Key, commit); ok { 62 + if _, ok := crypto.VerifySignature([]byte(pk.Key), []byte(signature), []byte(payload)); ok { 72 63 73 64 fp, err := crypto.SSHFingerprint(pk.Key) 74 65 if err != nil { 75 66 log.Println("error computing ssh fingerprint:", err) 76 67 } 77 68 78 - vc := verifiedCommit{fingerprint: fp, hash: c.This} 69 + vc := verifiedCommit{fingerprint: fp, hash: commit.This} 79 70 vcs[vc] = struct{}{} 80 71 break 81 72 } ··· 86 77 87 78 return vcs, nil 88 79 } 89 - 90 - // ObjectCommitToNiceDiff is a compatibility function to convert a 91 - // commit object into a NiceDiff structure. 92 - func ObjectCommitToNiceDiff(c *object.Commit) types.NiceDiff { 93 - var niceDiff types.NiceDiff 94 - 95 - // set commit information 96 - niceDiff.Commit.Message = c.Message 97 - niceDiff.Commit.Author = c.Author 98 - niceDiff.Commit.This = c.Hash.String() 99 - niceDiff.Commit.Committer = c.Committer 100 - niceDiff.Commit.Tree = c.TreeHash.String() 101 - niceDiff.Commit.PGPSignature = c.PGPSignature 102 - 103 - changeId, ok := c.ExtraHeaders["change-id"] 104 - if ok { 105 - niceDiff.Commit.ChangedId = string(changeId) 106 - } 107 - 108 - // set parent hash if available 109 - if len(c.ParentHashes) > 0 { 110 - niceDiff.Commit.Parent = c.ParentHashes[0].String() 111 - } 112 - 113 - // XXX: Stats and Diff fields are typically populated 114 - // after fetching the actual diff information, which isn't 115 - // directly available in the commit object itself. 116 - 117 - return niceDiff 118 - }
+11
appview/config/config.go
··· 30 30 ClientKid string `env:"CLIENT_KID"` 31 31 } 32 32 33 + type PlcConfig struct { 34 + PLCURL string `env:"URL, default=https://plc.directory"` 35 + } 36 + 33 37 type JetstreamConfig struct { 34 38 Endpoint string `env:"ENDPOINT, default=wss://jetstream1.us-east.bsky.network/subscribe"` 35 39 } ··· 80 84 TurnstileSecretKey string `env:"TURNSTILE_SECRET_KEY"` 81 85 } 82 86 87 + type LabelConfig struct { 88 + DefaultLabelDefs []string `env:"DEFAULTS, 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"` // delimiter=, 89 + GoodFirstIssue string `env:"GFI, default=at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/good-first-issue"` 90 + } 91 + 83 92 func (cfg RedisConfig) ToURL() string { 84 93 u := &url.URL{ 85 94 Scheme: "redis", ··· 105 114 Avatar AvatarConfig `env:",prefix=TANGLED_AVATAR_"` 106 115 OAuth OAuthConfig `env:",prefix=TANGLED_OAUTH_"` 107 116 Redis RedisConfig `env:",prefix=TANGLED_REDIS_"` 117 + Plc PlcConfig `env:",prefix=TANGLED_PLC_"` 108 118 Pds PdsConfig `env:",prefix=TANGLED_PDS_"` 109 119 Cloudflare Cloudflare `env:",prefix=TANGLED_CLOUDFLARE_"` 120 + Label LabelConfig `env:",prefix=TANGLED_LABEL_"` 110 121 } 111 122 112 123 func LoadConfig(ctx context.Context) (*Config, error) {
+3 -2
appview/db/artifact.go
··· 8 8 "github.com/go-git/go-git/v5/plumbing" 9 9 "github.com/ipfs/go-cid" 10 10 "tangled.org/core/appview/models" 11 + "tangled.org/core/orm" 11 12 ) 12 13 13 14 func AddArtifact(e Execer, artifact models.Artifact) error { ··· 37 38 return err 38 39 } 39 40 40 - func GetArtifact(e Execer, filters ...filter) ([]models.Artifact, error) { 41 + func GetArtifact(e Execer, filters ...orm.Filter) ([]models.Artifact, error) { 41 42 var artifacts []models.Artifact 42 43 43 44 var conditions []string ··· 109 110 return artifacts, nil 110 111 } 111 112 112 - func DeleteArtifact(e Execer, filters ...filter) error { 113 + func DeleteArtifact(e Execer, filters ...orm.Filter) error { 113 114 var conditions []string 114 115 var args []any 115 116 for _, filter := range filters {
+4 -3
appview/db/collaborators.go
··· 6 6 "time" 7 7 8 8 "tangled.org/core/appview/models" 9 + "tangled.org/core/orm" 9 10 ) 10 11 11 12 func AddCollaborator(e Execer, c models.Collaborator) error { ··· 16 17 return err 17 18 } 18 19 19 - func DeleteCollaborator(e Execer, filters ...filter) error { 20 + func DeleteCollaborator(e Execer, filters ...orm.Filter) error { 20 21 var conditions []string 21 22 var args []any 22 23 for _, filter := range filters { ··· 58 59 return nil, nil 59 60 } 60 61 61 - return GetRepos(e, 0, FilterIn("at_uri", repoAts)) 62 + return GetRepos(e, 0, orm.FilterIn("at_uri", repoAts)) 62 63 } 63 64 64 - func GetCollaborators(e Execer, filters ...filter) ([]models.Collaborator, error) { 65 + func GetCollaborators(e Execer, filters ...orm.Filter) ([]models.Collaborator, error) { 65 66 var collaborators []models.Collaborator 66 67 var conditions []string 67 68 var args []any
+85 -130
appview/db/db.go
··· 3 3 import ( 4 4 "context" 5 5 "database/sql" 6 - "fmt" 7 6 "log/slog" 8 - "reflect" 9 7 "strings" 10 8 11 9 _ "github.com/mattn/go-sqlite3" 12 10 "tangled.org/core/log" 11 + "tangled.org/core/orm" 13 12 ) 14 13 15 14 type DB struct { ··· 561 560 email_notifications integer not null default 0 562 561 ); 563 562 563 + create table if not exists reference_links ( 564 + id integer primary key autoincrement, 565 + from_at text not null, 566 + to_at text not null, 567 + unique (from_at, to_at) 568 + ); 569 + 564 570 create table if not exists migrations ( 565 571 id integer primary key autoincrement, 566 572 name text unique ··· 569 575 -- indexes for better performance 570 576 create index if not exists idx_notifications_recipient_created on notifications(recipient_did, created desc); 571 577 create index if not exists idx_notifications_recipient_read on notifications(recipient_did, read); 572 - create index if not exists idx_stars_created on stars(created); 573 - create index if not exists idx_stars_repo_at_created on stars(repo_at, created); 578 + create index if not exists idx_references_from_at on reference_links(from_at); 579 + create index if not exists idx_references_to_at on reference_links(to_at); 574 580 `) 575 581 if err != nil { 576 582 return nil, err 577 583 } 578 584 579 585 // run migrations 580 - runMigration(conn, logger, "add-description-to-repos", func(tx *sql.Tx) error { 586 + orm.RunMigration(conn, logger, "add-description-to-repos", func(tx *sql.Tx) error { 581 587 tx.Exec(` 582 588 alter table repos add column description text check (length(description) <= 200); 583 589 `) 584 590 return nil 585 591 }) 586 592 587 - runMigration(conn, logger, "add-rkey-to-pubkeys", func(tx *sql.Tx) error { 593 + orm.RunMigration(conn, logger, "add-rkey-to-pubkeys", func(tx *sql.Tx) error { 588 594 // add unconstrained column 589 595 _, err := tx.Exec(` 590 596 alter table public_keys ··· 607 613 return nil 608 614 }) 609 615 610 - runMigration(conn, logger, "add-rkey-to-comments", func(tx *sql.Tx) error { 616 + orm.RunMigration(conn, logger, "add-rkey-to-comments", func(tx *sql.Tx) error { 611 617 _, err := tx.Exec(` 612 618 alter table comments drop column comment_at; 613 619 alter table comments add column rkey text; ··· 615 621 return err 616 622 }) 617 623 618 - runMigration(conn, logger, "add-deleted-and-edited-to-issue-comments", func(tx *sql.Tx) error { 624 + orm.RunMigration(conn, logger, "add-deleted-and-edited-to-issue-comments", func(tx *sql.Tx) error { 619 625 _, err := tx.Exec(` 620 626 alter table comments add column deleted text; -- timestamp 621 627 alter table comments add column edited text; -- timestamp ··· 623 629 return err 624 630 }) 625 631 626 - runMigration(conn, logger, "add-source-info-to-pulls-and-submissions", func(tx *sql.Tx) error { 632 + orm.RunMigration(conn, logger, "add-source-info-to-pulls-and-submissions", func(tx *sql.Tx) error { 627 633 _, err := tx.Exec(` 628 634 alter table pulls add column source_branch text; 629 635 alter table pulls add column source_repo_at text; ··· 632 638 return err 633 639 }) 634 640 635 - runMigration(conn, logger, "add-source-to-repos", func(tx *sql.Tx) error { 641 + orm.RunMigration(conn, logger, "add-source-to-repos", func(tx *sql.Tx) error { 636 642 _, err := tx.Exec(` 637 643 alter table repos add column source text; 638 644 `) ··· 644 650 // 645 651 // [0]: https://sqlite.org/pragma.html#pragma_foreign_keys 646 652 conn.ExecContext(ctx, "pragma foreign_keys = off;") 647 - runMigration(conn, logger, "recreate-pulls-column-for-stacking-support", func(tx *sql.Tx) error { 653 + orm.RunMigration(conn, logger, "recreate-pulls-column-for-stacking-support", func(tx *sql.Tx) error { 648 654 _, err := tx.Exec(` 649 655 create table pulls_new ( 650 656 -- identifiers ··· 701 707 }) 702 708 conn.ExecContext(ctx, "pragma foreign_keys = on;") 703 709 704 - runMigration(conn, logger, "add-spindle-to-repos", func(tx *sql.Tx) error { 710 + orm.RunMigration(conn, logger, "add-spindle-to-repos", func(tx *sql.Tx) error { 705 711 tx.Exec(` 706 712 alter table repos add column spindle text; 707 713 `) ··· 711 717 // drop all knot secrets, add unique constraint to knots 712 718 // 713 719 // knots will henceforth use service auth for signed requests 714 - runMigration(conn, logger, "no-more-secrets", func(tx *sql.Tx) error { 720 + orm.RunMigration(conn, logger, "no-more-secrets", func(tx *sql.Tx) error { 715 721 _, err := tx.Exec(` 716 722 create table registrations_new ( 717 723 id integer primary key autoincrement, ··· 734 740 }) 735 741 736 742 // recreate and add rkey + created columns with default constraint 737 - runMigration(conn, logger, "rework-collaborators-table", func(tx *sql.Tx) error { 743 + orm.RunMigration(conn, logger, "rework-collaborators-table", func(tx *sql.Tx) error { 738 744 // create new table 739 745 // - repo_at instead of repo integer 740 746 // - rkey field ··· 788 794 return err 789 795 }) 790 796 791 - runMigration(conn, logger, "add-rkey-to-issues", func(tx *sql.Tx) error { 797 + orm.RunMigration(conn, logger, "add-rkey-to-issues", func(tx *sql.Tx) error { 792 798 _, err := tx.Exec(` 793 799 alter table issues add column rkey text not null default ''; 794 800 ··· 800 806 }) 801 807 802 808 // repurpose the read-only column to "needs-upgrade" 803 - runMigration(conn, logger, "rename-registrations-read-only-to-needs-upgrade", func(tx *sql.Tx) error { 809 + orm.RunMigration(conn, logger, "rename-registrations-read-only-to-needs-upgrade", func(tx *sql.Tx) error { 804 810 _, err := tx.Exec(` 805 811 alter table registrations rename column read_only to needs_upgrade; 806 812 `) ··· 808 814 }) 809 815 810 816 // require all knots to upgrade after the release of total xrpc 811 - runMigration(conn, logger, "migrate-knots-to-total-xrpc", func(tx *sql.Tx) error { 817 + orm.RunMigration(conn, logger, "migrate-knots-to-total-xrpc", func(tx *sql.Tx) error { 812 818 _, err := tx.Exec(` 813 819 update registrations set needs_upgrade = 1; 814 820 `) ··· 816 822 }) 817 823 818 824 // require all knots to upgrade after the release of total xrpc 819 - runMigration(conn, logger, "migrate-spindles-to-xrpc-owner", func(tx *sql.Tx) error { 825 + orm.RunMigration(conn, logger, "migrate-spindles-to-xrpc-owner", func(tx *sql.Tx) error { 820 826 _, err := tx.Exec(` 821 827 alter table spindles add column needs_upgrade integer not null default 0; 822 828 `) ··· 834 840 // 835 841 // disable foreign-keys for the next migration 836 842 conn.ExecContext(ctx, "pragma foreign_keys = off;") 837 - runMigration(conn, logger, "remove-issue-at-from-issues", func(tx *sql.Tx) error { 843 + orm.RunMigration(conn, logger, "remove-issue-at-from-issues", func(tx *sql.Tx) error { 838 844 _, err := tx.Exec(` 839 845 create table if not exists issues_new ( 840 846 -- identifiers ··· 904 910 // - new columns 905 911 // * column "reply_to" which can be any other comment 906 912 // * column "at-uri" which is a generated column 907 - runMigration(conn, logger, "rework-issue-comments", func(tx *sql.Tx) error { 913 + orm.RunMigration(conn, logger, "rework-issue-comments", func(tx *sql.Tx) error { 908 914 _, err := tx.Exec(` 909 915 create table if not exists issue_comments ( 910 916 -- identifiers ··· 964 970 // 965 971 // disable foreign-keys for the next migration 966 972 conn.ExecContext(ctx, "pragma foreign_keys = off;") 967 - runMigration(conn, logger, "add-at-uri-to-pulls", func(tx *sql.Tx) error { 973 + orm.RunMigration(conn, logger, "add-at-uri-to-pulls", func(tx *sql.Tx) error { 968 974 _, err := tx.Exec(` 969 975 create table if not exists pulls_new ( 970 976 -- identifiers ··· 1045 1051 // 1046 1052 // disable foreign-keys for the next migration 1047 1053 conn.ExecContext(ctx, "pragma foreign_keys = off;") 1048 - runMigration(conn, logger, "remove-repo-at-pull-id-from-pull-submissions", func(tx *sql.Tx) error { 1054 + orm.RunMigration(conn, logger, "remove-repo-at-pull-id-from-pull-submissions", func(tx *sql.Tx) error { 1049 1055 _, err := tx.Exec(` 1050 1056 create table if not exists pull_submissions_new ( 1051 1057 -- identifiers ··· 1099 1105 1100 1106 // knots may report the combined patch for a comparison, we can store that on the appview side 1101 1107 // (but not on the pds record), because calculating the combined patch requires a git index 1102 - runMigration(conn, logger, "add-combined-column-submissions", func(tx *sql.Tx) error { 1108 + orm.RunMigration(conn, logger, "add-combined-column-submissions", func(tx *sql.Tx) error { 1103 1109 _, err := tx.Exec(` 1104 1110 alter table pull_submissions add column combined text; 1105 1111 `) 1106 1112 return err 1107 1113 }) 1108 1114 1109 - return &DB{ 1110 - db, 1111 - logger, 1112 - }, nil 1113 - } 1115 + orm.RunMigration(conn, logger, "add-pronouns-profile", func(tx *sql.Tx) error { 1116 + _, err := tx.Exec(` 1117 + alter table profile add column pronouns text; 1118 + `) 1119 + return err 1120 + }) 1114 1121 1115 - type migrationFn = func(*sql.Tx) error 1116 - 1117 - func runMigration(c *sql.Conn, logger *slog.Logger, name string, migrationFn migrationFn) error { 1118 - logger = logger.With("migration", name) 1119 - 1120 - tx, err := c.BeginTx(context.Background(), nil) 1121 - if err != nil { 1122 + orm.RunMigration(conn, logger, "add-meta-column-repos", func(tx *sql.Tx) error { 1123 + _, err := tx.Exec(` 1124 + alter table repos add column website text; 1125 + alter table repos add column topics text; 1126 + `) 1122 1127 return err 1123 - } 1124 - defer tx.Rollback() 1128 + }) 1125 1129 1126 - var exists bool 1127 - err = tx.QueryRow("select exists (select 1 from migrations where name = ?)", name).Scan(&exists) 1128 - if err != nil { 1130 + orm.RunMigration(conn, logger, "add-usermentioned-preference", func(tx *sql.Tx) error { 1131 + _, err := tx.Exec(` 1132 + alter table notification_preferences add column user_mentioned integer not null default 1; 1133 + `) 1129 1134 return err 1130 - } 1135 + }) 1136 + 1137 + // remove the foreign key constraints from stars. 1138 + orm.RunMigration(conn, logger, "generalize-stars-subject", func(tx *sql.Tx) error { 1139 + _, err := tx.Exec(` 1140 + create table stars_new ( 1141 + id integer primary key autoincrement, 1142 + did text not null, 1143 + rkey text not null, 1144 + 1145 + subject_at text not null, 1131 1146 1132 - if !exists { 1133 - // run migration 1134 - err = migrationFn(tx) 1135 - if err != nil { 1136 - logger.Error("failed to run migration", "err", err) 1137 - return err 1138 - } 1147 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 1148 + unique(did, rkey), 1149 + unique(did, subject_at) 1150 + ); 1139 1151 1140 - // mark migration as complete 1141 - _, err = tx.Exec("insert into migrations (name) values (?)", name) 1142 - if err != nil { 1143 - logger.Error("failed to mark migration as complete", "err", err) 1144 - return err 1145 - } 1152 + insert into stars_new ( 1153 + id, 1154 + did, 1155 + rkey, 1156 + subject_at, 1157 + created 1158 + ) 1159 + select 1160 + id, 1161 + starred_by_did, 1162 + rkey, 1163 + repo_at, 1164 + created 1165 + from stars; 1146 1166 1147 - // commit the transaction 1148 - if err := tx.Commit(); err != nil { 1149 - return err 1150 - } 1167 + drop table stars; 1168 + alter table stars_new rename to stars; 1151 1169 1152 - logger.Info("migration applied successfully") 1153 - } else { 1154 - logger.Warn("skipped migration, already applied") 1155 - } 1170 + create index if not exists idx_stars_created on stars(created); 1171 + create index if not exists idx_stars_subject_at_created on stars(subject_at, created); 1172 + `) 1173 + return err 1174 + }) 1156 1175 1157 - return nil 1176 + return &DB{ 1177 + db, 1178 + logger, 1179 + }, nil 1158 1180 } 1159 1181 1160 1182 func (d *DB) Close() error { 1161 1183 return d.DB.Close() 1162 1184 } 1163 - 1164 - type filter struct { 1165 - key string 1166 - arg any 1167 - cmp string 1168 - } 1169 - 1170 - func newFilter(key, cmp string, arg any) filter { 1171 - return filter{ 1172 - key: key, 1173 - arg: arg, 1174 - cmp: cmp, 1175 - } 1176 - } 1177 - 1178 - func FilterEq(key string, arg any) filter { return newFilter(key, "=", arg) } 1179 - func FilterNotEq(key string, arg any) filter { return newFilter(key, "<>", arg) } 1180 - func FilterGte(key string, arg any) filter { return newFilter(key, ">=", arg) } 1181 - func FilterLte(key string, arg any) filter { return newFilter(key, "<=", arg) } 1182 - func FilterIs(key string, arg any) filter { return newFilter(key, "is", arg) } 1183 - func FilterIsNot(key string, arg any) filter { return newFilter(key, "is not", arg) } 1184 - func FilterIn(key string, arg any) filter { return newFilter(key, "in", arg) } 1185 - func FilterLike(key string, arg any) filter { return newFilter(key, "like", arg) } 1186 - func FilterNotLike(key string, arg any) filter { return newFilter(key, "not like", arg) } 1187 - func FilterContains(key string, arg any) filter { 1188 - return newFilter(key, "like", fmt.Sprintf("%%%v%%", arg)) 1189 - } 1190 - 1191 - func (f filter) Condition() string { 1192 - rv := reflect.ValueOf(f.arg) 1193 - kind := rv.Kind() 1194 - 1195 - // if we have `FilterIn(k, [1, 2, 3])`, compile it down to `k in (?, ?, ?)` 1196 - if (kind == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8) || kind == reflect.Array { 1197 - if rv.Len() == 0 { 1198 - // always false 1199 - return "1 = 0" 1200 - } 1201 - 1202 - placeholders := make([]string, rv.Len()) 1203 - for i := range placeholders { 1204 - placeholders[i] = "?" 1205 - } 1206 - 1207 - return fmt.Sprintf("%s %s (%s)", f.key, f.cmp, strings.Join(placeholders, ", ")) 1208 - } 1209 - 1210 - return fmt.Sprintf("%s %s ?", f.key, f.cmp) 1211 - } 1212 - 1213 - func (f filter) Arg() []any { 1214 - rv := reflect.ValueOf(f.arg) 1215 - kind := rv.Kind() 1216 - if (kind == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8) || kind == reflect.Array { 1217 - if rv.Len() == 0 { 1218 - return nil 1219 - } 1220 - 1221 - out := make([]any, rv.Len()) 1222 - for i := range rv.Len() { 1223 - out[i] = rv.Index(i).Interface() 1224 - } 1225 - return out 1226 - } 1227 - 1228 - return []any{f.arg} 1229 - }
+6 -3
appview/db/follow.go
··· 7 7 "time" 8 8 9 9 "tangled.org/core/appview/models" 10 + "tangled.org/core/orm" 10 11 ) 11 12 12 13 func AddFollow(e Execer, follow *models.Follow) error { ··· 134 135 return result, nil 135 136 } 136 137 137 - func GetFollows(e Execer, limit int, filters ...filter) ([]models.Follow, error) { 138 + func GetFollows(e Execer, limit int, filters ...orm.Filter) ([]models.Follow, error) { 138 139 var follows []models.Follow 139 140 140 141 var conditions []string ··· 166 167 if err != nil { 167 168 return nil, err 168 169 } 170 + defer rows.Close() 171 + 169 172 for rows.Next() { 170 173 var follow models.Follow 171 174 var followedAt string ··· 191 194 } 192 195 193 196 func GetFollowers(e Execer, did string) ([]models.Follow, error) { 194 - return GetFollows(e, 0, FilterEq("subject_did", did)) 197 + return GetFollows(e, 0, orm.FilterEq("subject_did", did)) 195 198 } 196 199 197 200 func GetFollowing(e Execer, did string) ([]models.Follow, error) { 198 - return GetFollows(e, 0, FilterEq("user_did", did)) 201 + return GetFollows(e, 0, orm.FilterEq("user_did", did)) 199 202 } 200 203 201 204 func getFollowStatuses(e Execer, userDid string, subjectDids []string) (map[string]models.FollowStatus, error) {
+108 -34
appview/db/issues.go
··· 10 10 "time" 11 11 12 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 + "tangled.org/core/api/tangled" 13 14 "tangled.org/core/appview/models" 14 15 "tangled.org/core/appview/pagination" 16 + "tangled.org/core/orm" 15 17 ) 16 18 17 19 func PutIssue(tx *sql.Tx, issue *models.Issue) error { ··· 26 28 27 29 issues, err := GetIssues( 28 30 tx, 29 - FilterEq("did", issue.Did), 30 - FilterEq("rkey", issue.Rkey), 31 + orm.FilterEq("did", issue.Did), 32 + orm.FilterEq("rkey", issue.Rkey), 31 33 ) 32 34 switch { 33 35 case err != nil: ··· 69 71 returning rowid, issue_id 70 72 `, issue.RepoAt, issue.Did, issue.Rkey, newIssueId, issue.Title, issue.Body) 71 73 72 - return row.Scan(&issue.Id, &issue.IssueId) 74 + err = row.Scan(&issue.Id, &issue.IssueId) 75 + if err != nil { 76 + return fmt.Errorf("scan row: %w", err) 77 + } 78 + 79 + if err := putReferences(tx, issue.AtUri(), issue.References); err != nil { 80 + return fmt.Errorf("put reference_links: %w", err) 81 + } 82 + return nil 73 83 } 74 84 75 85 func updateIssue(tx *sql.Tx, issue *models.Issue) error { ··· 79 89 set title = ?, body = ?, edited = ? 80 90 where did = ? and rkey = ? 81 91 `, issue.Title, issue.Body, time.Now().Format(time.RFC3339), issue.Did, issue.Rkey) 82 - return err 92 + if err != nil { 93 + return err 94 + } 95 + 96 + if err := putReferences(tx, issue.AtUri(), issue.References); err != nil { 97 + return fmt.Errorf("put reference_links: %w", err) 98 + } 99 + return nil 83 100 } 84 101 85 - func GetIssuesPaginated(e Execer, page pagination.Page, filters ...filter) ([]models.Issue, error) { 102 + func GetIssuesPaginated(e Execer, page pagination.Page, filters ...orm.Filter) ([]models.Issue, error) { 86 103 issueMap := make(map[string]*models.Issue) // at-uri -> issue 87 104 88 105 var conditions []string ··· 98 115 whereClause = " where " + strings.Join(conditions, " and ") 99 116 } 100 117 101 - pLower := FilterGte("row_num", page.Offset+1) 102 - pUpper := FilterLte("row_num", page.Offset+page.Limit) 118 + pLower := orm.FilterGte("row_num", page.Offset+1) 119 + pUpper := orm.FilterLte("row_num", page.Offset+page.Limit) 103 120 104 121 pageClause := "" 105 122 if page.Limit > 0 { ··· 189 206 repoAts = append(repoAts, string(issue.RepoAt)) 190 207 } 191 208 192 - repos, err := GetRepos(e, 0, FilterIn("at_uri", repoAts)) 209 + repos, err := GetRepos(e, 0, orm.FilterIn("at_uri", repoAts)) 193 210 if err != nil { 194 211 return nil, fmt.Errorf("failed to build repo mappings: %w", err) 195 212 } ··· 212 229 // collect comments 213 230 issueAts := slices.Collect(maps.Keys(issueMap)) 214 231 215 - comments, err := GetIssueComments(e, FilterIn("issue_at", issueAts)) 232 + comments, err := GetIssueComments(e, orm.FilterIn("issue_at", issueAts)) 216 233 if err != nil { 217 234 return nil, fmt.Errorf("failed to query comments: %w", err) 218 235 } ··· 224 241 } 225 242 226 243 // collect allLabels for each issue 227 - allLabels, err := GetLabels(e, FilterIn("subject", issueAts)) 244 + allLabels, err := GetLabels(e, orm.FilterIn("subject", issueAts)) 228 245 if err != nil { 229 246 return nil, fmt.Errorf("failed to query labels: %w", err) 230 247 } ··· 234 251 } 235 252 } 236 253 254 + // collect references for each issue 255 + allReferencs, err := GetReferencesAll(e, orm.FilterIn("from_at", issueAts)) 256 + if err != nil { 257 + return nil, fmt.Errorf("failed to query reference_links: %w", err) 258 + } 259 + for issueAt, references := range allReferencs { 260 + if issue, ok := issueMap[issueAt.String()]; ok { 261 + issue.References = references 262 + } 263 + } 264 + 237 265 var issues []models.Issue 238 266 for _, i := range issueMap { 239 267 issues = append(issues, *i) ··· 246 274 return issues, nil 247 275 } 248 276 249 - func GetIssues(e Execer, filters ...filter) ([]models.Issue, error) { 277 + func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*models.Issue, error) { 278 + issues, err := GetIssuesPaginated( 279 + e, 280 + pagination.Page{}, 281 + orm.FilterEq("repo_at", repoAt), 282 + orm.FilterEq("issue_id", issueId), 283 + ) 284 + if err != nil { 285 + return nil, err 286 + } 287 + if len(issues) != 1 { 288 + return nil, sql.ErrNoRows 289 + } 290 + 291 + return &issues[0], nil 292 + } 293 + 294 + func GetIssues(e Execer, filters ...orm.Filter) ([]models.Issue, error) { 250 295 return GetIssuesPaginated(e, pagination.Page{}, filters...) 251 296 } 252 297 ··· 254 299 func GetIssueIDs(e Execer, opts models.IssueSearchOptions) ([]int64, error) { 255 300 var ids []int64 256 301 257 - var filters []filter 302 + var filters []orm.Filter 258 303 openValue := 0 259 304 if opts.IsOpen { 260 305 openValue = 1 261 306 } 262 - filters = append(filters, FilterEq("open", openValue)) 307 + filters = append(filters, orm.FilterEq("open", openValue)) 263 308 if opts.RepoAt != "" { 264 - filters = append(filters, FilterEq("repo_at", opts.RepoAt)) 309 + filters = append(filters, orm.FilterEq("repo_at", opts.RepoAt)) 265 310 } 266 311 267 312 var conditions []string ··· 306 351 return ids, nil 307 352 } 308 353 309 - func AddIssueComment(e Execer, c models.IssueComment) (int64, error) { 310 - result, err := e.Exec( 354 + func AddIssueComment(tx *sql.Tx, c models.IssueComment) (int64, error) { 355 + result, err := tx.Exec( 311 356 `insert into issue_comments ( 312 357 did, 313 358 rkey, ··· 346 391 return 0, err 347 392 } 348 393 394 + if err := putReferences(tx, c.AtUri(), c.References); err != nil { 395 + return 0, fmt.Errorf("put reference_links: %w", err) 396 + } 397 + 349 398 return id, nil 350 399 } 351 400 352 - func DeleteIssueComments(e Execer, filters ...filter) error { 401 + func DeleteIssueComments(e Execer, filters ...orm.Filter) error { 353 402 var conditions []string 354 403 var args []any 355 404 for _, filter := range filters { ··· 368 417 return err 369 418 } 370 419 371 - func GetIssueComments(e Execer, filters ...filter) ([]models.IssueComment, error) { 372 - var comments []models.IssueComment 420 + func GetIssueComments(e Execer, filters ...orm.Filter) ([]models.IssueComment, error) { 421 + commentMap := make(map[string]*models.IssueComment) 373 422 374 423 var conditions []string 375 424 var args []any ··· 403 452 if err != nil { 404 453 return nil, err 405 454 } 455 + defer rows.Close() 406 456 407 457 for rows.Next() { 408 458 var comment models.IssueComment ··· 448 498 comment.ReplyTo = &replyTo.V 449 499 } 450 500 451 - comments = append(comments, comment) 501 + atUri := comment.AtUri().String() 502 + commentMap[atUri] = &comment 452 503 } 453 504 454 505 if err = rows.Err(); err != nil { 455 506 return nil, err 456 507 } 457 508 509 + // collect references for each comments 510 + commentAts := slices.Collect(maps.Keys(commentMap)) 511 + allReferencs, err := GetReferencesAll(e, orm.FilterIn("from_at", commentAts)) 512 + if err != nil { 513 + return nil, fmt.Errorf("failed to query reference_links: %w", err) 514 + } 515 + for commentAt, references := range allReferencs { 516 + if comment, ok := commentMap[commentAt.String()]; ok { 517 + comment.References = references 518 + } 519 + } 520 + 521 + var comments []models.IssueComment 522 + for _, c := range commentMap { 523 + comments = append(comments, *c) 524 + } 525 + 526 + sort.Slice(comments, func(i, j int) bool { 527 + return comments[i].Created.After(comments[j].Created) 528 + }) 529 + 458 530 return comments, nil 459 531 } 460 532 461 - func DeleteIssues(e Execer, filters ...filter) error { 462 - var conditions []string 463 - var args []any 464 - for _, filter := range filters { 465 - conditions = append(conditions, filter.Condition()) 466 - args = append(args, filter.Arg()...) 533 + func DeleteIssues(tx *sql.Tx, did, rkey string) error { 534 + _, err := tx.Exec( 535 + `delete from issues 536 + where did = ? and rkey = ?`, 537 + did, 538 + rkey, 539 + ) 540 + if err != nil { 541 + return fmt.Errorf("delete issue: %w", err) 467 542 } 468 543 469 - whereClause := "" 470 - if conditions != nil { 471 - whereClause = " where " + strings.Join(conditions, " and ") 544 + uri := syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", did, tangled.RepoIssueNSID, rkey)) 545 + err = deleteReferences(tx, uri) 546 + if err != nil { 547 + return fmt.Errorf("delete reference_links: %w", err) 472 548 } 473 549 474 - query := fmt.Sprintf(`delete from issues %s`, whereClause) 475 - _, err := e.Exec(query, args...) 476 - return err 550 + return nil 477 551 } 478 552 479 - func CloseIssues(e Execer, filters ...filter) error { 553 + func CloseIssues(e Execer, filters ...orm.Filter) error { 480 554 var conditions []string 481 555 var args []any 482 556 for _, filter := range filters { ··· 494 568 return err 495 569 } 496 570 497 - func ReopenIssues(e Execer, filters ...filter) error { 571 + func ReopenIssues(e Execer, filters ...orm.Filter) error { 498 572 var conditions []string 499 573 var args []any 500 574 for _, filter := range filters {
+8 -7
appview/db/label.go
··· 10 10 11 11 "github.com/bluesky-social/indigo/atproto/syntax" 12 12 "tangled.org/core/appview/models" 13 + "tangled.org/core/orm" 13 14 ) 14 15 15 16 // no updating type for now ··· 59 60 return id, nil 60 61 } 61 62 62 - func DeleteLabelDefinition(e Execer, filters ...filter) error { 63 + func DeleteLabelDefinition(e Execer, filters ...orm.Filter) error { 63 64 var conditions []string 64 65 var args []any 65 66 for _, filter := range filters { ··· 75 76 return err 76 77 } 77 78 78 - func GetLabelDefinitions(e Execer, filters ...filter) ([]models.LabelDefinition, error) { 79 + func GetLabelDefinitions(e Execer, filters ...orm.Filter) ([]models.LabelDefinition, error) { 79 80 var labelDefinitions []models.LabelDefinition 80 81 var conditions []string 81 82 var args []any ··· 167 168 } 168 169 169 170 // helper to get exactly one label def 170 - func GetLabelDefinition(e Execer, filters ...filter) (*models.LabelDefinition, error) { 171 + func GetLabelDefinition(e Execer, filters ...orm.Filter) (*models.LabelDefinition, error) { 171 172 labels, err := GetLabelDefinitions(e, filters...) 172 173 if err != nil { 173 174 return nil, err ··· 227 228 return id, nil 228 229 } 229 230 230 - func GetLabelOps(e Execer, filters ...filter) ([]models.LabelOp, error) { 231 + func GetLabelOps(e Execer, filters ...orm.Filter) ([]models.LabelOp, error) { 231 232 var labelOps []models.LabelOp 232 233 var conditions []string 233 234 var args []any ··· 302 303 } 303 304 304 305 // get labels for a given list of subject URIs 305 - func GetLabels(e Execer, filters ...filter) (map[syntax.ATURI]models.LabelState, error) { 306 + func GetLabels(e Execer, filters ...orm.Filter) (map[syntax.ATURI]models.LabelState, error) { 306 307 ops, err := GetLabelOps(e, filters...) 307 308 if err != nil { 308 309 return nil, err ··· 322 323 } 323 324 labelAts := slices.Collect(maps.Keys(labelAtSet)) 324 325 325 - actx, err := NewLabelApplicationCtx(e, FilterIn("at_uri", labelAts)) 326 + actx, err := NewLabelApplicationCtx(e, orm.FilterIn("at_uri", labelAts)) 326 327 if err != nil { 327 328 return nil, err 328 329 } ··· 338 339 return results, nil 339 340 } 340 341 341 - func NewLabelApplicationCtx(e Execer, filters ...filter) (*models.LabelApplicationCtx, error) { 342 + func NewLabelApplicationCtx(e Execer, filters ...orm.Filter) (*models.LabelApplicationCtx, error) { 342 343 labels, err := GetLabelDefinitions(e, filters...) 343 344 if err != nil { 344 345 return nil, err
+6 -5
appview/db/language.go
··· 7 7 8 8 "github.com/bluesky-social/indigo/atproto/syntax" 9 9 "tangled.org/core/appview/models" 10 + "tangled.org/core/orm" 10 11 ) 11 12 12 - func GetRepoLanguages(e Execer, filters ...filter) ([]models.RepoLanguage, error) { 13 + func GetRepoLanguages(e Execer, filters ...orm.Filter) ([]models.RepoLanguage, error) { 13 14 var conditions []string 14 15 var args []any 15 16 for _, filter := range filters { ··· 27 28 whereClause, 28 29 ) 29 30 rows, err := e.Query(query, args...) 30 - 31 31 if err != nil { 32 32 return nil, fmt.Errorf("failed to execute query: %w ", err) 33 33 } 34 + defer rows.Close() 34 35 35 36 var langs []models.RepoLanguage 36 37 for rows.Next() { ··· 85 86 return nil 86 87 } 87 88 88 - func DeleteRepoLanguages(e Execer, filters ...filter) error { 89 + func DeleteRepoLanguages(e Execer, filters ...orm.Filter) error { 89 90 var conditions []string 90 91 var args []any 91 92 for _, filter := range filters { ··· 107 108 func UpdateRepoLanguages(tx *sql.Tx, repoAt syntax.ATURI, ref string, langs []models.RepoLanguage) error { 108 109 err := DeleteRepoLanguages( 109 110 tx, 110 - FilterEq("repo_at", repoAt), 111 - FilterEq("ref", ref), 111 + orm.FilterEq("repo_at", repoAt), 112 + orm.FilterEq("ref", ref), 112 113 ) 113 114 if err != nil { 114 115 return fmt.Errorf("failed to delete existing languages: %w", err)
+29 -18
appview/db/notifications.go
··· 11 11 "github.com/bluesky-social/indigo/atproto/syntax" 12 12 "tangled.org/core/appview/models" 13 13 "tangled.org/core/appview/pagination" 14 + "tangled.org/core/orm" 14 15 ) 15 16 16 17 func CreateNotification(e Execer, notification *models.Notification) error { ··· 44 45 } 45 46 46 47 // GetNotificationsPaginated retrieves notifications with filters and pagination 47 - func GetNotificationsPaginated(e Execer, page pagination.Page, filters ...filter) ([]*models.Notification, error) { 48 + func GetNotificationsPaginated(e Execer, page pagination.Page, filters ...orm.Filter) ([]*models.Notification, error) { 48 49 var conditions []string 49 50 var args []any 50 51 ··· 113 114 } 114 115 115 116 // GetNotificationsWithEntities retrieves notifications with their related entities 116 - func GetNotificationsWithEntities(e Execer, page pagination.Page, filters ...filter) ([]*models.NotificationWithEntity, error) { 117 + func GetNotificationsWithEntities(e Execer, page pagination.Page, filters ...orm.Filter) ([]*models.NotificationWithEntity, error) { 117 118 var conditions []string 118 119 var args []any 119 120 ··· 134 135 select 135 136 n.id, n.recipient_did, n.actor_did, n.type, n.entity_type, n.entity_id, 136 137 n.read, n.created, n.repo_id, n.issue_id, n.pull_id, 137 - r.id as r_id, r.did as r_did, r.name as r_name, r.description as r_description, 138 + r.id as r_id, r.did as r_did, r.name as r_name, r.description as r_description, r.website as r_website, r.topics as r_topics, 138 139 i.id as i_id, i.did as i_did, i.issue_id as i_issue_id, i.title as i_title, i.open as i_open, 139 140 p.id as p_id, p.owner_did as p_owner_did, p.pull_id as p_pull_id, p.title as p_title, p.state as p_state 140 141 from notifications n ··· 163 164 var issue models.Issue 164 165 var pull models.Pull 165 166 var rId, iId, pId sql.NullInt64 166 - var rDid, rName, rDescription sql.NullString 167 + var rDid, rName, rDescription, rWebsite, rTopicStr sql.NullString 167 168 var iDid sql.NullString 168 169 var iIssueId sql.NullInt64 169 170 var iTitle sql.NullString ··· 176 177 err := rows.Scan( 177 178 &n.ID, &n.RecipientDid, &n.ActorDid, &typeStr, &n.EntityType, &n.EntityId, 178 179 &n.Read, &createdStr, &n.RepoId, &n.IssueId, &n.PullId, 179 - &rId, &rDid, &rName, &rDescription, 180 + &rId, &rDid, &rName, &rDescription, &rWebsite, &rTopicStr, 180 181 &iId, &iDid, &iIssueId, &iTitle, &iOpen, 181 182 &pId, &pOwnerDid, &pPullId, &pTitle, &pState, 182 183 ) ··· 204 205 if rDescription.Valid { 205 206 repo.Description = rDescription.String 206 207 } 208 + if rWebsite.Valid { 209 + repo.Website = rWebsite.String 210 + } 211 + if rTopicStr.Valid { 212 + repo.Topics = strings.Fields(rTopicStr.String) 213 + } 207 214 nwe.Repo = &repo 208 215 } 209 216 ··· 250 257 } 251 258 252 259 // GetNotifications retrieves notifications with filters 253 - func GetNotifications(e Execer, filters ...filter) ([]*models.Notification, error) { 260 + func GetNotifications(e Execer, filters ...orm.Filter) ([]*models.Notification, error) { 254 261 return GetNotificationsPaginated(e, pagination.FirstPage(), filters...) 255 262 } 256 263 257 - func CountNotifications(e Execer, filters ...filter) (int64, error) { 264 + func CountNotifications(e Execer, filters ...orm.Filter) (int64, error) { 258 265 var conditions []string 259 266 var args []any 260 267 for _, filter := range filters { ··· 279 286 } 280 287 281 288 func MarkNotificationRead(e Execer, notificationID int64, userDID string) error { 282 - idFilter := FilterEq("id", notificationID) 283 - recipientFilter := FilterEq("recipient_did", userDID) 289 + idFilter := orm.FilterEq("id", notificationID) 290 + recipientFilter := orm.FilterEq("recipient_did", userDID) 284 291 285 292 query := fmt.Sprintf(` 286 293 UPDATE notifications ··· 308 315 } 309 316 310 317 func MarkAllNotificationsRead(e Execer, userDID string) error { 311 - recipientFilter := FilterEq("recipient_did", userDID) 312 - readFilter := FilterEq("read", 0) 318 + recipientFilter := orm.FilterEq("recipient_did", userDID) 319 + readFilter := orm.FilterEq("read", 0) 313 320 314 321 query := fmt.Sprintf(` 315 322 UPDATE notifications ··· 328 335 } 329 336 330 337 func DeleteNotification(e Execer, notificationID int64, userDID string) error { 331 - idFilter := FilterEq("id", notificationID) 332 - recipientFilter := FilterEq("recipient_did", userDID) 338 + idFilter := orm.FilterEq("id", notificationID) 339 + recipientFilter := orm.FilterEq("recipient_did", userDID) 333 340 334 341 query := fmt.Sprintf(` 335 342 DELETE FROM notifications ··· 356 363 } 357 364 358 365 func GetNotificationPreference(e Execer, userDid string) (*models.NotificationPreferences, error) { 359 - prefs, err := GetNotificationPreferences(e, FilterEq("user_did", userDid)) 366 + prefs, err := GetNotificationPreferences(e, orm.FilterEq("user_did", userDid)) 360 367 if err != nil { 361 368 return nil, err 362 369 } ··· 369 376 return p, nil 370 377 } 371 378 372 - func GetNotificationPreferences(e Execer, filters ...filter) (map[syntax.DID]*models.NotificationPreferences, error) { 379 + func GetNotificationPreferences(e Execer, filters ...orm.Filter) (map[syntax.DID]*models.NotificationPreferences, error) { 373 380 prefsMap := make(map[syntax.DID]*models.NotificationPreferences) 374 381 375 382 var conditions []string ··· 394 401 pull_created, 395 402 pull_commented, 396 403 followed, 404 + user_mentioned, 397 405 pull_merged, 398 406 issue_closed, 399 407 email_notifications ··· 419 427 &prefs.PullCreated, 420 428 &prefs.PullCommented, 421 429 &prefs.Followed, 430 + &prefs.UserMentioned, 422 431 &prefs.PullMerged, 423 432 &prefs.IssueClosed, 424 433 &prefs.EmailNotifications, ··· 440 449 query := ` 441 450 INSERT OR REPLACE INTO notification_preferences 442 451 (user_did, repo_starred, issue_created, issue_commented, pull_created, 443 - pull_commented, followed, pull_merged, issue_closed, email_notifications) 444 - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 452 + pull_commented, followed, user_mentioned, pull_merged, issue_closed, 453 + email_notifications) 454 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 445 455 ` 446 456 447 457 result, err := d.DB.ExecContext(ctx, query, ··· 452 462 prefs.PullCreated, 453 463 prefs.PullCommented, 454 464 prefs.Followed, 465 + prefs.UserMentioned, 455 466 prefs.PullMerged, 456 467 prefs.IssueClosed, 457 468 prefs.EmailNotifications, ··· 473 484 474 485 func (d *DB) ClearOldNotifications(ctx context.Context, olderThan time.Duration) error { 475 486 cutoff := time.Now().Add(-olderThan) 476 - createdFilter := FilterLte("created", cutoff) 487 + createdFilter := orm.FilterLte("created", cutoff) 477 488 478 489 query := fmt.Sprintf(` 479 490 DELETE FROM notifications
+9 -6
appview/db/pipeline.go
··· 7 7 "time" 8 8 9 9 "tangled.org/core/appview/models" 10 + "tangled.org/core/orm" 10 11 ) 11 12 12 - func GetPipelines(e Execer, filters ...filter) ([]models.Pipeline, error) { 13 + func GetPipelines(e Execer, filters ...orm.Filter) ([]models.Pipeline, error) { 13 14 var pipelines []models.Pipeline 14 15 15 16 var conditions []string ··· 168 169 169 170 // this is a mega query, but the most useful one: 170 171 // get N pipelines, for each one get the latest status of its N workflows 171 - func GetPipelineStatuses(e Execer, filters ...filter) ([]models.Pipeline, error) { 172 + func GetPipelineStatuses(e Execer, limit int, filters ...orm.Filter) ([]models.Pipeline, error) { 172 173 var conditions []string 173 174 var args []any 174 175 for _, filter := range filters { 175 - filter.key = "p." + filter.key // the table is aliased in the query to `p` 176 + filter.Key = "p." + filter.Key // the table is aliased in the query to `p` 176 177 conditions = append(conditions, filter.Condition()) 177 178 args = append(args, filter.Arg()...) 178 179 } ··· 205 206 join 206 207 triggers t ON p.trigger_id = t.id 207 208 %s 208 - `, whereClause) 209 + order by p.created desc 210 + limit %d 211 + `, whereClause, limit) 209 212 210 213 rows, err := e.Query(query, args...) 211 214 if err != nil { ··· 262 265 conditions = nil 263 266 args = nil 264 267 for _, p := range pipelines { 265 - knotFilter := FilterEq("pipeline_knot", p.Knot) 266 - rkeyFilter := FilterEq("pipeline_rkey", p.Rkey) 268 + knotFilter := orm.FilterEq("pipeline_knot", p.Knot) 269 + rkeyFilter := orm.FilterEq("pipeline_rkey", p.Rkey) 267 270 conditions = append(conditions, fmt.Sprintf("(%s and %s)", knotFilter.Condition(), rkeyFilter.Condition())) 268 271 args = append(args, p.Knot) 269 272 args = append(args, p.Rkey)
+55 -22
appview/db/profile.go
··· 11 11 12 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 13 "tangled.org/core/appview/models" 14 + "tangled.org/core/orm" 14 15 ) 15 16 16 17 const TimeframeMonths = 7 ··· 19 20 timeline := models.ProfileTimeline{ 20 21 ByMonth: make([]models.ByMonth, TimeframeMonths), 21 22 } 22 - currentMonth := time.Now().Month() 23 + now := time.Now() 23 24 timeframe := fmt.Sprintf("-%d months", TimeframeMonths) 24 25 25 26 pulls, err := GetPullsByOwnerDid(e, forDid, timeframe) ··· 29 30 30 31 // group pulls by month 31 32 for _, pull := range pulls { 32 - pullMonth := pull.Created.Month() 33 + monthsAgo := monthsBetween(pull.Created, now) 33 34 34 - if currentMonth-pullMonth >= TimeframeMonths { 35 + if monthsAgo >= TimeframeMonths { 35 36 // shouldn't happen; but times are weird 36 37 continue 37 38 } 38 39 39 - idx := currentMonth - pullMonth 40 + idx := monthsAgo 40 41 items := &timeline.ByMonth[idx].PullEvents.Items 41 42 42 43 *items = append(*items, &pull) ··· 44 45 45 46 issues, err := GetIssues( 46 47 e, 47 - FilterEq("did", forDid), 48 - FilterGte("created", time.Now().AddDate(0, -TimeframeMonths, 0)), 48 + orm.FilterEq("did", forDid), 49 + orm.FilterGte("created", time.Now().AddDate(0, -TimeframeMonths, 0)), 49 50 ) 50 51 if err != nil { 51 52 return nil, fmt.Errorf("error getting issues by owner did: %w", err) 52 53 } 53 54 54 55 for _, issue := range issues { 55 - issueMonth := issue.Created.Month() 56 + monthsAgo := monthsBetween(issue.Created, now) 56 57 57 - if currentMonth-issueMonth >= TimeframeMonths { 58 + if monthsAgo >= TimeframeMonths { 58 59 // shouldn't happen; but times are weird 59 60 continue 60 61 } 61 62 62 - idx := currentMonth - issueMonth 63 + idx := monthsAgo 63 64 items := &timeline.ByMonth[idx].IssueEvents.Items 64 65 65 66 *items = append(*items, &issue) 66 67 } 67 68 68 - repos, err := GetRepos(e, 0, FilterEq("did", forDid)) 69 + repos, err := GetRepos(e, 0, orm.FilterEq("did", forDid)) 69 70 if err != nil { 70 71 return nil, fmt.Errorf("error getting all repos by did: %w", err) 71 72 } ··· 76 77 if repo.Source != "" { 77 78 sourceRepo, err = GetRepoByAtUri(e, repo.Source) 78 79 if err != nil { 79 - return nil, err 80 + // the source repo was not found, skip this bit 81 + log.Println("profile", "err", err) 80 82 } 81 83 } 82 84 83 - repoMonth := repo.Created.Month() 85 + monthsAgo := monthsBetween(repo.Created, now) 84 86 85 - if currentMonth-repoMonth >= TimeframeMonths { 87 + if monthsAgo >= TimeframeMonths { 86 88 // shouldn't happen; but times are weird 87 89 continue 88 90 } 89 91 90 - idx := currentMonth - repoMonth 92 + idx := monthsAgo 91 93 92 94 items := &timeline.ByMonth[idx].RepoEvents 93 95 *items = append(*items, models.RepoEvent{ ··· 99 101 return &timeline, nil 100 102 } 101 103 104 + func monthsBetween(from, to time.Time) int { 105 + years := to.Year() - from.Year() 106 + months := int(to.Month() - from.Month()) 107 + return years*12 + months 108 + } 109 + 102 110 func UpsertProfile(tx *sql.Tx, profile *models.Profile) error { 103 111 defer tx.Rollback() 104 112 ··· 129 137 did, 130 138 description, 131 139 include_bluesky, 132 - location 140 + location, 141 + pronouns 133 142 ) 134 - values (?, ?, ?, ?)`, 143 + values (?, ?, ?, ?, ?)`, 135 144 profile.Did, 136 145 profile.Description, 137 146 includeBskyValue, 138 147 profile.Location, 148 + profile.Pronouns, 139 149 ) 140 150 141 151 if err != nil { ··· 197 207 return tx.Commit() 198 208 } 199 209 200 - func GetProfiles(e Execer, filters ...filter) (map[string]*models.Profile, error) { 210 + func GetProfiles(e Execer, filters ...orm.Filter) (map[string]*models.Profile, error) { 201 211 var conditions []string 202 212 var args []any 203 213 for _, filter := range filters { ··· 216 226 did, 217 227 description, 218 228 include_bluesky, 219 - location 229 + location, 230 + pronouns 220 231 from 221 232 profile 222 233 %s`, ··· 226 237 if err != nil { 227 238 return nil, err 228 239 } 240 + defer rows.Close() 229 241 230 242 profileMap := make(map[string]*models.Profile) 231 243 for rows.Next() { 232 244 var profile models.Profile 233 245 var includeBluesky int 246 + var pronouns sql.Null[string] 234 247 235 - err = rows.Scan(&profile.ID, &profile.Did, &profile.Description, &includeBluesky, &profile.Location) 248 + err = rows.Scan(&profile.ID, &profile.Did, &profile.Description, &includeBluesky, &profile.Location, &pronouns) 236 249 if err != nil { 237 250 return nil, err 238 251 } ··· 241 254 profile.IncludeBluesky = true 242 255 } 243 256 257 + if pronouns.Valid { 258 + profile.Pronouns = pronouns.V 259 + } 260 + 244 261 profileMap[profile.Did] = &profile 245 262 } 246 263 if err = rows.Err(); err != nil { ··· 261 278 if err != nil { 262 279 return nil, err 263 280 } 281 + defer rows.Close() 282 + 264 283 idxs := make(map[string]int) 265 284 for did := range profileMap { 266 285 idxs[did] = 0 ··· 281 300 if err != nil { 282 301 return nil, err 283 302 } 303 + defer rows.Close() 304 + 284 305 idxs = make(map[string]int) 285 306 for did := range profileMap { 286 307 idxs[did] = 0 ··· 302 323 303 324 func GetProfile(e Execer, did string) (*models.Profile, error) { 304 325 var profile models.Profile 326 + var pronouns sql.Null[string] 327 + 305 328 profile.Did = did 306 329 307 330 includeBluesky := 0 331 + 308 332 err := e.QueryRow( 309 - `select description, include_bluesky, location from profile where did = ?`, 333 + `select description, include_bluesky, location, pronouns from profile where did = ?`, 310 334 did, 311 - ).Scan(&profile.Description, &includeBluesky, &profile.Location) 335 + ).Scan(&profile.Description, &includeBluesky, &profile.Location, &pronouns) 312 336 if err == sql.ErrNoRows { 313 337 profile := models.Profile{} 314 338 profile.Did = did ··· 321 345 322 346 if includeBluesky != 0 { 323 347 profile.IncludeBluesky = true 348 + } 349 + 350 + if pronouns.Valid { 351 + profile.Pronouns = pronouns.V 324 352 } 325 353 326 354 rows, err := e.Query(`select link from profile_links where did = ?`, did) ··· 414 442 return fmt.Errorf("Entered location is too long.") 415 443 } 416 444 445 + // ensure pronouns are not too long 446 + if len(profile.Pronouns) > 40 { 447 + return fmt.Errorf("Entered pronouns are too long.") 448 + } 449 + 417 450 // ensure links are in order 418 451 err := validateLinks(profile) 419 452 if err != nil { ··· 421 454 } 422 455 423 456 // ensure all pinned repos are either own repos or collaborating repos 424 - repos, err := GetRepos(e, 0, FilterEq("did", profile.Did)) 457 + repos, err := GetRepos(e, 0, orm.FilterEq("did", profile.Did)) 425 458 if err != nil { 426 459 log.Printf("getting repos for %s: %s", profile.Did, err) 427 460 }
+132 -26
appview/db/pulls.go
··· 13 13 14 14 "github.com/bluesky-social/indigo/atproto/syntax" 15 15 "tangled.org/core/appview/models" 16 + "tangled.org/core/orm" 16 17 ) 17 18 18 19 func NewPull(tx *sql.Tx, pull *models.Pull) error { ··· 92 93 _, err = tx.Exec(` 93 94 insert into pull_submissions (pull_at, round_number, patch, combined, source_rev) 94 95 values (?, ?, ?, ?, ?) 95 - `, pull.PullAt(), 0, pull.Submissions[0].Patch, pull.Submissions[0].Combined, pull.Submissions[0].SourceRev) 96 - return err 96 + `, pull.AtUri(), 0, pull.Submissions[0].Patch, pull.Submissions[0].Combined, pull.Submissions[0].SourceRev) 97 + if err != nil { 98 + return err 99 + } 100 + 101 + if err := putReferences(tx, pull.AtUri(), pull.References); err != nil { 102 + return fmt.Errorf("put reference_links: %w", err) 103 + } 104 + 105 + return nil 97 106 } 98 107 99 108 func GetPullAt(e Execer, repoAt syntax.ATURI, pullId int) (syntax.ATURI, error) { ··· 101 110 if err != nil { 102 111 return "", err 103 112 } 104 - return pull.PullAt(), err 113 + return pull.AtUri(), err 105 114 } 106 115 107 116 func NextPullId(e Execer, repoAt syntax.ATURI) (int, error) { ··· 110 119 return pullId - 1, err 111 120 } 112 121 113 - func GetPullsWithLimit(e Execer, limit int, filters ...filter) ([]*models.Pull, error) { 122 + func GetPullsWithLimit(e Execer, limit int, filters ...orm.Filter) ([]*models.Pull, error) { 114 123 pulls := make(map[syntax.ATURI]*models.Pull) 115 124 116 125 var conditions []string ··· 214 223 pull.ParentChangeId = parentChangeId.String 215 224 } 216 225 217 - pulls[pull.PullAt()] = &pull 226 + pulls[pull.AtUri()] = &pull 218 227 } 219 228 220 229 var pullAts []syntax.ATURI 221 230 for _, p := range pulls { 222 - pullAts = append(pullAts, p.PullAt()) 231 + pullAts = append(pullAts, p.AtUri()) 223 232 } 224 - submissionsMap, err := GetPullSubmissions(e, FilterIn("pull_at", pullAts)) 233 + submissionsMap, err := GetPullSubmissions(e, orm.FilterIn("pull_at", pullAts)) 225 234 if err != nil { 226 235 return nil, fmt.Errorf("failed to get submissions: %w", err) 227 236 } ··· 233 242 } 234 243 235 244 // collect allLabels for each issue 236 - allLabels, err := GetLabels(e, FilterIn("subject", pullAts)) 245 + allLabels, err := GetLabels(e, orm.FilterIn("subject", pullAts)) 237 246 if err != nil { 238 247 return nil, fmt.Errorf("failed to query labels: %w", err) 239 248 } ··· 250 259 sourceAts = append(sourceAts, *p.PullSource.RepoAt) 251 260 } 252 261 } 253 - sourceRepos, err := GetRepos(e, 0, FilterIn("at_uri", sourceAts)) 262 + sourceRepos, err := GetRepos(e, 0, orm.FilterIn("at_uri", sourceAts)) 254 263 if err != nil && !errors.Is(err, sql.ErrNoRows) { 255 264 return nil, fmt.Errorf("failed to get source repos: %w", err) 256 265 } ··· 266 275 } 267 276 } 268 277 278 + allReferences, err := GetReferencesAll(e, orm.FilterIn("from_at", pullAts)) 279 + if err != nil { 280 + return nil, fmt.Errorf("failed to query reference_links: %w", err) 281 + } 282 + for pullAt, references := range allReferences { 283 + if pull, ok := pulls[pullAt]; ok { 284 + pull.References = references 285 + } 286 + } 287 + 269 288 orderedByPullId := []*models.Pull{} 270 289 for _, p := range pulls { 271 290 orderedByPullId = append(orderedByPullId, p) ··· 277 296 return orderedByPullId, nil 278 297 } 279 298 280 - func GetPulls(e Execer, filters ...filter) ([]*models.Pull, error) { 299 + func GetPulls(e Execer, filters ...orm.Filter) ([]*models.Pull, error) { 281 300 return GetPullsWithLimit(e, 0, filters...) 282 301 } 283 302 303 + func GetPullIDs(e Execer, opts models.PullSearchOptions) ([]int64, error) { 304 + var ids []int64 305 + 306 + var filters []orm.Filter 307 + filters = append(filters, orm.FilterEq("state", opts.State)) 308 + if opts.RepoAt != "" { 309 + filters = append(filters, orm.FilterEq("repo_at", opts.RepoAt)) 310 + } 311 + 312 + var conditions []string 313 + var args []any 314 + 315 + for _, filter := range filters { 316 + conditions = append(conditions, filter.Condition()) 317 + args = append(args, filter.Arg()...) 318 + } 319 + 320 + whereClause := "" 321 + if conditions != nil { 322 + whereClause = " where " + strings.Join(conditions, " and ") 323 + } 324 + pageClause := "" 325 + if opts.Page.Limit != 0 { 326 + pageClause = fmt.Sprintf( 327 + " limit %d offset %d ", 328 + opts.Page.Limit, 329 + opts.Page.Offset, 330 + ) 331 + } 332 + 333 + query := fmt.Sprintf( 334 + ` 335 + select 336 + id 337 + from 338 + pulls 339 + %s 340 + %s`, 341 + whereClause, 342 + pageClause, 343 + ) 344 + args = append(args, opts.Page.Limit, opts.Page.Offset) 345 + rows, err := e.Query(query, args...) 346 + if err != nil { 347 + return nil, err 348 + } 349 + defer rows.Close() 350 + 351 + for rows.Next() { 352 + var id int64 353 + err := rows.Scan(&id) 354 + if err != nil { 355 + return nil, err 356 + } 357 + 358 + ids = append(ids, id) 359 + } 360 + 361 + return ids, nil 362 + } 363 + 284 364 func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*models.Pull, error) { 285 - pulls, err := GetPullsWithLimit(e, 1, FilterEq("repo_at", repoAt), FilterEq("pull_id", pullId)) 365 + pulls, err := GetPullsWithLimit(e, 1, orm.FilterEq("repo_at", repoAt), orm.FilterEq("pull_id", pullId)) 286 366 if err != nil { 287 367 return nil, err 288 368 } 289 - if pulls == nil { 369 + if len(pulls) == 0 { 290 370 return nil, sql.ErrNoRows 291 371 } 292 372 ··· 294 374 } 295 375 296 376 // mapping from pull -> pull submissions 297 - func GetPullSubmissions(e Execer, filters ...filter) (map[syntax.ATURI][]*models.PullSubmission, error) { 377 + func GetPullSubmissions(e Execer, filters ...orm.Filter) (map[syntax.ATURI][]*models.PullSubmission, error) { 298 378 var conditions []string 299 379 var args []any 300 380 for _, filter := range filters { ··· 369 449 370 450 // Get comments for all submissions using GetPullComments 371 451 submissionIds := slices.Collect(maps.Keys(submissionMap)) 372 - comments, err := GetPullComments(e, FilterIn("submission_id", submissionIds)) 452 + comments, err := GetPullComments(e, orm.FilterIn("submission_id", submissionIds)) 373 453 if err != nil { 374 - return nil, err 454 + return nil, fmt.Errorf("failed to get pull comments: %w", err) 375 455 } 376 456 for _, comment := range comments { 377 457 if submission, ok := submissionMap[comment.SubmissionId]; ok { ··· 395 475 return m, nil 396 476 } 397 477 398 - func GetPullComments(e Execer, filters ...filter) ([]models.PullComment, error) { 478 + func GetPullComments(e Execer, filters ...orm.Filter) ([]models.PullComment, error) { 399 479 var conditions []string 400 480 var args []any 401 481 for _, filter := range filters { ··· 431 511 } 432 512 defer rows.Close() 433 513 434 - var comments []models.PullComment 514 + commentMap := make(map[string]*models.PullComment) 435 515 for rows.Next() { 436 516 var comment models.PullComment 437 517 var createdAt string ··· 453 533 comment.Created = t 454 534 } 455 535 456 - comments = append(comments, comment) 536 + atUri := comment.AtUri().String() 537 + commentMap[atUri] = &comment 457 538 } 458 539 459 540 if err := rows.Err(); err != nil { 460 541 return nil, err 461 542 } 462 543 544 + // collect references for each comments 545 + commentAts := slices.Collect(maps.Keys(commentMap)) 546 + allReferencs, err := GetReferencesAll(e, orm.FilterIn("from_at", commentAts)) 547 + if err != nil { 548 + return nil, fmt.Errorf("failed to query reference_links: %w", err) 549 + } 550 + for commentAt, references := range allReferencs { 551 + if comment, ok := commentMap[commentAt.String()]; ok { 552 + comment.References = references 553 + } 554 + } 555 + 556 + var comments []models.PullComment 557 + for _, c := range commentMap { 558 + comments = append(comments, *c) 559 + } 560 + 561 + sort.Slice(comments, func(i, j int) bool { 562 + return comments[i].Created.Before(comments[j].Created) 563 + }) 564 + 463 565 return comments, nil 464 566 } 465 567 ··· 539 641 return pulls, nil 540 642 } 541 643 542 - func NewPullComment(e Execer, comment *models.PullComment) (int64, error) { 644 + func NewPullComment(tx *sql.Tx, comment *models.PullComment) (int64, error) { 543 645 query := `insert into pull_comments (owner_did, repo_at, submission_id, comment_at, pull_id, body) values (?, ?, ?, ?, ?, ?)` 544 - res, err := e.Exec( 646 + res, err := tx.Exec( 545 647 query, 546 648 comment.OwnerDid, 547 649 comment.RepoAt, ··· 557 659 i, err := res.LastInsertId() 558 660 if err != nil { 559 661 return 0, err 662 + } 663 + 664 + if err := putReferences(tx, comment.AtUri(), comment.References); err != nil { 665 + return 0, fmt.Errorf("put reference_links: %w", err) 560 666 } 561 667 562 668 return i, nil ··· 603 709 return err 604 710 } 605 711 606 - func SetPullParentChangeId(e Execer, parentChangeId string, filters ...filter) error { 712 + func SetPullParentChangeId(e Execer, parentChangeId string, filters ...orm.Filter) error { 607 713 var conditions []string 608 714 var args []any 609 715 ··· 627 733 628 734 // Only used when stacking to update contents in the event of a rebase (the interdiff should be empty). 629 735 // otherwise submissions are immutable 630 - func UpdatePull(e Execer, newPatch, sourceRev string, filters ...filter) error { 736 + func UpdatePull(e Execer, newPatch, sourceRev string, filters ...orm.Filter) error { 631 737 var conditions []string 632 738 var args []any 633 739 ··· 685 791 func GetStack(e Execer, stackId string) (models.Stack, error) { 686 792 unorderedPulls, err := GetPulls( 687 793 e, 688 - FilterEq("stack_id", stackId), 689 - FilterNotEq("state", models.PullDeleted), 794 + orm.FilterEq("stack_id", stackId), 795 + orm.FilterNotEq("state", models.PullDeleted), 690 796 ) 691 797 if err != nil { 692 798 return nil, err ··· 730 836 func GetAbandonedPulls(e Execer, stackId string) ([]*models.Pull, error) { 731 837 pulls, err := GetPulls( 732 838 e, 733 - FilterEq("stack_id", stackId), 734 - FilterEq("state", models.PullDeleted), 839 + orm.FilterEq("stack_id", stackId), 840 + orm.FilterEq("state", models.PullDeleted), 735 841 ) 736 842 if err != nil { 737 843 return nil, err
+3 -2
appview/db/punchcard.go
··· 7 7 "time" 8 8 9 9 "tangled.org/core/appview/models" 10 + "tangled.org/core/orm" 10 11 ) 11 12 12 13 // this adds to the existing count ··· 20 21 return err 21 22 } 22 23 23 - func MakePunchcard(e Execer, filters ...filter) (*models.Punchcard, error) { 24 + func MakePunchcard(e Execer, filters ...orm.Filter) (*models.Punchcard, error) { 24 25 punchcard := &models.Punchcard{} 25 26 now := time.Now() 26 27 startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC) ··· 77 78 punch.Count = int(count.Int64) 78 79 } 79 80 80 - punchcard.Punches[punch.Date.YearDay()] = punch 81 + punchcard.Punches[punch.Date.YearDay()-1] = punch 81 82 punchcard.Total += punch.Count 82 83 } 83 84
+463
appview/db/reference.go
··· 1 + package db 2 + 3 + import ( 4 + "database/sql" 5 + "fmt" 6 + "strings" 7 + 8 + "github.com/bluesky-social/indigo/atproto/syntax" 9 + "tangled.org/core/api/tangled" 10 + "tangled.org/core/appview/models" 11 + "tangled.org/core/orm" 12 + ) 13 + 14 + // ValidateReferenceLinks resolves refLinks to Issue/PR/IssueComment/PullComment ATURIs. 15 + // It will ignore missing refLinks. 16 + func ValidateReferenceLinks(e Execer, refLinks []models.ReferenceLink) ([]syntax.ATURI, error) { 17 + var ( 18 + issueRefs []models.ReferenceLink 19 + pullRefs []models.ReferenceLink 20 + ) 21 + for _, ref := range refLinks { 22 + switch ref.Kind { 23 + case models.RefKindIssue: 24 + issueRefs = append(issueRefs, ref) 25 + case models.RefKindPull: 26 + pullRefs = append(pullRefs, ref) 27 + } 28 + } 29 + issueUris, err := findIssueReferences(e, issueRefs) 30 + if err != nil { 31 + return nil, fmt.Errorf("find issue references: %w", err) 32 + } 33 + pullUris, err := findPullReferences(e, pullRefs) 34 + if err != nil { 35 + return nil, fmt.Errorf("find pull references: %w", err) 36 + } 37 + 38 + return append(issueUris, pullUris...), nil 39 + } 40 + 41 + func findIssueReferences(e Execer, refLinks []models.ReferenceLink) ([]syntax.ATURI, error) { 42 + if len(refLinks) == 0 { 43 + return nil, nil 44 + } 45 + vals := make([]string, len(refLinks)) 46 + args := make([]any, 0, len(refLinks)*4) 47 + for i, ref := range refLinks { 48 + vals[i] = "(?, ?, ?, ?)" 49 + args = append(args, ref.Handle, ref.Repo, ref.SubjectId, ref.CommentId) 50 + } 51 + query := fmt.Sprintf( 52 + `with input(owner_did, name, issue_id, comment_id) as ( 53 + values %s 54 + ) 55 + select 56 + i.did, i.rkey, 57 + c.did, c.rkey 58 + from input inp 59 + join repos r 60 + on r.did = inp.owner_did 61 + and r.name = inp.name 62 + join issues i 63 + on i.repo_at = r.at_uri 64 + and i.issue_id = inp.issue_id 65 + left join issue_comments c 66 + on inp.comment_id is not null 67 + and c.issue_at = i.at_uri 68 + and c.id = inp.comment_id 69 + `, 70 + strings.Join(vals, ","), 71 + ) 72 + rows, err := e.Query(query, args...) 73 + if err != nil { 74 + return nil, err 75 + } 76 + defer rows.Close() 77 + 78 + var uris []syntax.ATURI 79 + 80 + for rows.Next() { 81 + // Scan rows 82 + var issueOwner, issueRkey string 83 + var commentOwner, commentRkey sql.NullString 84 + var uri syntax.ATURI 85 + if err := rows.Scan(&issueOwner, &issueRkey, &commentOwner, &commentRkey); err != nil { 86 + return nil, err 87 + } 88 + if commentOwner.Valid && commentRkey.Valid { 89 + uri = syntax.ATURI(fmt.Sprintf( 90 + "at://%s/%s/%s", 91 + commentOwner.String, 92 + tangled.RepoIssueCommentNSID, 93 + commentRkey.String, 94 + )) 95 + } else { 96 + uri = syntax.ATURI(fmt.Sprintf( 97 + "at://%s/%s/%s", 98 + issueOwner, 99 + tangled.RepoIssueNSID, 100 + issueRkey, 101 + )) 102 + } 103 + uris = append(uris, uri) 104 + } 105 + if err := rows.Err(); err != nil { 106 + return nil, fmt.Errorf("iterate rows: %w", err) 107 + } 108 + 109 + return uris, nil 110 + } 111 + 112 + func findPullReferences(e Execer, refLinks []models.ReferenceLink) ([]syntax.ATURI, error) { 113 + if len(refLinks) == 0 { 114 + return nil, nil 115 + } 116 + vals := make([]string, len(refLinks)) 117 + args := make([]any, 0, len(refLinks)*4) 118 + for i, ref := range refLinks { 119 + vals[i] = "(?, ?, ?, ?)" 120 + args = append(args, ref.Handle, ref.Repo, ref.SubjectId, ref.CommentId) 121 + } 122 + query := fmt.Sprintf( 123 + `with input(owner_did, name, pull_id, comment_id) as ( 124 + values %s 125 + ) 126 + select 127 + p.owner_did, p.rkey, 128 + c.comment_at 129 + from input inp 130 + join repos r 131 + on r.did = inp.owner_did 132 + and r.name = inp.name 133 + join pulls p 134 + on p.repo_at = r.at_uri 135 + and p.pull_id = inp.pull_id 136 + left join pull_comments c 137 + on inp.comment_id is not null 138 + and c.repo_at = r.at_uri and c.pull_id = p.pull_id 139 + and c.id = inp.comment_id 140 + `, 141 + strings.Join(vals, ","), 142 + ) 143 + rows, err := e.Query(query, args...) 144 + if err != nil { 145 + return nil, err 146 + } 147 + defer rows.Close() 148 + 149 + var uris []syntax.ATURI 150 + 151 + for rows.Next() { 152 + // Scan rows 153 + var pullOwner, pullRkey string 154 + var commentUri sql.NullString 155 + var uri syntax.ATURI 156 + if err := rows.Scan(&pullOwner, &pullRkey, &commentUri); err != nil { 157 + return nil, err 158 + } 159 + if commentUri.Valid { 160 + // no-op 161 + uri = syntax.ATURI(commentUri.String) 162 + } else { 163 + uri = syntax.ATURI(fmt.Sprintf( 164 + "at://%s/%s/%s", 165 + pullOwner, 166 + tangled.RepoPullNSID, 167 + pullRkey, 168 + )) 169 + } 170 + uris = append(uris, uri) 171 + } 172 + return uris, nil 173 + } 174 + 175 + func putReferences(tx *sql.Tx, fromAt syntax.ATURI, references []syntax.ATURI) error { 176 + err := deleteReferences(tx, fromAt) 177 + if err != nil { 178 + return fmt.Errorf("delete old reference_links: %w", err) 179 + } 180 + if len(references) == 0 { 181 + return nil 182 + } 183 + 184 + values := make([]string, 0, len(references)) 185 + args := make([]any, 0, len(references)*2) 186 + for _, ref := range references { 187 + values = append(values, "(?, ?)") 188 + args = append(args, fromAt, ref) 189 + } 190 + _, err = tx.Exec( 191 + fmt.Sprintf( 192 + `insert into reference_links (from_at, to_at) 193 + values %s`, 194 + strings.Join(values, ","), 195 + ), 196 + args..., 197 + ) 198 + if err != nil { 199 + return fmt.Errorf("insert new reference_links: %w", err) 200 + } 201 + return nil 202 + } 203 + 204 + func deleteReferences(tx *sql.Tx, fromAt syntax.ATURI) error { 205 + _, err := tx.Exec(`delete from reference_links where from_at = ?`, fromAt) 206 + return err 207 + } 208 + 209 + func GetReferencesAll(e Execer, filters ...orm.Filter) (map[syntax.ATURI][]syntax.ATURI, error) { 210 + var ( 211 + conditions []string 212 + args []any 213 + ) 214 + for _, filter := range filters { 215 + conditions = append(conditions, filter.Condition()) 216 + args = append(args, filter.Arg()...) 217 + } 218 + 219 + whereClause := "" 220 + if conditions != nil { 221 + whereClause = " where " + strings.Join(conditions, " and ") 222 + } 223 + 224 + rows, err := e.Query( 225 + fmt.Sprintf( 226 + `select from_at, to_at from reference_links %s`, 227 + whereClause, 228 + ), 229 + args..., 230 + ) 231 + if err != nil { 232 + return nil, fmt.Errorf("query reference_links: %w", err) 233 + } 234 + defer rows.Close() 235 + 236 + result := make(map[syntax.ATURI][]syntax.ATURI) 237 + 238 + for rows.Next() { 239 + var from, to syntax.ATURI 240 + if err := rows.Scan(&from, &to); err != nil { 241 + return nil, fmt.Errorf("scan row: %w", err) 242 + } 243 + 244 + result[from] = append(result[from], to) 245 + } 246 + if err := rows.Err(); err != nil { 247 + return nil, fmt.Errorf("iterate rows: %w", err) 248 + } 249 + 250 + return result, nil 251 + } 252 + 253 + func GetBacklinks(e Execer, target syntax.ATURI) ([]models.RichReferenceLink, error) { 254 + rows, err := e.Query( 255 + `select from_at from reference_links 256 + where to_at = ?`, 257 + target, 258 + ) 259 + if err != nil { 260 + return nil, fmt.Errorf("query backlinks: %w", err) 261 + } 262 + defer rows.Close() 263 + 264 + var ( 265 + backlinks []models.RichReferenceLink 266 + backlinksMap = make(map[string][]syntax.ATURI) 267 + ) 268 + for rows.Next() { 269 + var from syntax.ATURI 270 + if err := rows.Scan(&from); err != nil { 271 + return nil, fmt.Errorf("scan row: %w", err) 272 + } 273 + nsid := from.Collection().String() 274 + backlinksMap[nsid] = append(backlinksMap[nsid], from) 275 + } 276 + if err := rows.Err(); err != nil { 277 + return nil, fmt.Errorf("iterate rows: %w", err) 278 + } 279 + 280 + var ls []models.RichReferenceLink 281 + ls, err = getIssueBacklinks(e, backlinksMap[tangled.RepoIssueNSID]) 282 + if err != nil { 283 + return nil, fmt.Errorf("get issue backlinks: %w", err) 284 + } 285 + backlinks = append(backlinks, ls...) 286 + ls, err = getIssueCommentBacklinks(e, backlinksMap[tangled.RepoIssueCommentNSID]) 287 + if err != nil { 288 + return nil, fmt.Errorf("get issue_comment backlinks: %w", err) 289 + } 290 + backlinks = append(backlinks, ls...) 291 + ls, err = getPullBacklinks(e, backlinksMap[tangled.RepoPullNSID]) 292 + if err != nil { 293 + return nil, fmt.Errorf("get pull backlinks: %w", err) 294 + } 295 + backlinks = append(backlinks, ls...) 296 + ls, err = getPullCommentBacklinks(e, backlinksMap[tangled.RepoPullCommentNSID]) 297 + if err != nil { 298 + return nil, fmt.Errorf("get pull_comment backlinks: %w", err) 299 + } 300 + backlinks = append(backlinks, ls...) 301 + 302 + return backlinks, nil 303 + } 304 + 305 + func getIssueBacklinks(e Execer, aturis []syntax.ATURI) ([]models.RichReferenceLink, error) { 306 + if len(aturis) == 0 { 307 + return nil, nil 308 + } 309 + vals := make([]string, len(aturis)) 310 + args := make([]any, 0, len(aturis)*2) 311 + for i, aturi := range aturis { 312 + vals[i] = "(?, ?)" 313 + did := aturi.Authority().String() 314 + rkey := aturi.RecordKey().String() 315 + args = append(args, did, rkey) 316 + } 317 + rows, err := e.Query( 318 + fmt.Sprintf( 319 + `select r.did, r.name, i.issue_id, i.title, i.open 320 + from issues i 321 + join repos r 322 + on r.at_uri = i.repo_at 323 + where (i.did, i.rkey) in (%s)`, 324 + strings.Join(vals, ","), 325 + ), 326 + args..., 327 + ) 328 + if err != nil { 329 + return nil, err 330 + } 331 + defer rows.Close() 332 + var refLinks []models.RichReferenceLink 333 + for rows.Next() { 334 + var l models.RichReferenceLink 335 + l.Kind = models.RefKindIssue 336 + if err := rows.Scan(&l.Handle, &l.Repo, &l.SubjectId, &l.Title, &l.State); err != nil { 337 + return nil, err 338 + } 339 + refLinks = append(refLinks, l) 340 + } 341 + if err := rows.Err(); err != nil { 342 + return nil, fmt.Errorf("iterate rows: %w", err) 343 + } 344 + return refLinks, nil 345 + } 346 + 347 + func getIssueCommentBacklinks(e Execer, aturis []syntax.ATURI) ([]models.RichReferenceLink, error) { 348 + if len(aturis) == 0 { 349 + return nil, nil 350 + } 351 + filter := orm.FilterIn("c.at_uri", aturis) 352 + rows, err := e.Query( 353 + fmt.Sprintf( 354 + `select r.did, r.name, i.issue_id, c.id, i.title, i.open 355 + from issue_comments c 356 + join issues i 357 + on i.at_uri = c.issue_at 358 + join repos r 359 + on r.at_uri = i.repo_at 360 + where %s`, 361 + filter.Condition(), 362 + ), 363 + filter.Arg()..., 364 + ) 365 + if err != nil { 366 + return nil, err 367 + } 368 + defer rows.Close() 369 + var refLinks []models.RichReferenceLink 370 + for rows.Next() { 371 + var l models.RichReferenceLink 372 + l.Kind = models.RefKindIssue 373 + l.CommentId = new(int) 374 + if err := rows.Scan(&l.Handle, &l.Repo, &l.SubjectId, l.CommentId, &l.Title, &l.State); err != nil { 375 + return nil, err 376 + } 377 + refLinks = append(refLinks, l) 378 + } 379 + if err := rows.Err(); err != nil { 380 + return nil, fmt.Errorf("iterate rows: %w", err) 381 + } 382 + return refLinks, nil 383 + } 384 + 385 + func getPullBacklinks(e Execer, aturis []syntax.ATURI) ([]models.RichReferenceLink, error) { 386 + if len(aturis) == 0 { 387 + return nil, nil 388 + } 389 + vals := make([]string, len(aturis)) 390 + args := make([]any, 0, len(aturis)*2) 391 + for i, aturi := range aturis { 392 + vals[i] = "(?, ?)" 393 + did := aturi.Authority().String() 394 + rkey := aturi.RecordKey().String() 395 + args = append(args, did, rkey) 396 + } 397 + rows, err := e.Query( 398 + fmt.Sprintf( 399 + `select r.did, r.name, p.pull_id, p.title, p.state 400 + from pulls p 401 + join repos r 402 + on r.at_uri = p.repo_at 403 + where (p.owner_did, p.rkey) in (%s)`, 404 + strings.Join(vals, ","), 405 + ), 406 + args..., 407 + ) 408 + if err != nil { 409 + return nil, err 410 + } 411 + defer rows.Close() 412 + var refLinks []models.RichReferenceLink 413 + for rows.Next() { 414 + var l models.RichReferenceLink 415 + l.Kind = models.RefKindPull 416 + if err := rows.Scan(&l.Handle, &l.Repo, &l.SubjectId, &l.Title, &l.State); err != nil { 417 + return nil, err 418 + } 419 + refLinks = append(refLinks, l) 420 + } 421 + if err := rows.Err(); err != nil { 422 + return nil, fmt.Errorf("iterate rows: %w", err) 423 + } 424 + return refLinks, nil 425 + } 426 + 427 + func getPullCommentBacklinks(e Execer, aturis []syntax.ATURI) ([]models.RichReferenceLink, error) { 428 + if len(aturis) == 0 { 429 + return nil, nil 430 + } 431 + filter := orm.FilterIn("c.comment_at", aturis) 432 + rows, err := e.Query( 433 + fmt.Sprintf( 434 + `select r.did, r.name, p.pull_id, c.id, p.title, p.state 435 + from repos r 436 + join pulls p 437 + on r.at_uri = p.repo_at 438 + join pull_comments c 439 + on r.at_uri = c.repo_at and p.pull_id = c.pull_id 440 + where %s`, 441 + filter.Condition(), 442 + ), 443 + filter.Arg()..., 444 + ) 445 + if err != nil { 446 + return nil, err 447 + } 448 + defer rows.Close() 449 + var refLinks []models.RichReferenceLink 450 + for rows.Next() { 451 + var l models.RichReferenceLink 452 + l.Kind = models.RefKindPull 453 + l.CommentId = new(int) 454 + if err := rows.Scan(&l.Handle, &l.Repo, &l.SubjectId, l.CommentId, &l.Title, &l.State); err != nil { 455 + return nil, err 456 + } 457 + refLinks = append(refLinks, l) 458 + } 459 + if err := rows.Err(); err != nil { 460 + return nil, fmt.Errorf("iterate rows: %w", err) 461 + } 462 + return refLinks, nil 463 + }
+5 -3
appview/db/registration.go
··· 7 7 "time" 8 8 9 9 "tangled.org/core/appview/models" 10 + "tangled.org/core/orm" 10 11 ) 11 12 12 - func GetRegistrations(e Execer, filters ...filter) ([]models.Registration, error) { 13 + func GetRegistrations(e Execer, filters ...orm.Filter) ([]models.Registration, error) { 13 14 var registrations []models.Registration 14 15 15 16 var conditions []string ··· 37 38 if err != nil { 38 39 return nil, err 39 40 } 41 + defer rows.Close() 40 42 41 43 for rows.Next() { 42 44 var createdAt string ··· 69 71 return registrations, nil 70 72 } 71 73 72 - func MarkRegistered(e Execer, filters ...filter) error { 74 + func MarkRegistered(e Execer, filters ...orm.Filter) error { 73 75 var conditions []string 74 76 var args []any 75 77 for _, filter := range filters { ··· 94 96 return err 95 97 } 96 98 97 - func DeleteKnot(e Execer, filters ...filter) error { 99 + func DeleteKnot(e Execer, filters ...orm.Filter) error { 98 100 var conditions []string 99 101 var args []any 100 102 for _, filter := range filters {
+82 -49
appview/db/repos.go
··· 10 10 "time" 11 11 12 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 - securejoin "github.com/cyphar/filepath-securejoin" 14 - "tangled.org/core/api/tangled" 15 13 "tangled.org/core/appview/models" 14 + "tangled.org/core/orm" 16 15 ) 17 16 18 - type Repo struct { 19 - Id int64 20 - Did string 21 - Name string 22 - Knot string 23 - Rkey string 24 - Created time.Time 25 - Description string 26 - Spindle string 27 - 28 - // optionally, populate this when querying for reverse mappings 29 - RepoStats *models.RepoStats 30 - 31 - // optional 32 - Source string 33 - } 34 - 35 - func (r Repo) RepoAt() syntax.ATURI { 36 - return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", r.Did, tangled.RepoNSID, r.Rkey)) 37 - } 38 - 39 - func (r Repo) DidSlashRepo() string { 40 - p, _ := securejoin.SecureJoin(r.Did, r.Name) 41 - return p 42 - } 43 - 44 - func GetRepos(e Execer, limit int, filters ...filter) ([]models.Repo, error) { 17 + func GetRepos(e Execer, limit int, filters ...orm.Filter) ([]models.Repo, error) { 45 18 repoMap := make(map[syntax.ATURI]*models.Repo) 46 19 47 20 var conditions []string ··· 70 43 rkey, 71 44 created, 72 45 description, 46 + website, 47 + topics, 73 48 source, 74 49 spindle 75 50 from ··· 81 56 limitClause, 82 57 ) 83 58 rows, err := e.Query(repoQuery, args...) 84 - 85 59 if err != nil { 86 60 return nil, fmt.Errorf("failed to execute repo query: %w ", err) 87 61 } 62 + defer rows.Close() 88 63 89 64 for rows.Next() { 90 65 var repo models.Repo 91 66 var createdAt string 92 - var description, source, spindle sql.NullString 67 + var description, website, topicStr, source, spindle sql.NullString 93 68 94 69 err := rows.Scan( 95 70 &repo.Id, ··· 99 74 &repo.Rkey, 100 75 &createdAt, 101 76 &description, 77 + &website, 78 + &topicStr, 102 79 &source, 103 80 &spindle, 104 81 ) ··· 112 89 if description.Valid { 113 90 repo.Description = description.String 114 91 } 92 + if website.Valid { 93 + repo.Website = website.String 94 + } 95 + if topicStr.Valid { 96 + repo.Topics = strings.Fields(topicStr.String) 97 + } 115 98 if source.Valid { 116 99 repo.Source = source.String 117 100 } ··· 145 128 if err != nil { 146 129 return nil, fmt.Errorf("failed to execute labels query: %w ", err) 147 130 } 131 + defer rows.Close() 132 + 148 133 for rows.Next() { 149 134 var repoat, labelat string 150 135 if err := rows.Scan(&repoat, &labelat); err != nil { ··· 173 158 from repo_languages 174 159 where repo_at in (%s) 175 160 and is_default_ref = 1 161 + and language <> '' 176 162 ) 177 163 where rn = 1 178 164 `, ··· 182 168 if err != nil { 183 169 return nil, fmt.Errorf("failed to execute lang query: %w ", err) 184 170 } 171 + defer rows.Close() 172 + 185 173 for rows.Next() { 186 174 var repoat, lang string 187 175 if err := rows.Scan(&repoat, &lang); err != nil { ··· 198 186 199 187 starCountQuery := fmt.Sprintf( 200 188 `select 201 - repo_at, count(1) 189 + subject_at, count(1) 202 190 from stars 203 - where repo_at in (%s) 204 - group by repo_at`, 191 + where subject_at in (%s) 192 + group by subject_at`, 205 193 inClause, 206 194 ) 207 195 rows, err = e.Query(starCountQuery, args...) 208 196 if err != nil { 209 197 return nil, fmt.Errorf("failed to execute star-count query: %w ", err) 210 198 } 199 + defer rows.Close() 200 + 211 201 for rows.Next() { 212 202 var repoat string 213 203 var count int ··· 237 227 if err != nil { 238 228 return nil, fmt.Errorf("failed to execute issue-count query: %w ", err) 239 229 } 230 + defer rows.Close() 231 + 240 232 for rows.Next() { 241 233 var repoat string 242 234 var open, closed int ··· 278 270 if err != nil { 279 271 return nil, fmt.Errorf("failed to execute pulls-count query: %w ", err) 280 272 } 273 + defer rows.Close() 274 + 281 275 for rows.Next() { 282 276 var repoat string 283 277 var open, merged, closed, deleted int ··· 312 306 } 313 307 314 308 // helper to get exactly one repo 315 - func GetRepo(e Execer, filters ...filter) (*models.Repo, error) { 309 + func GetRepo(e Execer, filters ...orm.Filter) (*models.Repo, error) { 316 310 repos, err := GetRepos(e, 0, filters...) 317 311 if err != nil { 318 312 return nil, err ··· 329 323 return &repos[0], nil 330 324 } 331 325 332 - func CountRepos(e Execer, filters ...filter) (int64, error) { 326 + func CountRepos(e Execer, filters ...orm.Filter) (int64, error) { 333 327 var conditions []string 334 328 var args []any 335 329 for _, filter := range filters { ··· 356 350 func GetRepoByAtUri(e Execer, atUri string) (*models.Repo, error) { 357 351 var repo models.Repo 358 352 var nullableDescription sql.NullString 353 + var nullableWebsite sql.NullString 354 + var nullableTopicStr sql.NullString 359 355 360 - row := e.QueryRow(`select id, did, name, knot, created, rkey, description from repos where at_uri = ?`, atUri) 356 + row := e.QueryRow(`select id, did, name, knot, created, rkey, description, website, topics from repos where at_uri = ?`, atUri) 361 357 362 358 var createdAt string 363 - if err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription); err != nil { 359 + if err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription, &nullableWebsite, &nullableTopicStr); err != nil { 364 360 return nil, err 365 361 } 366 362 createdAtTime, _ := time.Parse(time.RFC3339, createdAt) ··· 368 364 369 365 if nullableDescription.Valid { 370 366 repo.Description = nullableDescription.String 371 - } else { 372 - repo.Description = "" 367 + } 368 + if nullableWebsite.Valid { 369 + repo.Website = nullableWebsite.String 370 + } 371 + if nullableTopicStr.Valid { 372 + repo.Topics = strings.Fields(nullableTopicStr.String) 373 373 } 374 374 375 375 return &repo, nil 376 376 } 377 377 378 + func PutRepo(tx *sql.Tx, repo models.Repo) error { 379 + _, err := tx.Exec( 380 + `update repos 381 + set knot = ?, description = ?, website = ?, topics = ? 382 + where did = ? and rkey = ? 383 + `, 384 + repo.Knot, repo.Description, repo.Website, repo.TopicStr(), repo.Did, repo.Rkey, 385 + ) 386 + return err 387 + } 388 + 378 389 func AddRepo(tx *sql.Tx, repo *models.Repo) error { 379 390 _, err := tx.Exec( 380 391 `insert into repos 381 - (did, name, knot, rkey, at_uri, description, source) 382 - values (?, ?, ?, ?, ?, ?, ?)`, 383 - repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.RepoAt().String(), repo.Description, repo.Source, 392 + (did, name, knot, rkey, at_uri, description, website, topics, source) 393 + values (?, ?, ?, ?, ?, ?, ?, ?, ?)`, 394 + repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.RepoAt().String(), repo.Description, repo.Website, repo.TopicStr(), repo.Source, 384 395 ) 385 396 if err != nil { 386 397 return fmt.Errorf("failed to insert repo: %w", err) ··· 412 423 return nullableSource.String, nil 413 424 } 414 425 426 + func GetRepoSourceRepo(e Execer, repoAt syntax.ATURI) (*models.Repo, error) { 427 + source, err := GetRepoSource(e, repoAt) 428 + if source == "" || errors.Is(err, sql.ErrNoRows) { 429 + return nil, nil 430 + } 431 + if err != nil { 432 + return nil, err 433 + } 434 + return GetRepoByAtUri(e, source) 435 + } 436 + 415 437 func GetForksByDid(e Execer, did string) ([]models.Repo, error) { 416 438 var repos []models.Repo 417 439 418 440 rows, err := e.Query( 419 - `select distinct r.id, r.did, r.name, r.knot, r.rkey, r.description, r.created, r.source 441 + `select distinct r.id, r.did, r.name, r.knot, r.rkey, r.description, r.website, r.created, r.source 420 442 from repos r 421 443 left join collaborators c on r.at_uri = c.repo_at 422 444 where (r.did = ? or c.subject_did = ?) ··· 434 456 var repo models.Repo 435 457 var createdAt string 436 458 var nullableDescription sql.NullString 459 + var nullableWebsite sql.NullString 437 460 var nullableSource sql.NullString 438 461 439 - err := rows.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 462 + err := rows.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &nullableWebsite, &createdAt, &nullableSource) 440 463 if err != nil { 441 464 return nil, err 442 465 } ··· 470 493 var repo models.Repo 471 494 var createdAt string 472 495 var nullableDescription sql.NullString 496 + var nullableWebsite sql.NullString 497 + var nullableTopicStr sql.NullString 473 498 var nullableSource sql.NullString 474 499 475 500 row := e.QueryRow( 476 - `select id, did, name, knot, rkey, description, created, source 501 + `select id, did, name, knot, rkey, description, website, topics, created, source 477 502 from repos 478 503 where did = ? and name = ? and source is not null and source != ''`, 479 504 did, name, 480 505 ) 481 506 482 - err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 507 + err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &nullableWebsite, &nullableTopicStr, &createdAt, &nullableSource) 483 508 if err != nil { 484 509 return nil, err 485 510 } ··· 488 513 repo.Description = nullableDescription.String 489 514 } 490 515 516 + if nullableWebsite.Valid { 517 + repo.Website = nullableWebsite.String 518 + } 519 + 520 + if nullableTopicStr.Valid { 521 + repo.Topics = strings.Fields(nullableTopicStr.String) 522 + } 523 + 491 524 if nullableSource.Valid { 492 525 repo.Source = nullableSource.String 493 526 } ··· 521 554 return err 522 555 } 523 556 524 - func UnsubscribeLabel(e Execer, filters ...filter) error { 557 + func UnsubscribeLabel(e Execer, filters ...orm.Filter) error { 525 558 var conditions []string 526 559 var args []any 527 560 for _, filter := range filters { ··· 539 572 return err 540 573 } 541 574 542 - func GetRepoLabels(e Execer, filters ...filter) ([]models.RepoLabel, error) { 575 + func GetRepoLabels(e Execer, filters ...orm.Filter) ([]models.RepoLabel, error) { 543 576 var conditions []string 544 577 var args []any 545 578 for _, filter := range filters {
+6 -5
appview/db/spindle.go
··· 7 7 "time" 8 8 9 9 "tangled.org/core/appview/models" 10 + "tangled.org/core/orm" 10 11 ) 11 12 12 - func GetSpindles(e Execer, filters ...filter) ([]models.Spindle, error) { 13 + func GetSpindles(e Execer, filters ...orm.Filter) ([]models.Spindle, error) { 13 14 var spindles []models.Spindle 14 15 15 16 var conditions []string ··· 91 92 return err 92 93 } 93 94 94 - func VerifySpindle(e Execer, filters ...filter) (int64, error) { 95 + func VerifySpindle(e Execer, filters ...orm.Filter) (int64, error) { 95 96 var conditions []string 96 97 var args []any 97 98 for _, filter := range filters { ··· 114 115 return res.RowsAffected() 115 116 } 116 117 117 - func DeleteSpindle(e Execer, filters ...filter) error { 118 + func DeleteSpindle(e Execer, filters ...orm.Filter) error { 118 119 var conditions []string 119 120 var args []any 120 121 for _, filter := range filters { ··· 144 145 return err 145 146 } 146 147 147 - func RemoveSpindleMember(e Execer, filters ...filter) error { 148 + func RemoveSpindleMember(e Execer, filters ...orm.Filter) error { 148 149 var conditions []string 149 150 var args []any 150 151 for _, filter := range filters { ··· 163 164 return err 164 165 } 165 166 166 - func GetSpindleMembers(e Execer, filters ...filter) ([]models.SpindleMember, error) { 167 + func GetSpindleMembers(e Execer, filters ...orm.Filter) ([]models.SpindleMember, error) { 167 168 var members []models.SpindleMember 168 169 169 170 var conditions []string
+44 -102
appview/db/star.go
··· 11 11 12 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 13 "tangled.org/core/appview/models" 14 + "tangled.org/core/orm" 14 15 ) 15 16 16 17 func AddStar(e Execer, star *models.Star) error { 17 - query := `insert or ignore into stars (starred_by_did, repo_at, rkey) values (?, ?, ?)` 18 + query := `insert or ignore into stars (did, subject_at, rkey) values (?, ?, ?)` 18 19 _, err := e.Exec( 19 20 query, 20 - star.StarredByDid, 21 + star.Did, 21 22 star.RepoAt.String(), 22 23 star.Rkey, 23 24 ) ··· 25 26 } 26 27 27 28 // Get a star record 28 - func GetStar(e Execer, starredByDid string, repoAt syntax.ATURI) (*models.Star, error) { 29 + func GetStar(e Execer, did string, subjectAt syntax.ATURI) (*models.Star, error) { 29 30 query := ` 30 - select starred_by_did, repo_at, created, rkey 31 + select did, subject_at, created, rkey 31 32 from stars 32 - where starred_by_did = ? and repo_at = ?` 33 - row := e.QueryRow(query, starredByDid, repoAt) 33 + where did = ? and subject_at = ?` 34 + row := e.QueryRow(query, did, subjectAt) 34 35 35 36 var star models.Star 36 37 var created string 37 - err := row.Scan(&star.StarredByDid, &star.RepoAt, &created, &star.Rkey) 38 + err := row.Scan(&star.Did, &star.RepoAt, &created, &star.Rkey) 38 39 if err != nil { 39 40 return nil, err 40 41 } ··· 51 52 } 52 53 53 54 // Remove a star 54 - func DeleteStar(e Execer, starredByDid string, repoAt syntax.ATURI) error { 55 - _, err := e.Exec(`delete from stars where starred_by_did = ? and repo_at = ?`, starredByDid, repoAt) 55 + func DeleteStar(e Execer, did string, subjectAt syntax.ATURI) error { 56 + _, err := e.Exec(`delete from stars where did = ? and subject_at = ?`, did, subjectAt) 56 57 return err 57 58 } 58 59 59 60 // Remove a star 60 - func DeleteStarByRkey(e Execer, starredByDid string, rkey string) error { 61 - _, err := e.Exec(`delete from stars where starred_by_did = ? and rkey = ?`, starredByDid, rkey) 61 + func DeleteStarByRkey(e Execer, did string, rkey string) error { 62 + _, err := e.Exec(`delete from stars where did = ? and rkey = ?`, did, rkey) 62 63 return err 63 64 } 64 65 65 - func GetStarCount(e Execer, repoAt syntax.ATURI) (int, error) { 66 + func GetStarCount(e Execer, subjectAt syntax.ATURI) (int, error) { 66 67 stars := 0 67 68 err := e.QueryRow( 68 - `select count(starred_by_did) from stars where repo_at = ?`, repoAt).Scan(&stars) 69 + `select count(did) from stars where subject_at = ?`, subjectAt).Scan(&stars) 69 70 if err != nil { 70 71 return 0, err 71 72 } ··· 89 90 } 90 91 91 92 query := fmt.Sprintf(` 92 - SELECT repo_at 93 + SELECT subject_at 93 94 FROM stars 94 - WHERE starred_by_did = ? AND repo_at IN (%s) 95 + WHERE did = ? AND subject_at IN (%s) 95 96 `, strings.Join(placeholders, ",")) 96 97 97 98 rows, err := e.Query(query, args...) ··· 118 119 return result, nil 119 120 } 120 121 121 - func GetStarStatus(e Execer, userDid string, repoAt syntax.ATURI) bool { 122 - statuses, err := getStarStatuses(e, userDid, []syntax.ATURI{repoAt}) 122 + func GetStarStatus(e Execer, userDid string, subjectAt syntax.ATURI) bool { 123 + statuses, err := getStarStatuses(e, userDid, []syntax.ATURI{subjectAt}) 123 124 if err != nil { 124 125 return false 125 126 } 126 - return statuses[repoAt.String()] 127 + return statuses[subjectAt.String()] 127 128 } 128 129 129 130 // GetStarStatuses returns a map of repo URIs to star status for a given user 130 - func GetStarStatuses(e Execer, userDid string, repoAts []syntax.ATURI) (map[string]bool, error) { 131 - return getStarStatuses(e, userDid, repoAts) 131 + func GetStarStatuses(e Execer, userDid string, subjectAts []syntax.ATURI) (map[string]bool, error) { 132 + return getStarStatuses(e, userDid, subjectAts) 132 133 } 133 - func GetStars(e Execer, limit int, filters ...filter) ([]models.Star, error) { 134 + 135 + // GetRepoStars return a list of stars each holding target repository. 136 + // If there isn't known repo with starred at-uri, those stars will be ignored. 137 + func GetRepoStars(e Execer, limit int, filters ...orm.Filter) ([]models.RepoStar, error) { 134 138 var conditions []string 135 139 var args []any 136 140 for _, filter := range filters { ··· 149 153 } 150 154 151 155 repoQuery := fmt.Sprintf( 152 - `select starred_by_did, repo_at, created, rkey 156 + `select did, subject_at, created, rkey 153 157 from stars 154 158 %s 155 159 order by created desc ··· 161 165 if err != nil { 162 166 return nil, err 163 167 } 168 + defer rows.Close() 164 169 165 170 starMap := make(map[string][]models.Star) 166 171 for rows.Next() { 167 172 var star models.Star 168 173 var created string 169 - err := rows.Scan(&star.StarredByDid, &star.RepoAt, &created, &star.Rkey) 174 + err := rows.Scan(&star.Did, &star.RepoAt, &created, &star.Rkey) 170 175 if err != nil { 171 176 return nil, err 172 177 } ··· 192 197 return nil, nil 193 198 } 194 199 195 - repos, err := GetRepos(e, 0, FilterIn("at_uri", args)) 200 + repos, err := GetRepos(e, 0, orm.FilterIn("at_uri", args)) 196 201 if err != nil { 197 202 return nil, err 198 203 } 199 204 205 + var repoStars []models.RepoStar 200 206 for _, r := range repos { 201 207 if stars, ok := starMap[string(r.RepoAt())]; ok { 202 - for i := range stars { 203 - stars[i].Repo = &r 208 + for _, star := range stars { 209 + repoStars = append(repoStars, models.RepoStar{ 210 + Star: star, 211 + Repo: &r, 212 + }) 204 213 } 205 214 } 206 215 } 207 216 208 - var stars []models.Star 209 - for _, s := range starMap { 210 - stars = append(stars, s...) 211 - } 212 - 213 - slices.SortFunc(stars, func(a, b models.Star) int { 217 + slices.SortFunc(repoStars, func(a, b models.RepoStar) int { 214 218 if a.Created.After(b.Created) { 215 219 return -1 216 220 } ··· 220 224 return 0 221 225 }) 222 226 223 - return stars, nil 227 + return repoStars, nil 224 228 } 225 229 226 - func CountStars(e Execer, filters ...filter) (int64, error) { 230 + func CountStars(e Execer, filters ...orm.Filter) (int64, error) { 227 231 var conditions []string 228 232 var args []any 229 233 for _, filter := range filters { ··· 247 251 return count, nil 248 252 } 249 253 250 - func GetAllStars(e Execer, limit int) ([]models.Star, error) { 251 - var stars []models.Star 252 - 253 - rows, err := e.Query(` 254 - select 255 - s.starred_by_did, 256 - s.repo_at, 257 - s.rkey, 258 - s.created, 259 - r.did, 260 - r.name, 261 - r.knot, 262 - r.rkey, 263 - r.created 264 - from stars s 265 - join repos r on s.repo_at = r.at_uri 266 - `) 267 - 268 - if err != nil { 269 - return nil, err 270 - } 271 - defer rows.Close() 272 - 273 - for rows.Next() { 274 - var star models.Star 275 - var repo models.Repo 276 - var starCreatedAt, repoCreatedAt string 277 - 278 - if err := rows.Scan( 279 - &star.StarredByDid, 280 - &star.RepoAt, 281 - &star.Rkey, 282 - &starCreatedAt, 283 - &repo.Did, 284 - &repo.Name, 285 - &repo.Knot, 286 - &repo.Rkey, 287 - &repoCreatedAt, 288 - ); err != nil { 289 - return nil, err 290 - } 291 - 292 - star.Created, err = time.Parse(time.RFC3339, starCreatedAt) 293 - if err != nil { 294 - star.Created = time.Now() 295 - } 296 - repo.Created, err = time.Parse(time.RFC3339, repoCreatedAt) 297 - if err != nil { 298 - repo.Created = time.Now() 299 - } 300 - star.Repo = &repo 301 - 302 - stars = append(stars, star) 303 - } 304 - 305 - if err := rows.Err(); err != nil { 306 - return nil, err 307 - } 308 - 309 - return stars, nil 310 - } 311 - 312 254 // GetTopStarredReposLastWeek returns the top 8 most starred repositories from the last week 313 255 func GetTopStarredReposLastWeek(e Execer) ([]models.Repo, error) { 314 256 // first, get the top repo URIs by star count from the last week 315 257 query := ` 316 258 with recent_starred_repos as ( 317 - select distinct repo_at 259 + select distinct subject_at 318 260 from stars 319 261 where created >= datetime('now', '-7 days') 320 262 ), 321 263 repo_star_counts as ( 322 264 select 323 - s.repo_at, 265 + s.subject_at, 324 266 count(*) as stars_gained_last_week 325 267 from stars s 326 - join recent_starred_repos rsr on s.repo_at = rsr.repo_at 268 + join recent_starred_repos rsr on s.subject_at = rsr.subject_at 327 269 where s.created >= datetime('now', '-7 days') 328 - group by s.repo_at 270 + group by s.subject_at 329 271 ) 330 - select rsc.repo_at 272 + select rsc.subject_at 331 273 from repo_star_counts rsc 332 274 order by rsc.stars_gained_last_week desc 333 275 limit 8 ··· 358 300 } 359 301 360 302 // get full repo data 361 - repos, err := GetRepos(e, 0, FilterIn("at_uri", repoUris)) 303 + repos, err := GetRepos(e, 0, orm.FilterIn("at_uri", repoUris)) 362 304 if err != nil { 363 305 return nil, err 364 306 }
+4 -3
appview/db/strings.go
··· 8 8 "time" 9 9 10 10 "tangled.org/core/appview/models" 11 + "tangled.org/core/orm" 11 12 ) 12 13 13 14 func AddString(e Execer, s models.String) error { ··· 44 45 return err 45 46 } 46 47 47 - func GetStrings(e Execer, limit int, filters ...filter) ([]models.String, error) { 48 + func GetStrings(e Execer, limit int, filters ...orm.Filter) ([]models.String, error) { 48 49 var all []models.String 49 50 50 51 var conditions []string ··· 127 128 return all, nil 128 129 } 129 130 130 - func CountStrings(e Execer, filters ...filter) (int64, error) { 131 + func CountStrings(e Execer, filters ...orm.Filter) (int64, error) { 131 132 var conditions []string 132 133 var args []any 133 134 for _, filter := range filters { ··· 151 152 return count, nil 152 153 } 153 154 154 - func DeleteString(e Execer, filters ...filter) error { 155 + func DeleteString(e Execer, filters ...orm.Filter) error { 155 156 var conditions []string 156 157 var args []any 157 158 for _, filter := range filters {
+11 -20
appview/db/timeline.go
··· 5 5 6 6 "github.com/bluesky-social/indigo/atproto/syntax" 7 7 "tangled.org/core/appview/models" 8 + "tangled.org/core/orm" 8 9 ) 9 10 10 11 // TODO: this gathers heterogenous events from different sources and aggregates ··· 84 85 } 85 86 86 87 func getTimelineRepos(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) { 87 - filters := make([]filter, 0) 88 + filters := make([]orm.Filter, 0) 88 89 if userIsFollowing != nil { 89 - filters = append(filters, FilterIn("did", userIsFollowing)) 90 + filters = append(filters, orm.FilterIn("did", userIsFollowing)) 90 91 } 91 92 92 93 repos, err := GetRepos(e, limit, filters...) ··· 104 105 105 106 var origRepos []models.Repo 106 107 if args != nil { 107 - origRepos, err = GetRepos(e, 0, FilterIn("at_uri", args)) 108 + origRepos, err = GetRepos(e, 0, orm.FilterIn("at_uri", args)) 108 109 } 109 110 if err != nil { 110 111 return nil, err ··· 144 145 } 145 146 146 147 func getTimelineStars(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) { 147 - filters := make([]filter, 0) 148 + filters := make([]orm.Filter, 0) 148 149 if userIsFollowing != nil { 149 - filters = append(filters, FilterIn("starred_by_did", userIsFollowing)) 150 + filters = append(filters, orm.FilterIn("did", userIsFollowing)) 150 151 } 151 152 152 - stars, err := GetStars(e, limit, filters...) 153 + stars, err := GetRepoStars(e, limit, filters...) 153 154 if err != nil { 154 155 return nil, err 155 156 } 156 157 157 - // filter star records without a repo 158 - n := 0 159 - for _, s := range stars { 160 - if s.Repo != nil { 161 - stars[n] = s 162 - n++ 163 - } 164 - } 165 - stars = stars[:n] 166 - 167 158 var repos []models.Repo 168 159 for _, s := range stars { 169 160 repos = append(repos, *s.Repo) ··· 179 170 isStarred, starCount := getRepoStarInfo(s.Repo, starStatuses) 180 171 181 172 events = append(events, models.TimelineEvent{ 182 - Star: &s, 173 + RepoStar: &s, 183 174 EventAt: s.Created, 184 175 IsStarred: isStarred, 185 176 StarCount: starCount, ··· 190 181 } 191 182 192 183 func getTimelineFollows(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) { 193 - filters := make([]filter, 0) 184 + filters := make([]orm.Filter, 0) 194 185 if userIsFollowing != nil { 195 - filters = append(filters, FilterIn("user_did", userIsFollowing)) 186 + filters = append(filters, orm.FilterIn("user_did", userIsFollowing)) 196 187 } 197 188 198 189 follows, err := GetFollows(e, limit, filters...) ··· 209 200 return nil, nil 210 201 } 211 202 212 - profiles, err := GetProfiles(e, FilterIn("did", subjects)) 203 + profiles, err := GetProfiles(e, orm.FilterIn("did", subjects)) 213 204 if err != nil { 214 205 return nil, err 215 206 }
+7 -12
appview/email/email.go
··· 3 3 import ( 4 4 "fmt" 5 5 "net" 6 - "regexp" 6 + "net/mail" 7 7 "strings" 8 8 9 9 "github.com/resend/resend-go/v2" ··· 34 34 } 35 35 36 36 func IsValidEmail(email string) bool { 37 - // Basic length check 38 - if len(email) < 3 || len(email) > 254 { 37 + // Reject whitespace (ParseAddress normalizes it away) 38 + if strings.ContainsAny(email, " \t\n\r") { 39 39 return false 40 40 } 41 41 42 - // Regular expression for email validation (RFC 5322 compliant) 43 - pattern := `^[a-zA-Z0-9.!#$%&'*+/=?^_\x60{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$` 44 - 45 - // Compile regex 46 - regex := regexp.MustCompile(pattern) 47 - 48 - // Check if email matches regex pattern 49 - if !regex.MatchString(email) { 42 + // Use stdlib RFC 5322 parser 43 + addr, err := mail.ParseAddress(email) 44 + if err != nil { 50 45 return false 51 46 } 52 47 53 48 // Split email into local and domain parts 54 - parts := strings.Split(email, "@") 49 + parts := strings.Split(addr.Address, "@") 55 50 domain := parts[1] 56 51 57 52 mx, err := net.LookupMX(domain)
+53
appview/email/email_test.go
··· 1 + package email 2 + 3 + import ( 4 + "testing" 5 + ) 6 + 7 + func TestIsValidEmail(t *testing.T) { 8 + tests := []struct { 9 + name string 10 + email string 11 + want bool 12 + }{ 13 + // Valid emails using RFC 2606 reserved domains 14 + {"standard email", "user@example.com", true}, 15 + {"single char local", "a@example.com", true}, 16 + {"dot in middle", "first.last@example.com", true}, 17 + {"multiple dots", "a.b.c@example.com", true}, 18 + {"plus tag", "user+tag@example.com", true}, 19 + {"numbers", "user123@example.com", true}, 20 + {"example.org", "user@example.org", true}, 21 + {"example.net", "user@example.net", true}, 22 + 23 + // Invalid format - rejected by mail.ParseAddress 24 + {"empty string", "", false}, 25 + {"no at sign", "userexample.com", false}, 26 + {"no domain", "user@", false}, 27 + {"no local part", "@example.com", false}, 28 + {"double at", "user@@example.com", false}, 29 + {"just at sign", "@", false}, 30 + {"leading dot", ".user@example.com", false}, 31 + {"trailing dot", "user.@example.com", false}, 32 + {"consecutive dots", "user..name@example.com", false}, 33 + 34 + // Whitespace - rejected before parsing 35 + {"space in local", "user @example.com", false}, 36 + {"space in domain", "user@ example.com", false}, 37 + {"tab", "user\t@example.com", false}, 38 + {"newline", "user\n@example.com", false}, 39 + 40 + // MX lookup - using RFC 2606 reserved TLDs (guaranteed no MX) 41 + {"invalid TLD", "user@example.invalid", false}, 42 + {"test TLD", "user@mail.test", false}, 43 + } 44 + 45 + for _, tt := range tests { 46 + t.Run(tt.name, func(t *testing.T) { 47 + got := IsValidEmail(tt.email) 48 + if got != tt.want { 49 + t.Errorf("IsValidEmail(%q) = %v, want %v", tt.email, got, tt.want) 50 + } 51 + }) 52 + } 53 + }
+5 -1
appview/indexer/indexer.go
··· 6 6 7 7 "tangled.org/core/appview/db" 8 8 issues_indexer "tangled.org/core/appview/indexer/issues" 9 + pulls_indexer "tangled.org/core/appview/indexer/pulls" 9 10 "tangled.org/core/appview/notify" 10 11 tlog "tangled.org/core/log" 11 12 ) 12 13 13 14 type Indexer struct { 14 15 Issues *issues_indexer.Indexer 16 + Pulls *pulls_indexer.Indexer 15 17 logger *slog.Logger 16 18 notify.BaseNotifier 17 19 } 18 20 19 21 func New(logger *slog.Logger) *Indexer { 20 22 return &Indexer{ 21 - issues_indexer.NewIndexer("indexes.bleve"), 23 + issues_indexer.NewIndexer("indexes/issues.bleve"), 24 + pulls_indexer.NewIndexer("indexes/pulls.bleve"), 22 25 logger, 23 26 notify.BaseNotifier{}, 24 27 } ··· 28 31 func (ix *Indexer) Init(ctx context.Context, db *db.DB) error { 29 32 ctx = tlog.IntoContext(ctx, ix.logger) 30 33 ix.Issues.Init(ctx, db) 34 + ix.Pulls.Init(ctx, db) 31 35 return nil 32 36 }
+7 -1
appview/indexer/issues/indexer.go
··· 56 56 log.Fatalln("failed to populate issue indexer", err) 57 57 } 58 58 } 59 - l.Info("Initialized the issue indexer") 59 + 60 + count, _ := ix.indexer.DocCount() 61 + l.Info("Initialized the issue indexer", "docCount", count) 60 62 } 61 63 62 64 func generateIssueIndexMapping() (mapping.IndexMapping, error) { ··· 214 216 } 215 217 } 216 218 return batch.Flush() 219 + } 220 + 221 + func (ix *Indexer) Delete(ctx context.Context, issueId int64) error { 222 + return ix.indexer.Delete(base36.Encode(issueId)) 217 223 } 218 224 219 225 // Search searches for issues
+39 -2
appview/indexer/notifier.go
··· 3 3 import ( 4 4 "context" 5 5 6 + "github.com/bluesky-social/indigo/atproto/syntax" 6 7 "tangled.org/core/appview/models" 7 8 "tangled.org/core/appview/notify" 8 9 "tangled.org/core/log" ··· 10 11 11 12 var _ notify.Notifier = &Indexer{} 12 13 13 - func (ix *Indexer) NewIssue(ctx context.Context, issue *models.Issue) { 14 - l := log.FromContext(ctx).With("notifier", "indexer.NewIssue", "issue", issue) 14 + func (ix *Indexer) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) { 15 + l := log.FromContext(ctx).With("notifier", "indexer", "issue", issue) 15 16 l.Debug("indexing new issue") 16 17 err := ix.Issues.Index(ctx, *issue) 17 18 if err != nil { 18 19 l.Error("failed to index an issue", "err", err) 19 20 } 20 21 } 22 + 23 + func (ix *Indexer) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) { 24 + l := log.FromContext(ctx).With("notifier", "indexer", "issue", issue) 25 + l.Debug("updating an issue") 26 + err := ix.Issues.Index(ctx, *issue) 27 + if err != nil { 28 + l.Error("failed to index an issue", "err", err) 29 + } 30 + } 31 + 32 + func (ix *Indexer) DeleteIssue(ctx context.Context, issue *models.Issue) { 33 + l := log.FromContext(ctx).With("notifier", "indexer", "issue", issue) 34 + l.Debug("deleting an issue") 35 + err := ix.Issues.Delete(ctx, issue.Id) 36 + if err != nil { 37 + l.Error("failed to delete an issue", "err", err) 38 + } 39 + } 40 + 41 + func (ix *Indexer) NewPull(ctx context.Context, pull *models.Pull) { 42 + l := log.FromContext(ctx).With("notifier", "indexer", "pull", pull) 43 + l.Debug("indexing new pr") 44 + err := ix.Pulls.Index(ctx, pull) 45 + if err != nil { 46 + l.Error("failed to index a pr", "err", err) 47 + } 48 + } 49 + 50 + func (ix *Indexer) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) { 51 + l := log.FromContext(ctx).With("notifier", "indexer", "pull", pull) 52 + l.Debug("updating a pr") 53 + err := ix.Pulls.Index(ctx, pull) 54 + if err != nil { 55 + l.Error("failed to index a pr", "err", err) 56 + } 57 + }
+257
appview/indexer/pulls/indexer.go
··· 1 + // heavily inspired by gitea's model (basically copy-pasted) 2 + package pulls_indexer 3 + 4 + import ( 5 + "context" 6 + "errors" 7 + "log" 8 + "os" 9 + 10 + "github.com/blevesearch/bleve/v2" 11 + "github.com/blevesearch/bleve/v2/analysis/analyzer/custom" 12 + "github.com/blevesearch/bleve/v2/analysis/token/camelcase" 13 + "github.com/blevesearch/bleve/v2/analysis/token/lowercase" 14 + "github.com/blevesearch/bleve/v2/analysis/token/unicodenorm" 15 + "github.com/blevesearch/bleve/v2/analysis/tokenizer/unicode" 16 + "github.com/blevesearch/bleve/v2/index/upsidedown" 17 + "github.com/blevesearch/bleve/v2/mapping" 18 + "github.com/blevesearch/bleve/v2/search/query" 19 + "tangled.org/core/appview/db" 20 + "tangled.org/core/appview/indexer/base36" 21 + "tangled.org/core/appview/indexer/bleve" 22 + "tangled.org/core/appview/models" 23 + tlog "tangled.org/core/log" 24 + ) 25 + 26 + const ( 27 + pullIndexerAnalyzer = "pullIndexer" 28 + pullIndexerDocType = "pullIndexerDocType" 29 + 30 + unicodeNormalizeName = "uicodeNormalize" 31 + ) 32 + 33 + type Indexer struct { 34 + indexer bleve.Index 35 + path string 36 + } 37 + 38 + func NewIndexer(indexDir string) *Indexer { 39 + return &Indexer{ 40 + path: indexDir, 41 + } 42 + } 43 + 44 + // Init initializes the indexer 45 + func (ix *Indexer) Init(ctx context.Context, e db.Execer) { 46 + l := tlog.FromContext(ctx) 47 + existed, err := ix.intialize(ctx) 48 + if err != nil { 49 + log.Fatalln("failed to initialize pull indexer", err) 50 + } 51 + if !existed { 52 + l.Debug("Populating the pull indexer") 53 + err := PopulateIndexer(ctx, ix, e) 54 + if err != nil { 55 + log.Fatalln("failed to populate pull indexer", err) 56 + } 57 + } 58 + 59 + count, _ := ix.indexer.DocCount() 60 + l.Info("Initialized the pull indexer", "docCount", count) 61 + } 62 + 63 + func generatePullIndexMapping() (mapping.IndexMapping, error) { 64 + mapping := bleve.NewIndexMapping() 65 + docMapping := bleve.NewDocumentMapping() 66 + 67 + textFieldMapping := bleve.NewTextFieldMapping() 68 + textFieldMapping.Store = false 69 + textFieldMapping.IncludeInAll = false 70 + 71 + keywordFieldMapping := bleve.NewKeywordFieldMapping() 72 + keywordFieldMapping.Store = false 73 + keywordFieldMapping.IncludeInAll = false 74 + 75 + // numericFieldMapping := bleve.NewNumericFieldMapping() 76 + 77 + docMapping.AddFieldMappingsAt("title", textFieldMapping) 78 + docMapping.AddFieldMappingsAt("body", textFieldMapping) 79 + 80 + docMapping.AddFieldMappingsAt("repo_at", keywordFieldMapping) 81 + docMapping.AddFieldMappingsAt("state", keywordFieldMapping) 82 + 83 + err := mapping.AddCustomTokenFilter(unicodeNormalizeName, map[string]any{ 84 + "type": unicodenorm.Name, 85 + "form": unicodenorm.NFC, 86 + }) 87 + if err != nil { 88 + return nil, err 89 + } 90 + 91 + err = mapping.AddCustomAnalyzer(pullIndexerAnalyzer, map[string]any{ 92 + "type": custom.Name, 93 + "char_filters": []string{}, 94 + "tokenizer": unicode.Name, 95 + "token_filters": []string{unicodeNormalizeName, camelcase.Name, lowercase.Name}, 96 + }) 97 + if err != nil { 98 + return nil, err 99 + } 100 + 101 + mapping.DefaultAnalyzer = pullIndexerAnalyzer 102 + mapping.AddDocumentMapping(pullIndexerDocType, docMapping) 103 + mapping.AddDocumentMapping("_all", bleve.NewDocumentDisabledMapping()) 104 + mapping.DefaultMapping = bleve.NewDocumentDisabledMapping() 105 + 106 + return mapping, nil 107 + } 108 + 109 + func (ix *Indexer) intialize(ctx context.Context) (bool, error) { 110 + if ix.indexer != nil { 111 + return false, errors.New("indexer is already initialized") 112 + } 113 + 114 + indexer, err := openIndexer(ctx, ix.path) 115 + if err != nil { 116 + return false, err 117 + } 118 + if indexer != nil { 119 + ix.indexer = indexer 120 + return true, nil 121 + } 122 + 123 + mapping, err := generatePullIndexMapping() 124 + if err != nil { 125 + return false, err 126 + } 127 + indexer, err = bleve.New(ix.path, mapping) 128 + if err != nil { 129 + return false, err 130 + } 131 + 132 + ix.indexer = indexer 133 + 134 + return false, nil 135 + } 136 + 137 + func openIndexer(ctx context.Context, path string) (bleve.Index, error) { 138 + l := tlog.FromContext(ctx) 139 + indexer, err := bleve.Open(path) 140 + if err != nil { 141 + if errors.Is(err, upsidedown.IncompatibleVersion) { 142 + l.Info("Indexer was built with a previous version of bleve, deleting and rebuilding") 143 + return nil, os.RemoveAll(path) 144 + } 145 + return nil, nil 146 + } 147 + return indexer, nil 148 + } 149 + 150 + func PopulateIndexer(ctx context.Context, ix *Indexer, e db.Execer) error { 151 + l := tlog.FromContext(ctx) 152 + 153 + pulls, err := db.GetPulls(e) 154 + if err != nil { 155 + return err 156 + } 157 + count := len(pulls) 158 + err = ix.Index(ctx, pulls...) 159 + if err != nil { 160 + return err 161 + } 162 + l.Info("pulls indexed", "count", count) 163 + return err 164 + } 165 + 166 + // pullData data stored and will be indexed 167 + type pullData struct { 168 + ID int64 `json:"id"` 169 + RepoAt string `json:"repo_at"` 170 + PullID int `json:"pull_id"` 171 + Title string `json:"title"` 172 + Body string `json:"body"` 173 + State string `json:"state"` 174 + 175 + Comments []pullCommentData `json:"comments"` 176 + } 177 + 178 + func makePullData(pull *models.Pull) *pullData { 179 + return &pullData{ 180 + ID: int64(pull.ID), 181 + RepoAt: pull.RepoAt.String(), 182 + PullID: pull.PullId, 183 + Title: pull.Title, 184 + Body: pull.Body, 185 + State: pull.State.String(), 186 + } 187 + } 188 + 189 + // Type returns the document type, for bleve's mapping.Classifier interface. 190 + func (i *pullData) Type() string { 191 + return pullIndexerDocType 192 + } 193 + 194 + type pullCommentData struct { 195 + Body string `json:"body"` 196 + } 197 + 198 + type searchResult struct { 199 + Hits []int64 200 + Total uint64 201 + } 202 + 203 + const maxBatchSize = 20 204 + 205 + func (ix *Indexer) Index(ctx context.Context, pulls ...*models.Pull) error { 206 + batch := bleveutil.NewFlushingBatch(ix.indexer, maxBatchSize) 207 + for _, pull := range pulls { 208 + pullData := makePullData(pull) 209 + if err := batch.Index(base36.Encode(pullData.ID), pullData); err != nil { 210 + return err 211 + } 212 + } 213 + return batch.Flush() 214 + } 215 + 216 + func (ix *Indexer) Delete(ctx context.Context, pullID int64) error { 217 + return ix.indexer.Delete(base36.Encode(pullID)) 218 + } 219 + 220 + // Search searches for pulls 221 + func (ix *Indexer) Search(ctx context.Context, opts models.PullSearchOptions) (*searchResult, error) { 222 + var queries []query.Query 223 + 224 + // TODO(boltless): remove this after implementing pulls page pagination 225 + limit := opts.Page.Limit 226 + if limit == 0 { 227 + limit = 500 228 + } 229 + 230 + if opts.Keyword != "" { 231 + queries = append(queries, bleve.NewDisjunctionQuery( 232 + bleveutil.MatchAndQuery("title", opts.Keyword, pullIndexerAnalyzer, 0), 233 + bleveutil.MatchAndQuery("body", opts.Keyword, pullIndexerAnalyzer, 0), 234 + )) 235 + } 236 + queries = append(queries, bleveutil.KeywordFieldQuery("repo_at", opts.RepoAt)) 237 + queries = append(queries, bleveutil.KeywordFieldQuery("state", opts.State.String())) 238 + 239 + var indexerQuery query.Query = bleve.NewConjunctionQuery(queries...) 240 + searchReq := bleve.NewSearchRequestOptions(indexerQuery, limit, opts.Page.Offset, false) 241 + res, err := ix.indexer.SearchInContext(ctx, searchReq) 242 + if err != nil { 243 + return nil, nil 244 + } 245 + ret := &searchResult{ 246 + Total: res.Total, 247 + Hits: make([]int64, len(res.Hits)), 248 + } 249 + for i, hit := range res.Hits { 250 + id, err := base36.Decode(hit.ID) 251 + if err != nil { 252 + return nil, err 253 + } 254 + ret.Hits[i] = id 255 + } 256 + return ret, nil 257 + }
+56 -32
appview/ingester.go
··· 21 21 "tangled.org/core/appview/serververify" 22 22 "tangled.org/core/appview/validator" 23 23 "tangled.org/core/idresolver" 24 + "tangled.org/core/orm" 24 25 "tangled.org/core/rbac" 25 26 ) 26 27 ··· 121 122 return err 122 123 } 123 124 err = db.AddStar(i.Db, &models.Star{ 124 - StarredByDid: did, 125 - RepoAt: subjectUri, 126 - Rkey: e.Commit.RKey, 125 + Did: did, 126 + RepoAt: subjectUri, 127 + Rkey: e.Commit.RKey, 127 128 }) 128 129 case jmodels.CommitOperationDelete: 129 130 err = db.DeleteStarByRkey(i.Db, did, e.Commit.RKey) ··· 253 254 254 255 err = db.AddArtifact(i.Db, artifact) 255 256 case jmodels.CommitOperationDelete: 256 - err = db.DeleteArtifact(i.Db, db.FilterEq("did", did), db.FilterEq("rkey", e.Commit.RKey)) 257 + err = db.DeleteArtifact(i.Db, orm.FilterEq("did", did), orm.FilterEq("rkey", e.Commit.RKey)) 257 258 } 258 259 259 260 if err != nil { ··· 291 292 292 293 includeBluesky := record.Bluesky 293 294 295 + pronouns := "" 296 + if record.Pronouns != nil { 297 + pronouns = *record.Pronouns 298 + } 299 + 294 300 location := "" 295 301 if record.Location != nil { 296 302 location = *record.Location ··· 325 331 Links: links, 326 332 Stats: stats, 327 333 PinnedRepos: pinned, 334 + Pronouns: pronouns, 328 335 } 329 336 330 337 ddb, ok := i.Db.Execer.(*db.DB) ··· 344 351 345 352 err = db.UpsertProfile(tx, &profile) 346 353 case jmodels.CommitOperationDelete: 347 - err = db.DeleteArtifact(i.Db, db.FilterEq("did", did), db.FilterEq("rkey", e.Commit.RKey)) 354 + err = db.DeleteArtifact(i.Db, orm.FilterEq("did", did), orm.FilterEq("rkey", e.Commit.RKey)) 348 355 } 349 356 350 357 if err != nil { ··· 418 425 // get record from db first 419 426 members, err := db.GetSpindleMembers( 420 427 ddb, 421 - db.FilterEq("did", did), 422 - db.FilterEq("rkey", rkey), 428 + orm.FilterEq("did", did), 429 + orm.FilterEq("rkey", rkey), 423 430 ) 424 431 if err != nil || len(members) != 1 { 425 432 return fmt.Errorf("failed to get member: %w, len(members) = %d", err, len(members)) ··· 434 441 // remove record by rkey && update enforcer 435 442 if err = db.RemoveSpindleMember( 436 443 tx, 437 - db.FilterEq("did", did), 438 - db.FilterEq("rkey", rkey), 444 + orm.FilterEq("did", did), 445 + orm.FilterEq("rkey", rkey), 439 446 ); err != nil { 440 447 return fmt.Errorf("failed to remove from db: %w", err) 441 448 } ··· 517 524 // get record from db first 518 525 spindles, err := db.GetSpindles( 519 526 ddb, 520 - db.FilterEq("owner", did), 521 - db.FilterEq("instance", instance), 527 + orm.FilterEq("owner", did), 528 + orm.FilterEq("instance", instance), 522 529 ) 523 530 if err != nil || len(spindles) != 1 { 524 531 return fmt.Errorf("failed to get spindles: %w, len(spindles) = %d", err, len(spindles)) ··· 537 544 // remove spindle members first 538 545 err = db.RemoveSpindleMember( 539 546 tx, 540 - db.FilterEq("owner", did), 541 - db.FilterEq("instance", instance), 547 + orm.FilterEq("owner", did), 548 + orm.FilterEq("instance", instance), 542 549 ) 543 550 if err != nil { 544 551 return err ··· 546 553 547 554 err = db.DeleteSpindle( 548 555 tx, 549 - db.FilterEq("owner", did), 550 - db.FilterEq("instance", instance), 556 + orm.FilterEq("owner", did), 557 + orm.FilterEq("instance", instance), 551 558 ) 552 559 if err != nil { 553 560 return err ··· 615 622 case jmodels.CommitOperationDelete: 616 623 if err := db.DeleteString( 617 624 ddb, 618 - db.FilterEq("did", did), 619 - db.FilterEq("rkey", rkey), 625 + orm.FilterEq("did", did), 626 + orm.FilterEq("rkey", rkey), 620 627 ); err != nil { 621 628 l.Error("failed to delete", "err", err) 622 629 return fmt.Errorf("failed to delete string record: %w", err) ··· 734 741 // get record from db first 735 742 registrations, err := db.GetRegistrations( 736 743 ddb, 737 - db.FilterEq("domain", domain), 738 - db.FilterEq("did", did), 744 + orm.FilterEq("domain", domain), 745 + orm.FilterEq("did", did), 739 746 ) 740 747 if err != nil { 741 748 return fmt.Errorf("failed to get registration: %w", err) ··· 756 763 757 764 err = db.DeleteKnot( 758 765 tx, 759 - db.FilterEq("did", did), 760 - db.FilterEq("domain", domain), 766 + orm.FilterEq("did", did), 767 + orm.FilterEq("domain", domain), 761 768 ) 762 769 if err != nil { 763 770 return err ··· 835 842 return nil 836 843 837 844 case jmodels.CommitOperationDelete: 845 + tx, err := ddb.BeginTx(ctx, nil) 846 + if err != nil { 847 + l.Error("failed to begin transaction", "err", err) 848 + return err 849 + } 850 + defer tx.Rollback() 851 + 838 852 if err := db.DeleteIssues( 839 - ddb, 840 - db.FilterEq("did", did), 841 - db.FilterEq("rkey", rkey), 853 + tx, 854 + did, 855 + rkey, 842 856 ); err != nil { 843 857 l.Error("failed to delete", "err", err) 844 858 return fmt.Errorf("failed to delete issue record: %w", err) 859 + } 860 + if err := tx.Commit(); err != nil { 861 + l.Error("failed to commit txn", "err", err) 862 + return err 845 863 } 846 864 847 865 return nil ··· 882 900 return fmt.Errorf("failed to validate comment: %w", err) 883 901 } 884 902 885 - _, err = db.AddIssueComment(ddb, *comment) 903 + tx, err := ddb.Begin() 904 + if err != nil { 905 + return fmt.Errorf("failed to start transaction: %w", err) 906 + } 907 + defer tx.Rollback() 908 + 909 + _, err = db.AddIssueComment(tx, *comment) 886 910 if err != nil { 887 911 return fmt.Errorf("failed to create issue comment: %w", err) 888 912 } 889 913 890 - return nil 914 + return tx.Commit() 891 915 892 916 case jmodels.CommitOperationDelete: 893 917 if err := db.DeleteIssueComments( 894 918 ddb, 895 - db.FilterEq("did", did), 896 - db.FilterEq("rkey", rkey), 919 + orm.FilterEq("did", did), 920 + orm.FilterEq("rkey", rkey), 897 921 ); err != nil { 898 922 return fmt.Errorf("failed to delete issue comment record: %w", err) 899 923 } ··· 946 970 case jmodels.CommitOperationDelete: 947 971 if err := db.DeleteLabelDefinition( 948 972 ddb, 949 - db.FilterEq("did", did), 950 - db.FilterEq("rkey", rkey), 973 + orm.FilterEq("did", did), 974 + orm.FilterEq("rkey", rkey), 951 975 ); err != nil { 952 976 return fmt.Errorf("failed to delete labeldef record: %w", err) 953 977 } ··· 987 1011 var repo *models.Repo 988 1012 switch collection { 989 1013 case tangled.RepoIssueNSID: 990 - i, err := db.GetIssues(ddb, db.FilterEq("at_uri", subject)) 1014 + i, err := db.GetIssues(ddb, orm.FilterEq("at_uri", subject)) 991 1015 if err != nil || len(i) != 1 { 992 1016 return fmt.Errorf("failed to find subject: %w || subject count %d", err, len(i)) 993 1017 } ··· 996 1020 return fmt.Errorf("unsupport label subject: %s", collection) 997 1021 } 998 1022 999 - actx, err := db.NewLabelApplicationCtx(ddb, db.FilterIn("at_uri", repo.Labels)) 1023 + actx, err := db.NewLabelApplicationCtx(ddb, orm.FilterIn("at_uri", repo.Labels)) 1000 1024 if err != nil { 1001 1025 return fmt.Errorf("failed to build label application ctx: %w", err) 1002 1026 }
+189 -132
appview/issues/issues.go
··· 7 7 "fmt" 8 8 "log/slog" 9 9 "net/http" 10 - "slices" 11 10 "time" 12 11 13 12 comatproto "github.com/bluesky-social/indigo/api/atproto" ··· 20 19 "tangled.org/core/appview/config" 21 20 "tangled.org/core/appview/db" 22 21 issues_indexer "tangled.org/core/appview/indexer/issues" 22 + "tangled.org/core/appview/mentions" 23 23 "tangled.org/core/appview/models" 24 24 "tangled.org/core/appview/notify" 25 25 "tangled.org/core/appview/oauth" 26 26 "tangled.org/core/appview/pages" 27 + "tangled.org/core/appview/pages/repoinfo" 27 28 "tangled.org/core/appview/pagination" 28 29 "tangled.org/core/appview/reporesolver" 29 30 "tangled.org/core/appview/validator" 30 31 "tangled.org/core/idresolver" 32 + "tangled.org/core/orm" 33 + "tangled.org/core/rbac" 31 34 "tangled.org/core/tid" 32 35 ) 33 36 34 37 type Issues struct { 35 - oauth *oauth.OAuth 36 - repoResolver *reporesolver.RepoResolver 37 - pages *pages.Pages 38 - idResolver *idresolver.Resolver 39 - db *db.DB 40 - config *config.Config 41 - notifier notify.Notifier 42 - logger *slog.Logger 43 - validator *validator.Validator 44 - indexer *issues_indexer.Indexer 38 + oauth *oauth.OAuth 39 + repoResolver *reporesolver.RepoResolver 40 + enforcer *rbac.Enforcer 41 + pages *pages.Pages 42 + idResolver *idresolver.Resolver 43 + mentionsResolver *mentions.Resolver 44 + db *db.DB 45 + config *config.Config 46 + notifier notify.Notifier 47 + logger *slog.Logger 48 + validator *validator.Validator 49 + indexer *issues_indexer.Indexer 45 50 } 46 51 47 52 func New( 48 53 oauth *oauth.OAuth, 49 54 repoResolver *reporesolver.RepoResolver, 55 + enforcer *rbac.Enforcer, 50 56 pages *pages.Pages, 51 57 idResolver *idresolver.Resolver, 58 + mentionsResolver *mentions.Resolver, 52 59 db *db.DB, 53 60 config *config.Config, 54 61 notifier notify.Notifier, ··· 57 64 logger *slog.Logger, 58 65 ) *Issues { 59 66 return &Issues{ 60 - oauth: oauth, 61 - repoResolver: repoResolver, 62 - pages: pages, 63 - idResolver: idResolver, 64 - db: db, 65 - config: config, 66 - notifier: notifier, 67 - logger: logger, 68 - validator: validator, 69 - indexer: indexer, 67 + oauth: oauth, 68 + repoResolver: repoResolver, 69 + enforcer: enforcer, 70 + pages: pages, 71 + idResolver: idResolver, 72 + mentionsResolver: mentionsResolver, 73 + db: db, 74 + config: config, 75 + notifier: notifier, 76 + logger: logger, 77 + validator: validator, 78 + indexer: indexer, 70 79 } 71 80 } 72 81 ··· 96 105 userReactions = db.GetReactionStatusMap(rp.db, user.Did, issue.AtUri()) 97 106 } 98 107 108 + backlinks, err := db.GetBacklinks(rp.db, issue.AtUri()) 109 + if err != nil { 110 + l.Error("failed to fetch backlinks", "err", err) 111 + rp.pages.Error503(w) 112 + return 113 + } 114 + 99 115 labelDefs, err := db.GetLabelDefinitions( 100 116 rp.db, 101 - db.FilterIn("at_uri", f.Repo.Labels), 102 - db.FilterContains("scope", tangled.RepoIssueNSID), 117 + orm.FilterIn("at_uri", f.Labels), 118 + orm.FilterContains("scope", tangled.RepoIssueNSID), 103 119 ) 104 120 if err != nil { 105 121 l.Error("failed to fetch labels", "err", err) ··· 114 130 115 131 rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{ 116 132 LoggedInUser: user, 117 - RepoInfo: f.RepoInfo(user), 133 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 118 134 Issue: issue, 119 135 CommentList: issue.CommentList(), 136 + Backlinks: backlinks, 120 137 OrderedReactionKinds: models.OrderedReactionKinds, 121 138 Reactions: reactionMap, 122 139 UserReacted: userReactions, ··· 127 144 func (rp *Issues) EditIssue(w http.ResponseWriter, r *http.Request) { 128 145 l := rp.logger.With("handler", "EditIssue") 129 146 user := rp.oauth.GetUser(r) 130 - f, err := rp.repoResolver.Resolve(r) 131 - if err != nil { 132 - l.Error("failed to get repo and knot", "err", err) 133 - return 134 - } 135 147 136 148 issue, ok := r.Context().Value("issue").(*models.Issue) 137 149 if !ok { ··· 144 156 case http.MethodGet: 145 157 rp.pages.EditIssueFragment(w, pages.EditIssueParams{ 146 158 LoggedInUser: user, 147 - RepoInfo: f.RepoInfo(user), 159 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 148 160 Issue: issue, 149 161 }) 150 162 case http.MethodPost: ··· 152 164 newIssue := issue 153 165 newIssue.Title = r.FormValue("title") 154 166 newIssue.Body = r.FormValue("body") 167 + newIssue.Mentions, newIssue.References = rp.mentionsResolver.Resolve(r.Context(), newIssue.Body) 155 168 156 169 if err := rp.validator.ValidateIssue(newIssue); err != nil { 157 170 l.Error("validation error", "err", err) ··· 221 234 l := rp.logger.With("handler", "DeleteIssue") 222 235 noticeId := "issue-actions-error" 223 236 224 - user := rp.oauth.GetUser(r) 225 - 226 237 f, err := rp.repoResolver.Resolve(r) 227 238 if err != nil { 228 239 l.Error("failed to get repo and knot", "err", err) ··· 237 248 } 238 249 l = l.With("did", issue.Did, "rkey", issue.Rkey) 239 250 251 + tx, err := rp.db.Begin() 252 + if err != nil { 253 + l.Error("failed to start transaction", "err", err) 254 + rp.pages.Notice(w, "issue-comment", "Failed to create comment, try again later.") 255 + return 256 + } 257 + defer tx.Rollback() 258 + 240 259 // delete from PDS 241 260 client, err := rp.oauth.AuthorizedClient(r) 242 261 if err != nil { ··· 257 276 } 258 277 259 278 // delete from db 260 - if err := db.DeleteIssues(rp.db, db.FilterEq("id", issue.Id)); err != nil { 279 + if err := db.DeleteIssues(tx, issue.Did, issue.Rkey); err != nil { 261 280 l.Error("failed to delete issue", "err", err) 262 281 rp.pages.Notice(w, noticeId, "Failed to delete issue.") 263 282 return 264 283 } 284 + tx.Commit() 285 + 286 + rp.notifier.DeleteIssue(r.Context(), issue) 265 287 266 288 // return to all issues page 267 - rp.pages.HxRedirect(w, "/"+f.RepoInfo(user).FullName()+"/issues") 289 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 290 + rp.pages.HxRedirect(w, "/"+ownerSlashRepo+"/issues") 268 291 } 269 292 270 293 func (rp *Issues) CloseIssue(w http.ResponseWriter, r *http.Request) { ··· 283 306 return 284 307 } 285 308 286 - collaborators, err := f.Collaborators(r.Context()) 287 - if err != nil { 288 - l.Error("failed to fetch repo collaborators", "err", err) 289 - } 290 - isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 291 - return user.Did == collab.Did 292 - }) 309 + roles := repoinfo.RolesInRepo{Roles: rp.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())} 310 + isRepoOwner := roles.IsOwner() 311 + isCollaborator := roles.IsCollaborator() 293 312 isIssueOwner := user.Did == issue.Did 294 313 295 314 // TODO: make this more granular 296 - if isIssueOwner || isCollaborator { 315 + if isIssueOwner || isRepoOwner || isCollaborator { 297 316 err = db.CloseIssues( 298 317 rp.db, 299 - db.FilterEq("id", issue.Id), 318 + orm.FilterEq("id", issue.Id), 300 319 ) 301 320 if err != nil { 302 321 l.Error("failed to close issue", "err", err) 303 322 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 304 323 return 305 324 } 325 + // change the issue state (this will pass down to the notifiers) 326 + issue.Open = false 306 327 307 328 // notify about the issue closure 308 - rp.notifier.NewIssueClosed(r.Context(), issue) 329 + rp.notifier.NewIssueState(r.Context(), syntax.DID(user.Did), issue) 309 330 310 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 331 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 332 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId)) 311 333 return 312 334 } else { 313 335 l.Error("user is not permitted to close issue") ··· 332 354 return 333 355 } 334 356 335 - collaborators, err := f.Collaborators(r.Context()) 336 - if err != nil { 337 - l.Error("failed to fetch repo collaborators", "err", err) 338 - } 339 - isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 340 - return user.Did == collab.Did 341 - }) 357 + roles := repoinfo.RolesInRepo{Roles: rp.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())} 358 + isRepoOwner := roles.IsOwner() 359 + isCollaborator := roles.IsCollaborator() 342 360 isIssueOwner := user.Did == issue.Did 343 361 344 - if isCollaborator || isIssueOwner { 362 + if isCollaborator || isRepoOwner || isIssueOwner { 345 363 err := db.ReopenIssues( 346 364 rp.db, 347 - db.FilterEq("id", issue.Id), 365 + orm.FilterEq("id", issue.Id), 348 366 ) 349 367 if err != nil { 350 368 l.Error("failed to reopen issue", "err", err) 351 369 rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") 352 370 return 353 371 } 354 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 372 + // change the issue state (this will pass down to the notifiers) 373 + issue.Open = true 374 + 375 + // notify about the issue reopen 376 + rp.notifier.NewIssueState(r.Context(), syntax.DID(user.Did), issue) 377 + 378 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 379 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId)) 355 380 return 356 381 } else { 357 382 l.Error("user is not the owner of the repo") ··· 388 413 replyTo = &replyToUri 389 414 } 390 415 416 + mentions, references := rp.mentionsResolver.Resolve(r.Context(), body) 417 + 391 418 comment := models.IssueComment{ 392 - Did: user.Did, 393 - Rkey: tid.TID(), 394 - IssueAt: issue.AtUri().String(), 395 - ReplyTo: replyTo, 396 - Body: body, 397 - Created: time.Now(), 419 + Did: user.Did, 420 + Rkey: tid.TID(), 421 + IssueAt: issue.AtUri().String(), 422 + ReplyTo: replyTo, 423 + Body: body, 424 + Created: time.Now(), 425 + Mentions: mentions, 426 + References: references, 398 427 } 399 428 if err = rp.validator.ValidateIssueComment(&comment); err != nil { 400 429 l.Error("failed to validate comment", "err", err) ··· 431 460 } 432 461 }() 433 462 434 - commentId, err := db.AddIssueComment(rp.db, comment) 463 + tx, err := rp.db.Begin() 464 + if err != nil { 465 + l.Error("failed to start transaction", "err", err) 466 + rp.pages.Notice(w, "issue-comment", "Failed to create comment, try again later.") 467 + return 468 + } 469 + defer tx.Rollback() 470 + 471 + commentId, err := db.AddIssueComment(tx, comment) 435 472 if err != nil { 436 473 l.Error("failed to create comment", "err", err) 437 474 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 438 475 return 439 476 } 477 + err = tx.Commit() 478 + if err != nil { 479 + l.Error("failed to commit transaction", "err", err) 480 + rp.pages.Notice(w, "issue-comment", "Failed to create comment, try again later.") 481 + return 482 + } 440 483 441 484 // reset atUri to make rollback a no-op 442 485 atUri = "" 443 486 444 487 // notify about the new comment 445 488 comment.Id = commentId 446 - rp.notifier.NewIssueComment(r.Context(), &comment) 447 489 448 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issue.IssueId, commentId)) 490 + rp.notifier.NewIssueComment(r.Context(), &comment, mentions) 491 + 492 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 493 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", ownerSlashRepo, issue.IssueId, commentId)) 449 494 } 450 495 451 496 func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) { 452 497 l := rp.logger.With("handler", "IssueComment") 453 498 user := rp.oauth.GetUser(r) 454 - f, err := rp.repoResolver.Resolve(r) 455 - if err != nil { 456 - l.Error("failed to get repo and knot", "err", err) 457 - return 458 - } 459 499 460 500 issue, ok := r.Context().Value("issue").(*models.Issue) 461 501 if !ok { ··· 467 507 commentId := chi.URLParam(r, "commentId") 468 508 comments, err := db.GetIssueComments( 469 509 rp.db, 470 - db.FilterEq("id", commentId), 510 + orm.FilterEq("id", commentId), 471 511 ) 472 512 if err != nil { 473 513 l.Error("failed to fetch comment", "id", commentId) ··· 483 523 484 524 rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 485 525 LoggedInUser: user, 486 - RepoInfo: f.RepoInfo(user), 526 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 487 527 Issue: issue, 488 528 Comment: &comment, 489 529 }) ··· 492 532 func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) { 493 533 l := rp.logger.With("handler", "EditIssueComment") 494 534 user := rp.oauth.GetUser(r) 495 - f, err := rp.repoResolver.Resolve(r) 496 - if err != nil { 497 - l.Error("failed to get repo and knot", "err", err) 498 - return 499 - } 500 535 501 536 issue, ok := r.Context().Value("issue").(*models.Issue) 502 537 if !ok { ··· 508 543 commentId := chi.URLParam(r, "commentId") 509 544 comments, err := db.GetIssueComments( 510 545 rp.db, 511 - db.FilterEq("id", commentId), 546 + orm.FilterEq("id", commentId), 512 547 ) 513 548 if err != nil { 514 549 l.Error("failed to fetch comment", "id", commentId) ··· 532 567 case http.MethodGet: 533 568 rp.pages.EditIssueCommentFragment(w, pages.EditIssueCommentParams{ 534 569 LoggedInUser: user, 535 - RepoInfo: f.RepoInfo(user), 570 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 536 571 Issue: issue, 537 572 Comment: &comment, 538 573 }) ··· 550 585 newComment := comment 551 586 newComment.Body = newBody 552 587 newComment.Edited = &now 588 + newComment.Mentions, newComment.References = rp.mentionsResolver.Resolve(r.Context(), newBody) 589 + 553 590 record := newComment.AsRecord() 554 591 555 - _, err = db.AddIssueComment(rp.db, newComment) 592 + tx, err := rp.db.Begin() 593 + if err != nil { 594 + l.Error("failed to start transaction", "err", err) 595 + rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 596 + return 597 + } 598 + defer tx.Rollback() 599 + 600 + _, err = db.AddIssueComment(tx, newComment) 556 601 if err != nil { 557 602 l.Error("failed to perferom update-description query", "err", err) 558 603 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 559 604 return 560 605 } 606 + tx.Commit() 561 607 562 608 // rkey is optional, it was introduced later 563 609 if newComment.Rkey != "" { ··· 586 632 // return new comment body with htmx 587 633 rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 588 634 LoggedInUser: user, 589 - RepoInfo: f.RepoInfo(user), 635 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 590 636 Issue: issue, 591 637 Comment: &newComment, 592 638 }) ··· 596 642 func (rp *Issues) ReplyIssueCommentPlaceholder(w http.ResponseWriter, r *http.Request) { 597 643 l := rp.logger.With("handler", "ReplyIssueCommentPlaceholder") 598 644 user := rp.oauth.GetUser(r) 599 - f, err := rp.repoResolver.Resolve(r) 600 - if err != nil { 601 - l.Error("failed to get repo and knot", "err", err) 602 - return 603 - } 604 645 605 646 issue, ok := r.Context().Value("issue").(*models.Issue) 606 647 if !ok { ··· 612 653 commentId := chi.URLParam(r, "commentId") 613 654 comments, err := db.GetIssueComments( 614 655 rp.db, 615 - db.FilterEq("id", commentId), 656 + orm.FilterEq("id", commentId), 616 657 ) 617 658 if err != nil { 618 659 l.Error("failed to fetch comment", "id", commentId) ··· 628 669 629 670 rp.pages.ReplyIssueCommentPlaceholderFragment(w, pages.ReplyIssueCommentPlaceholderParams{ 630 671 LoggedInUser: user, 631 - RepoInfo: f.RepoInfo(user), 672 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 632 673 Issue: issue, 633 674 Comment: &comment, 634 675 }) ··· 637 678 func (rp *Issues) ReplyIssueComment(w http.ResponseWriter, r *http.Request) { 638 679 l := rp.logger.With("handler", "ReplyIssueComment") 639 680 user := rp.oauth.GetUser(r) 640 - f, err := rp.repoResolver.Resolve(r) 641 - if err != nil { 642 - l.Error("failed to get repo and knot", "err", err) 643 - return 644 - } 645 681 646 682 issue, ok := r.Context().Value("issue").(*models.Issue) 647 683 if !ok { ··· 653 689 commentId := chi.URLParam(r, "commentId") 654 690 comments, err := db.GetIssueComments( 655 691 rp.db, 656 - db.FilterEq("id", commentId), 692 + orm.FilterEq("id", commentId), 657 693 ) 658 694 if err != nil { 659 695 l.Error("failed to fetch comment", "id", commentId) ··· 669 705 670 706 rp.pages.ReplyIssueCommentFragment(w, pages.ReplyIssueCommentParams{ 671 707 LoggedInUser: user, 672 - RepoInfo: f.RepoInfo(user), 708 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 673 709 Issue: issue, 674 710 Comment: &comment, 675 711 }) ··· 678 714 func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { 679 715 l := rp.logger.With("handler", "DeleteIssueComment") 680 716 user := rp.oauth.GetUser(r) 681 - f, err := rp.repoResolver.Resolve(r) 682 - if err != nil { 683 - l.Error("failed to get repo and knot", "err", err) 684 - return 685 - } 686 717 687 718 issue, ok := r.Context().Value("issue").(*models.Issue) 688 719 if !ok { ··· 694 725 commentId := chi.URLParam(r, "commentId") 695 726 comments, err := db.GetIssueComments( 696 727 rp.db, 697 - db.FilterEq("id", commentId), 728 + orm.FilterEq("id", commentId), 698 729 ) 699 730 if err != nil { 700 731 l.Error("failed to fetch comment", "id", commentId) ··· 721 752 722 753 // optimistic deletion 723 754 deleted := time.Now() 724 - err = db.DeleteIssueComments(rp.db, db.FilterEq("id", comment.Id)) 755 + err = db.DeleteIssueComments(rp.db, orm.FilterEq("id", comment.Id)) 725 756 if err != nil { 726 757 l.Error("failed to delete comment", "err", err) 727 758 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment") ··· 753 784 // htmx fragment of comment after deletion 754 785 rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 755 786 LoggedInUser: user, 756 - RepoInfo: f.RepoInfo(user), 787 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 757 788 Issue: issue, 758 789 Comment: &comment, 759 790 }) ··· 783 814 return 784 815 } 785 816 817 + totalIssues := 0 818 + if isOpen { 819 + totalIssues = f.RepoStats.IssueCount.Open 820 + } else { 821 + totalIssues = f.RepoStats.IssueCount.Closed 822 + } 823 + 786 824 keyword := params.Get("q") 787 825 788 - var ids []int64 826 + var issues []models.Issue 789 827 searchOpts := models.IssueSearchOptions{ 790 828 Keyword: keyword, 791 829 RepoAt: f.RepoAt().String(), ··· 798 836 l.Error("failed to search for issues", "err", err) 799 837 return 800 838 } 801 - ids = res.Hits 802 - l.Debug("searched issues with indexer", "count", len(ids)) 803 - } else { 804 - ids, err = db.GetIssueIDs(rp.db, searchOpts) 839 + l.Debug("searched issues with indexer", "count", len(res.Hits)) 840 + totalIssues = int(res.Total) 841 + 842 + issues, err = db.GetIssues( 843 + rp.db, 844 + orm.FilterIn("id", res.Hits), 845 + ) 805 846 if err != nil { 806 - l.Error("failed to search for issues", "err", err) 847 + l.Error("failed to get issues", "err", err) 848 + rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 807 849 return 808 850 } 809 - l.Debug("indexed all issues from the db", "count", len(ids)) 810 - } 811 851 812 - issues, err := db.GetIssues( 813 - rp.db, 814 - db.FilterIn("id", ids), 815 - ) 816 - if err != nil { 817 - l.Error("failed to get issues", "err", err) 818 - rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 819 - return 852 + } else { 853 + openInt := 0 854 + if isOpen { 855 + openInt = 1 856 + } 857 + issues, err = db.GetIssuesPaginated( 858 + rp.db, 859 + page, 860 + orm.FilterEq("repo_at", f.RepoAt()), 861 + orm.FilterEq("open", openInt), 862 + ) 863 + if err != nil { 864 + l.Error("failed to get issues", "err", err) 865 + rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 866 + return 867 + } 820 868 } 821 869 822 870 labelDefs, err := db.GetLabelDefinitions( 823 871 rp.db, 824 - db.FilterIn("at_uri", f.Repo.Labels), 825 - db.FilterContains("scope", tangled.RepoIssueNSID), 872 + orm.FilterIn("at_uri", f.Labels), 873 + orm.FilterContains("scope", tangled.RepoIssueNSID), 826 874 ) 827 875 if err != nil { 828 876 l.Error("failed to fetch labels", "err", err) ··· 837 885 838 886 rp.pages.RepoIssues(w, pages.RepoIssuesParams{ 839 887 LoggedInUser: rp.oauth.GetUser(r), 840 - RepoInfo: f.RepoInfo(user), 888 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 841 889 Issues: issues, 890 + IssueCount: totalIssues, 842 891 LabelDefs: defs, 843 892 FilteringByOpen: isOpen, 844 893 FilterQuery: keyword, ··· 860 909 case http.MethodGet: 861 910 rp.pages.RepoNewIssue(w, pages.RepoNewIssueParams{ 862 911 LoggedInUser: user, 863 - RepoInfo: f.RepoInfo(user), 912 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 864 913 }) 865 914 case http.MethodPost: 915 + body := r.FormValue("body") 916 + mentions, references := rp.mentionsResolver.Resolve(r.Context(), body) 917 + 866 918 issue := &models.Issue{ 867 - RepoAt: f.RepoAt(), 868 - Rkey: tid.TID(), 869 - Title: r.FormValue("title"), 870 - Body: r.FormValue("body"), 871 - Open: true, 872 - Did: user.Did, 873 - Created: time.Now(), 874 - Repo: &f.Repo, 919 + RepoAt: f.RepoAt(), 920 + Rkey: tid.TID(), 921 + Title: r.FormValue("title"), 922 + Body: body, 923 + Open: true, 924 + Did: user.Did, 925 + Created: time.Now(), 926 + Mentions: mentions, 927 + References: references, 928 + Repo: f, 875 929 } 876 930 877 931 if err := rp.validator.ValidateIssue(issue); err != nil { ··· 938 992 939 993 // everything is successful, do not rollback the atproto record 940 994 atUri = "" 941 - rp.notifier.NewIssue(r.Context(), issue) 942 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 995 + 996 + rp.notifier.NewIssue(r.Context(), issue, mentions) 997 + 998 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 999 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId)) 943 1000 return 944 1001 } 945 1002 }
+9 -9
appview/issues/opengraph.go
··· 143 143 var statusBgColor color.RGBA 144 144 145 145 if issue.Open { 146 - statusIcon = "static/icons/circle-dot.svg" 146 + statusIcon = "circle-dot" 147 147 statusText = "open" 148 148 statusBgColor = color.RGBA{34, 139, 34, 255} // green 149 149 } else { 150 - statusIcon = "static/icons/circle-dot.svg" 150 + statusIcon = "ban" 151 151 statusText = "closed" 152 152 statusBgColor = color.RGBA{52, 58, 64, 255} // dark gray 153 153 } ··· 155 155 badgeIconSize := 36 156 156 157 157 // Draw icon with status color (no background) 158 - err = statusCommentsArea.DrawSVGIcon(statusIcon, statsX, statsY+iconBaselineOffset-badgeIconSize/2+5, badgeIconSize, statusBgColor) 158 + err = statusCommentsArea.DrawLucideIcon(statusIcon, statsX, statsY+iconBaselineOffset-badgeIconSize/2+5, badgeIconSize, statusBgColor) 159 159 if err != nil { 160 160 log.Printf("failed to draw status icon: %v", err) 161 161 } ··· 172 172 currentX := statsX + badgeIconSize + 12 + statusTextWidth + 50 173 173 174 174 // Draw comment count 175 - err = statusCommentsArea.DrawSVGIcon("static/icons/message-square.svg", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) 175 + err = statusCommentsArea.DrawLucideIcon("message-square", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) 176 176 if err != nil { 177 177 log.Printf("failed to draw comment icon: %v", err) 178 178 } ··· 193 193 dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2) 194 194 dollyY := statsY + iconBaselineOffset - dollySize/2 + 25 195 195 dollyColor := color.RGBA{180, 180, 180, 255} // light gray 196 - err = dollyArea.DrawSVGIcon("templates/fragments/dolly/silhouette.svg", dollyX, dollyY, dollySize, dollyColor) 196 + err = dollyArea.DrawDolly(dollyX, dollyY, dollySize, dollyColor) 197 197 if err != nil { 198 - log.Printf("dolly silhouette not available (this is ok): %v", err) 198 + log.Printf("dolly not available (this is ok): %v", err) 199 199 } 200 200 201 201 // Draw "opened by @author" and date at the bottom with more spacing ··· 232 232 233 233 // Get owner handle for avatar 234 234 var ownerHandle string 235 - owner, err := rp.idResolver.ResolveIdent(r.Context(), f.Repo.Did) 235 + owner, err := rp.idResolver.ResolveIdent(r.Context(), f.Did) 236 236 if err != nil { 237 - ownerHandle = f.Repo.Did 237 + ownerHandle = f.Did 238 238 } else { 239 239 ownerHandle = "@" + owner.Handle.String() 240 240 } 241 241 242 - card, err := rp.drawIssueSummaryCard(issue, &f.Repo, commentCount, ownerHandle) 242 + card, err := rp.drawIssueSummaryCard(issue, f, commentCount, ownerHandle) 243 243 if err != nil { 244 244 log.Println("failed to draw issue summary card", err) 245 245 http.Error(w, "failed to draw issue summary card", http.StatusInternalServerError)
+46 -24
appview/knots/knots.go
··· 6 6 "log/slog" 7 7 "net/http" 8 8 "slices" 9 + "strings" 9 10 "time" 10 11 11 12 "github.com/go-chi/chi/v5" ··· 20 21 "tangled.org/core/appview/xrpcclient" 21 22 "tangled.org/core/eventconsumer" 22 23 "tangled.org/core/idresolver" 24 + "tangled.org/core/orm" 23 25 "tangled.org/core/rbac" 24 26 "tangled.org/core/tid" 25 27 ··· 38 40 Knotstream *eventconsumer.Consumer 39 41 } 40 42 43 + type tab = map[string]any 44 + 45 + var ( 46 + knotsTabs []tab = []tab{ 47 + {"Name": "profile", "Icon": "user"}, 48 + {"Name": "keys", "Icon": "key"}, 49 + {"Name": "emails", "Icon": "mail"}, 50 + {"Name": "notifications", "Icon": "bell"}, 51 + {"Name": "knots", "Icon": "volleyball"}, 52 + {"Name": "spindles", "Icon": "spool"}, 53 + } 54 + ) 55 + 41 56 func (k *Knots) Router() http.Handler { 42 57 r := chi.NewRouter() 43 58 ··· 58 73 user := k.OAuth.GetUser(r) 59 74 registrations, err := db.GetRegistrations( 60 75 k.Db, 61 - db.FilterEq("did", user.Did), 76 + orm.FilterEq("did", user.Did), 62 77 ) 63 78 if err != nil { 64 79 k.Logger.Error("failed to fetch knot registrations", "err", err) ··· 69 84 k.Pages.Knots(w, pages.KnotsParams{ 70 85 LoggedInUser: user, 71 86 Registrations: registrations, 87 + Tabs: knotsTabs, 88 + Tab: "knots", 72 89 }) 73 90 } 74 91 ··· 86 103 87 104 registrations, err := db.GetRegistrations( 88 105 k.Db, 89 - db.FilterEq("did", user.Did), 90 - db.FilterEq("domain", domain), 106 + orm.FilterEq("did", user.Did), 107 + orm.FilterEq("domain", domain), 91 108 ) 92 109 if err != nil { 93 110 l.Error("failed to get registrations", "err", err) ··· 111 128 repos, err := db.GetRepos( 112 129 k.Db, 113 130 0, 114 - db.FilterEq("knot", domain), 131 + orm.FilterEq("knot", domain), 115 132 ) 116 133 if err != nil { 117 134 l.Error("failed to get knot repos", "err", err) ··· 131 148 Members: members, 132 149 Repos: repoMap, 133 150 IsOwner: true, 151 + Tabs: knotsTabs, 152 + Tab: "knots", 134 153 }) 135 154 } 136 155 ··· 145 164 } 146 165 147 166 domain := r.FormValue("domain") 167 + // Strip protocol, trailing slashes, and whitespace 168 + // Rkey cannot contain slashes 169 + domain = strings.TrimSpace(domain) 170 + domain = strings.TrimPrefix(domain, "https://") 171 + domain = strings.TrimPrefix(domain, "http://") 172 + domain = strings.TrimSuffix(domain, "/") 148 173 if domain == "" { 149 174 k.Pages.Notice(w, noticeId, "Incomplete form.") 150 175 return ··· 269 294 // get record from db first 270 295 registrations, err := db.GetRegistrations( 271 296 k.Db, 272 - db.FilterEq("did", user.Did), 273 - db.FilterEq("domain", domain), 297 + orm.FilterEq("did", user.Did), 298 + orm.FilterEq("domain", domain), 274 299 ) 275 300 if err != nil { 276 301 l.Error("failed to get registration", "err", err) ··· 297 322 298 323 err = db.DeleteKnot( 299 324 tx, 300 - db.FilterEq("did", user.Did), 301 - db.FilterEq("domain", domain), 325 + orm.FilterEq("did", user.Did), 326 + orm.FilterEq("domain", domain), 302 327 ) 303 328 if err != nil { 304 329 l.Error("failed to delete registration", "err", err) ··· 378 403 // get record from db first 379 404 registrations, err := db.GetRegistrations( 380 405 k.Db, 381 - db.FilterEq("did", user.Did), 382 - db.FilterEq("domain", domain), 406 + orm.FilterEq("did", user.Did), 407 + orm.FilterEq("domain", domain), 383 408 ) 384 409 if err != nil { 385 410 l.Error("failed to get registration", "err", err) ··· 469 494 // Get updated registration to show 470 495 registrations, err = db.GetRegistrations( 471 496 k.Db, 472 - db.FilterEq("did", user.Did), 473 - db.FilterEq("domain", domain), 497 + orm.FilterEq("did", user.Did), 498 + orm.FilterEq("domain", domain), 474 499 ) 475 500 if err != nil { 476 501 l.Error("failed to get registration", "err", err) ··· 505 530 506 531 registrations, err := db.GetRegistrations( 507 532 k.Db, 508 - db.FilterEq("did", user.Did), 509 - db.FilterEq("domain", domain), 510 - db.FilterIsNot("registered", "null"), 533 + orm.FilterEq("did", user.Did), 534 + orm.FilterEq("domain", domain), 535 + orm.FilterIsNot("registered", "null"), 511 536 ) 512 537 if err != nil { 513 538 l.Error("failed to get registration", "err", err) ··· 526 551 } 527 552 528 553 member := r.FormValue("member") 554 + member = strings.TrimPrefix(member, "@") 529 555 if member == "" { 530 556 l.Error("empty member") 531 557 k.Pages.Notice(w, noticeId, "Failed to add member, empty form.") ··· 588 614 } 589 615 590 616 // success 591 - k.Pages.HxRedirect(w, fmt.Sprintf("/knots/%s", domain)) 617 + k.Pages.HxRedirect(w, fmt.Sprintf("/settings/knots/%s", domain)) 592 618 } 593 619 594 620 func (k *Knots) removeMember(w http.ResponseWriter, r *http.Request) { ··· 612 638 613 639 registrations, err := db.GetRegistrations( 614 640 k.Db, 615 - db.FilterEq("did", user.Did), 616 - db.FilterEq("domain", domain), 617 - db.FilterIsNot("registered", "null"), 641 + orm.FilterEq("did", user.Did), 642 + orm.FilterEq("domain", domain), 643 + orm.FilterIsNot("registered", "null"), 618 644 ) 619 645 if err != nil { 620 646 l.Error("failed to get registration", "err", err) ··· 626 652 } 627 653 628 654 member := r.FormValue("member") 655 + member = strings.TrimPrefix(member, "@") 629 656 if member == "" { 630 657 l.Error("empty member") 631 658 k.Pages.Notice(w, noticeId, "Failed to remove member, empty form.") ··· 636 663 memberId, err := k.IdResolver.ResolveIdent(r.Context(), member) 637 664 if err != nil { 638 665 l.Error("failed to resolve member identity to handle", "err", err) 639 - k.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 640 - return 641 - } 642 - if memberId.Handle.IsInvalidHandle() { 643 - l.Error("failed to resolve member identity to handle") 644 666 k.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 645 667 return 646 668 }
+6 -5
appview/labels/labels.go
··· 16 16 "tangled.org/core/appview/oauth" 17 17 "tangled.org/core/appview/pages" 18 18 "tangled.org/core/appview/validator" 19 + "tangled.org/core/orm" 19 20 "tangled.org/core/rbac" 20 21 "tangled.org/core/tid" 21 22 ··· 53 54 } 54 55 } 55 56 56 - func (l *Labels) Router(mw *middleware.Middleware) http.Handler { 57 + func (l *Labels) Router() http.Handler { 57 58 r := chi.NewRouter() 58 59 59 60 r.Use(middleware.AuthMiddleware(l.oauth)) ··· 88 89 repoAt := r.Form.Get("repo") 89 90 subjectUri := r.Form.Get("subject") 90 91 91 - repo, err := db.GetRepo(l.db, db.FilterEq("at_uri", repoAt)) 92 + repo, err := db.GetRepo(l.db, orm.FilterEq("at_uri", repoAt)) 92 93 if err != nil { 93 94 fail("Failed to get repository.", err) 94 95 return 95 96 } 96 97 97 98 // find all the labels that this repo subscribes to 98 - repoLabels, err := db.GetRepoLabels(l.db, db.FilterEq("repo_at", repoAt)) 99 + repoLabels, err := db.GetRepoLabels(l.db, orm.FilterEq("repo_at", repoAt)) 99 100 if err != nil { 100 101 fail("Failed to get labels for this repository.", err) 101 102 return ··· 106 107 labelAts = append(labelAts, rl.LabelAt.String()) 107 108 } 108 109 109 - actx, err := db.NewLabelApplicationCtx(l.db, db.FilterIn("at_uri", labelAts)) 110 + actx, err := db.NewLabelApplicationCtx(l.db, orm.FilterIn("at_uri", labelAts)) 110 111 if err != nil { 111 112 fail("Invalid form data.", err) 112 113 return 113 114 } 114 115 115 116 // calculate the start state by applying already known labels 116 - existingOps, err := db.GetLabelOps(l.db, db.FilterEq("subject", subjectUri)) 117 + existingOps, err := db.GetLabelOps(l.db, orm.FilterEq("subject", subjectUri)) 117 118 if err != nil { 118 119 fail("Invalid form data.", err) 119 120 return
+67
appview/mentions/resolver.go
··· 1 + package mentions 2 + 3 + import ( 4 + "context" 5 + "log/slog" 6 + 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 + "tangled.org/core/appview/config" 9 + "tangled.org/core/appview/db" 10 + "tangled.org/core/appview/models" 11 + "tangled.org/core/appview/pages/markup" 12 + "tangled.org/core/idresolver" 13 + ) 14 + 15 + type Resolver struct { 16 + config *config.Config 17 + idResolver *idresolver.Resolver 18 + execer db.Execer 19 + logger *slog.Logger 20 + } 21 + 22 + func New( 23 + config *config.Config, 24 + idResolver *idresolver.Resolver, 25 + execer db.Execer, 26 + logger *slog.Logger, 27 + ) *Resolver { 28 + return &Resolver{ 29 + config, 30 + idResolver, 31 + execer, 32 + logger, 33 + } 34 + } 35 + 36 + func (r *Resolver) Resolve(ctx context.Context, source string) ([]syntax.DID, []syntax.ATURI) { 37 + l := r.logger.With("method", "Resolve") 38 + 39 + rawMentions, rawRefs := markup.FindReferences(r.config.Core.AppviewHost, source) 40 + l.Debug("found possible references", "mentions", rawMentions, "refs", rawRefs) 41 + 42 + idents := r.idResolver.ResolveIdents(ctx, rawMentions) 43 + var mentions []syntax.DID 44 + for _, ident := range idents { 45 + if ident != nil && !ident.Handle.IsInvalidHandle() { 46 + mentions = append(mentions, ident.DID) 47 + } 48 + } 49 + l.Debug("found mentions", "mentions", mentions) 50 + 51 + var resolvedRefs []models.ReferenceLink 52 + for _, rawRef := range rawRefs { 53 + ident, err := r.idResolver.ResolveIdent(ctx, rawRef.Handle) 54 + if err != nil || ident == nil || ident.Handle.IsInvalidHandle() { 55 + continue 56 + } 57 + rawRef.Handle = string(ident.DID) 58 + resolvedRefs = append(resolvedRefs, rawRef) 59 + } 60 + aturiRefs, err := db.ValidateReferenceLinks(r.execer, resolvedRefs) 61 + if err != nil { 62 + l.Error("failed running query", "err", err) 63 + } 64 + l.Debug("found references", "refs", aturiRefs) 65 + 66 + return mentions, aturiRefs 67 + }
+19 -19
appview/middleware/middleware.go
··· 18 18 "tangled.org/core/appview/pagination" 19 19 "tangled.org/core/appview/reporesolver" 20 20 "tangled.org/core/idresolver" 21 + "tangled.org/core/orm" 21 22 "tangled.org/core/rbac" 22 23 ) 23 24 ··· 164 165 ok, err := mw.enforcer.E.Enforce(actor.Did, f.Knot, f.DidSlashRepo(), requiredPerm) 165 166 if err != nil || !ok { 166 167 // we need a logged in user 167 - log.Printf("%s does not have perms of a %s in repo %s", actor.Did, requiredPerm, f.OwnerSlashRepo()) 168 + log.Printf("%s does not have perms of a %s in repo %s", actor.Did, requiredPerm, f.DidSlashRepo()) 168 169 http.Error(w, "Forbiden", http.StatusUnauthorized) 169 170 return 170 171 } ··· 180 181 return func(next http.Handler) http.Handler { 181 182 return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 182 183 didOrHandle := chi.URLParam(req, "user") 184 + didOrHandle = strings.TrimPrefix(didOrHandle, "@") 185 + 183 186 if slices.Contains(excluded, didOrHandle) { 184 187 next.ServeHTTP(w, req) 185 188 return 186 189 } 187 190 188 - didOrHandle = strings.TrimPrefix(didOrHandle, "@") 189 - 190 191 id, err := mw.idResolver.ResolveIdent(req.Context(), didOrHandle) 191 192 if err != nil { 192 193 // invalid did or handle ··· 206 207 return func(next http.Handler) http.Handler { 207 208 return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 208 209 repoName := chi.URLParam(req, "repo") 210 + repoName = strings.TrimSuffix(repoName, ".git") 211 + 209 212 id, ok := req.Context().Value("resolvedId").(identity.Identity) 210 213 if !ok { 211 214 log.Println("malformed middleware") ··· 215 218 216 219 repo, err := db.GetRepo( 217 220 mw.db, 218 - db.FilterEq("did", id.DID.String()), 219 - db.FilterEq("name", repoName), 221 + orm.FilterEq("did", id.DID.String()), 222 + orm.FilterEq("name", repoName), 220 223 ) 221 224 if err != nil { 222 225 log.Println("failed to resolve repo", "err", err) 226 + w.WriteHeader(http.StatusNotFound) 223 227 mw.pages.ErrorKnot404(w) 224 228 return 225 229 } ··· 237 241 f, err := mw.repoResolver.Resolve(r) 238 242 if err != nil { 239 243 log.Println("failed to fully resolve repo", err) 244 + w.WriteHeader(http.StatusNotFound) 240 245 mw.pages.ErrorKnot404(w) 241 246 return 242 247 } ··· 244 249 prId := chi.URLParam(r, "pull") 245 250 prIdInt, err := strconv.Atoi(prId) 246 251 if err != nil { 247 - http.Error(w, "bad pr id", http.StatusBadRequest) 248 252 log.Println("failed to parse pr id", err) 253 + mw.pages.Error404(w) 249 254 return 250 255 } 251 256 252 257 pr, err := db.GetPull(mw.db, f.RepoAt(), prIdInt) 253 258 if err != nil { 254 259 log.Println("failed to get pull and comments", err) 260 + mw.pages.Error404(w) 255 261 return 256 262 } 257 263 ··· 284 290 f, err := mw.repoResolver.Resolve(r) 285 291 if err != nil { 286 292 log.Println("failed to fully resolve repo", err) 293 + w.WriteHeader(http.StatusNotFound) 287 294 mw.pages.ErrorKnot404(w) 288 295 return 289 296 } ··· 292 299 issueId, err := strconv.Atoi(issueIdStr) 293 300 if err != nil { 294 301 log.Println("failed to fully resolve issue ID", err) 295 - mw.pages.ErrorKnot404(w) 302 + mw.pages.Error404(w) 296 303 return 297 304 } 298 305 299 - issues, err := db.GetIssues( 300 - mw.db, 301 - db.FilterEq("repo_at", f.RepoAt()), 302 - db.FilterEq("issue_id", issueId), 303 - ) 306 + issue, err := db.GetIssue(mw.db, f.RepoAt(), issueId) 304 307 if err != nil { 305 308 log.Println("failed to get issues", "err", err) 309 + mw.pages.Error404(w) 306 310 return 307 311 } 308 - if len(issues) != 1 { 309 - log.Println("got incorrect number of issues", "len(issuse)", len(issues)) 310 - return 311 - } 312 - issue := issues[0] 313 312 314 - ctx := context.WithValue(r.Context(), "issue", &issue) 313 + ctx := context.WithValue(r.Context(), "issue", issue) 315 314 next.ServeHTTP(w, r.WithContext(ctx)) 316 315 }) 317 316 } ··· 328 327 f, err := mw.repoResolver.Resolve(r) 329 328 if err != nil { 330 329 log.Println("failed to fully resolve repo", err) 330 + w.WriteHeader(http.StatusNotFound) 331 331 mw.pages.ErrorKnot404(w) 332 332 return 333 333 } 334 334 335 - fullName := f.OwnerHandle() + "/" + f.Name 335 + fullName := reporesolver.GetBaseRepoPath(r, f) 336 336 337 337 if r.Header.Get("User-Agent") == "Go-http-client/1.1" { 338 338 if r.URL.Query().Get("go-get") == "1" {
+70 -34
appview/models/issue.go
··· 10 10 ) 11 11 12 12 type Issue struct { 13 - Id int64 14 - Did string 15 - Rkey string 16 - RepoAt syntax.ATURI 17 - IssueId int 18 - Created time.Time 19 - Edited *time.Time 20 - Deleted *time.Time 21 - Title string 22 - Body string 23 - Open bool 13 + Id int64 14 + Did string 15 + Rkey string 16 + RepoAt syntax.ATURI 17 + IssueId int 18 + Created time.Time 19 + Edited *time.Time 20 + Deleted *time.Time 21 + Title string 22 + Body string 23 + Open bool 24 + Mentions []syntax.DID 25 + References []syntax.ATURI 24 26 25 27 // optionally, populate this when querying for reverse mappings 26 28 // like comment counts, parent repo etc. ··· 34 36 } 35 37 36 38 func (i *Issue) AsRecord() tangled.RepoIssue { 39 + mentions := make([]string, len(i.Mentions)) 40 + for i, did := range i.Mentions { 41 + mentions[i] = string(did) 42 + } 43 + references := make([]string, len(i.References)) 44 + for i, uri := range i.References { 45 + references[i] = string(uri) 46 + } 37 47 return tangled.RepoIssue{ 38 - Repo: i.RepoAt.String(), 39 - Title: i.Title, 40 - Body: &i.Body, 41 - CreatedAt: i.Created.Format(time.RFC3339), 48 + Repo: i.RepoAt.String(), 49 + Title: i.Title, 50 + Body: &i.Body, 51 + Mentions: mentions, 52 + References: references, 53 + CreatedAt: i.Created.Format(time.RFC3339), 42 54 } 43 55 } 44 56 ··· 161 173 } 162 174 163 175 type IssueComment struct { 164 - Id int64 165 - Did string 166 - Rkey string 167 - IssueAt string 168 - ReplyTo *string 169 - Body string 170 - Created time.Time 171 - Edited *time.Time 172 - Deleted *time.Time 176 + Id int64 177 + Did string 178 + Rkey string 179 + IssueAt string 180 + ReplyTo *string 181 + Body string 182 + Created time.Time 183 + Edited *time.Time 184 + Deleted *time.Time 185 + Mentions []syntax.DID 186 + References []syntax.ATURI 173 187 } 174 188 175 189 func (i *IssueComment) AtUri() syntax.ATURI { ··· 177 191 } 178 192 179 193 func (i *IssueComment) AsRecord() tangled.RepoIssueComment { 194 + mentions := make([]string, len(i.Mentions)) 195 + for i, did := range i.Mentions { 196 + mentions[i] = string(did) 197 + } 198 + references := make([]string, len(i.References)) 199 + for i, uri := range i.References { 200 + references[i] = string(uri) 201 + } 180 202 return tangled.RepoIssueComment{ 181 - Body: i.Body, 182 - Issue: i.IssueAt, 183 - CreatedAt: i.Created.Format(time.RFC3339), 184 - ReplyTo: i.ReplyTo, 203 + Body: i.Body, 204 + Issue: i.IssueAt, 205 + CreatedAt: i.Created.Format(time.RFC3339), 206 + ReplyTo: i.ReplyTo, 207 + Mentions: mentions, 208 + References: references, 185 209 } 186 210 } 187 211 ··· 205 229 return nil, err 206 230 } 207 231 232 + i := record 233 + mentions := make([]syntax.DID, len(record.Mentions)) 234 + for i, did := range record.Mentions { 235 + mentions[i] = syntax.DID(did) 236 + } 237 + references := make([]syntax.ATURI, len(record.References)) 238 + for i, uri := range i.References { 239 + references[i] = syntax.ATURI(uri) 240 + } 241 + 208 242 comment := IssueComment{ 209 - Did: ownerDid, 210 - Rkey: rkey, 211 - Body: record.Body, 212 - IssueAt: record.Issue, 213 - ReplyTo: record.ReplyTo, 214 - Created: created, 243 + Did: ownerDid, 244 + Rkey: rkey, 245 + Body: record.Body, 246 + IssueAt: record.Issue, 247 + ReplyTo: record.ReplyTo, 248 + Created: created, 249 + Mentions: mentions, 250 + References: references, 215 251 } 216 252 217 253 return &comment, nil
+25 -43
appview/models/label.go
··· 14 14 "github.com/bluesky-social/indigo/atproto/syntax" 15 15 "github.com/bluesky-social/indigo/xrpc" 16 16 "tangled.org/core/api/tangled" 17 - "tangled.org/core/consts" 18 17 "tangled.org/core/idresolver" 19 18 ) 20 19 ··· 461 460 return result 462 461 } 463 462 464 - var ( 465 - LabelWontfix = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "wontfix") 466 - LabelDuplicate = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "duplicate") 467 - LabelAssignee = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "assignee") 468 - LabelGoodFirstIssue = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "good-first-issue") 469 - LabelDocumentation = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "documentation") 470 - ) 463 + func FetchLabelDefs(r *idresolver.Resolver, aturis []string) ([]LabelDefinition, error) { 464 + var labelDefs []LabelDefinition 465 + ctx := context.Background() 471 466 472 - func DefaultLabelDefs() []string { 473 - return []string{ 474 - LabelWontfix, 475 - LabelDuplicate, 476 - LabelAssignee, 477 - LabelGoodFirstIssue, 478 - LabelDocumentation, 479 - } 480 - } 467 + for _, dl := range aturis { 468 + atUri, err := syntax.ParseATURI(dl) 469 + if err != nil { 470 + return nil, fmt.Errorf("failed to parse AT-URI %s: %v", dl, err) 471 + } 472 + if atUri.Collection() != tangled.LabelDefinitionNSID { 473 + return nil, fmt.Errorf("expected AT-URI pointing %s collection: %s", tangled.LabelDefinitionNSID, atUri) 474 + } 481 475 482 - func FetchDefaultDefs(r *idresolver.Resolver) ([]LabelDefinition, error) { 483 - resolved, err := r.ResolveIdent(context.Background(), consts.TangledDid) 484 - if err != nil { 485 - return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", consts.TangledDid, err) 486 - } 487 - pdsEndpoint := resolved.PDSEndpoint() 488 - if pdsEndpoint == "" { 489 - return nil, fmt.Errorf("no PDS endpoint found for tangled.sh DID %s", consts.TangledDid) 490 - } 491 - client := &xrpc.Client{ 492 - Host: pdsEndpoint, 493 - } 476 + owner, err := r.ResolveIdent(ctx, atUri.Authority().String()) 477 + if err != nil { 478 + return nil, fmt.Errorf("failed to resolve default label owner DID %s: %v", atUri.Authority(), err) 479 + } 494 480 495 - var labelDefs []LabelDefinition 481 + xrpcc := xrpc.Client{ 482 + Host: owner.PDSEndpoint(), 483 + } 496 484 497 - for _, dl := range DefaultLabelDefs() { 498 - atUri := syntax.ATURI(dl) 499 - parsedUri, err := syntax.ParseATURI(string(atUri)) 500 - if err != nil { 501 - return nil, fmt.Errorf("failed to parse AT-URI %s: %v", atUri, err) 502 - } 503 485 record, err := atproto.RepoGetRecord( 504 - context.Background(), 505 - client, 486 + ctx, 487 + &xrpcc, 506 488 "", 507 - parsedUri.Collection().String(), 508 - parsedUri.Authority().String(), 509 - parsedUri.RecordKey().String(), 489 + atUri.Collection().String(), 490 + atUri.Authority().String(), 491 + atUri.RecordKey().String(), 510 492 ) 511 493 if err != nil { 512 494 return nil, fmt.Errorf("failed to get record for %s: %v", atUri, err) ··· 526 508 } 527 509 528 510 labelDef, err := LabelDefinitionFromRecord( 529 - parsedUri.Authority().String(), 530 - parsedUri.RecordKey().String(), 511 + atUri.Authority().String(), 512 + atUri.RecordKey().String(), 531 513 labelRecord, 532 514 ) 533 515 if err != nil {
+17
appview/models/notifications.go
··· 17 17 NotificationTypeFollowed NotificationType = "followed" 18 18 NotificationTypePullMerged NotificationType = "pull_merged" 19 19 NotificationTypeIssueClosed NotificationType = "issue_closed" 20 + NotificationTypeIssueReopen NotificationType = "issue_reopen" 20 21 NotificationTypePullClosed NotificationType = "pull_closed" 22 + NotificationTypePullReopen NotificationType = "pull_reopen" 23 + NotificationTypeUserMentioned NotificationType = "user_mentioned" 21 24 ) 22 25 23 26 type Notification struct { ··· 47 50 return "message-square" 48 51 case NotificationTypeIssueClosed: 49 52 return "ban" 53 + case NotificationTypeIssueReopen: 54 + return "circle-dot" 50 55 case NotificationTypePullCreated: 51 56 return "git-pull-request-create" 52 57 case NotificationTypePullCommented: ··· 55 60 return "git-merge" 56 61 case NotificationTypePullClosed: 57 62 return "git-pull-request-closed" 63 + case NotificationTypePullReopen: 64 + return "git-pull-request-create" 58 65 case NotificationTypeFollowed: 59 66 return "user-plus" 67 + case NotificationTypeUserMentioned: 68 + return "at-sign" 60 69 default: 61 70 return "" 62 71 } ··· 78 87 PullCreated bool 79 88 PullCommented bool 80 89 Followed bool 90 + UserMentioned bool 81 91 PullMerged bool 82 92 IssueClosed bool 83 93 EmailNotifications bool ··· 93 103 return prefs.IssueCommented 94 104 case NotificationTypeIssueClosed: 95 105 return prefs.IssueClosed 106 + case NotificationTypeIssueReopen: 107 + return prefs.IssueCreated // smae pref for now 96 108 case NotificationTypePullCreated: 97 109 return prefs.PullCreated 98 110 case NotificationTypePullCommented: ··· 101 113 return prefs.PullMerged 102 114 case NotificationTypePullClosed: 103 115 return prefs.PullMerged // same pref for now 116 + case NotificationTypePullReopen: 117 + return prefs.PullCreated // same pref for now 104 118 case NotificationTypeFollowed: 105 119 return prefs.Followed 120 + case NotificationTypeUserMentioned: 121 + return prefs.UserMentioned 106 122 default: 107 123 return false 108 124 } ··· 117 133 PullCreated: true, 118 134 PullCommented: true, 119 135 Followed: true, 136 + UserMentioned: true, 120 137 PullMerged: true, 121 138 IssueClosed: true, 122 139 EmailNotifications: false,
+4 -1
appview/models/profile.go
··· 19 19 Links [5]string 20 20 Stats [2]VanityStat 21 21 PinnedRepos [6]syntax.ATURI 22 + Pronouns string 22 23 } 23 24 24 25 func (p Profile) IsLinksEmpty() bool { ··· 110 111 } 111 112 112 113 type ByMonth struct { 114 + Commits int 113 115 RepoEvents []RepoEvent 114 116 IssueEvents IssueEvents 115 117 PullEvents PullEvents ··· 118 120 func (b ByMonth) IsEmpty() bool { 119 121 return len(b.RepoEvents) == 0 && 120 122 len(b.IssueEvents.Items) == 0 && 121 - len(b.PullEvents.Items) == 0 123 + len(b.PullEvents.Items) == 0 && 124 + b.Commits == 0 122 125 } 123 126 124 127 type IssueEvents struct {
+43 -5
appview/models/pull.go
··· 66 66 TargetBranch string 67 67 State PullState 68 68 Submissions []*PullSubmission 69 + Mentions []syntax.DID 70 + References []syntax.ATURI 69 71 70 72 // stacking 71 73 StackId string // nullable string ··· 81 83 Repo *Repo 82 84 } 83 85 86 + // NOTE: This method does not include patch blob in returned atproto record 84 87 func (p Pull) AsRecord() tangled.RepoPull { 85 88 var source *tangled.RepoPull_Source 86 89 if p.PullSource != nil { ··· 92 95 source.Repo = &s 93 96 } 94 97 } 98 + mentions := make([]string, len(p.Mentions)) 99 + for i, did := range p.Mentions { 100 + mentions[i] = string(did) 101 + } 102 + references := make([]string, len(p.References)) 103 + for i, uri := range p.References { 104 + references[i] = string(uri) 105 + } 95 106 96 107 record := tangled.RepoPull{ 97 - Title: p.Title, 98 - Body: &p.Body, 99 - CreatedAt: p.Created.Format(time.RFC3339), 108 + Title: p.Title, 109 + Body: &p.Body, 110 + Mentions: mentions, 111 + References: references, 112 + CreatedAt: p.Created.Format(time.RFC3339), 100 113 Target: &tangled.RepoPull_Target{ 101 114 Repo: p.RepoAt.String(), 102 115 Branch: p.TargetBranch, 103 116 }, 104 - Patch: p.LatestPatch(), 105 117 Source: source, 106 118 } 107 119 return record ··· 146 158 147 159 // content 148 160 Body string 161 + 162 + // meta 163 + Mentions []syntax.DID 164 + References []syntax.ATURI 149 165 150 166 // meta 151 167 Created time.Time 152 168 } 153 169 170 + func (p *PullComment) AtUri() syntax.ATURI { 171 + return syntax.ATURI(p.CommentAt) 172 + } 173 + 174 + // func (p *PullComment) AsRecord() tangled.RepoPullComment { 175 + // mentions := make([]string, len(p.Mentions)) 176 + // for i, did := range p.Mentions { 177 + // mentions[i] = string(did) 178 + // } 179 + // references := make([]string, len(p.References)) 180 + // for i, uri := range p.References { 181 + // references[i] = string(uri) 182 + // } 183 + // return tangled.RepoPullComment{ 184 + // Pull: p.PullAt, 185 + // Body: p.Body, 186 + // Mentions: mentions, 187 + // References: references, 188 + // CreatedAt: p.Created.Format(time.RFC3339), 189 + // } 190 + // } 191 + 154 192 func (p *Pull) LastRoundNumber() int { 155 193 return len(p.Submissions) - 1 156 194 } ··· 167 205 return p.LatestSubmission().SourceRev 168 206 } 169 207 170 - func (p *Pull) PullAt() syntax.ATURI { 208 + func (p *Pull) AtUri() syntax.ATURI { 171 209 return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.OwnerDid, tangled.RepoPullNSID, p.Rkey)) 172 210 } 173 211
+49
appview/models/reference.go
··· 1 + package models 2 + 3 + import "fmt" 4 + 5 + type RefKind int 6 + 7 + const ( 8 + RefKindIssue RefKind = iota 9 + RefKindPull 10 + ) 11 + 12 + func (k RefKind) String() string { 13 + if k == RefKindIssue { 14 + return "issues" 15 + } else { 16 + return "pulls" 17 + } 18 + } 19 + 20 + // /@alice.com/cool-proj/issues/123 21 + // /@alice.com/cool-proj/issues/123#comment-321 22 + type ReferenceLink struct { 23 + Handle string 24 + Repo string 25 + Kind RefKind 26 + SubjectId int 27 + CommentId *int 28 + } 29 + 30 + func (l ReferenceLink) String() string { 31 + comment := "" 32 + if l.CommentId != nil { 33 + comment = fmt.Sprintf("#comment-%d", *l.CommentId) 34 + } 35 + return fmt.Sprintf("/%s/%s/%s/%d%s", 36 + l.Handle, 37 + l.Repo, 38 + l.Kind.String(), 39 + l.SubjectId, 40 + comment, 41 + ) 42 + } 43 + 44 + type RichReferenceLink struct { 45 + ReferenceLink 46 + Title string 47 + // reusing PullState for both issue & PR 48 + State PullState 49 + }
+61 -1
appview/models/repo.go
··· 2 2 3 3 import ( 4 4 "fmt" 5 + "strings" 5 6 "time" 6 7 7 8 "github.com/bluesky-social/indigo/atproto/syntax" ··· 17 18 Rkey string 18 19 Created time.Time 19 20 Description string 21 + Website string 22 + Topics []string 20 23 Spindle string 21 24 Labels []string 22 25 ··· 28 31 } 29 32 30 33 func (r *Repo) AsRecord() tangled.Repo { 31 - var source, spindle, description *string 34 + var source, spindle, description, website *string 32 35 33 36 if r.Source != "" { 34 37 source = &r.Source ··· 42 45 description = &r.Description 43 46 } 44 47 48 + if r.Website != "" { 49 + website = &r.Website 50 + } 51 + 45 52 return tangled.Repo{ 46 53 Knot: r.Knot, 47 54 Name: r.Name, 48 55 Description: description, 56 + Website: website, 57 + Topics: r.Topics, 49 58 CreatedAt: r.Created.Format(time.RFC3339), 50 59 Source: source, 51 60 Spindle: spindle, ··· 60 69 func (r Repo) DidSlashRepo() string { 61 70 p, _ := securejoin.SecureJoin(r.Did, r.Name) 62 71 return p 72 + } 73 + 74 + func (r Repo) TopicStr() string { 75 + return strings.Join(r.Topics, " ") 63 76 } 64 77 65 78 type RepoStats struct { ··· 91 104 Repo *Repo 92 105 Issues []Issue 93 106 } 107 + 108 + type BlobContentType int 109 + 110 + const ( 111 + BlobContentTypeCode BlobContentType = iota 112 + BlobContentTypeMarkup 113 + BlobContentTypeImage 114 + BlobContentTypeSvg 115 + BlobContentTypeVideo 116 + BlobContentTypeSubmodule 117 + ) 118 + 119 + func (ty BlobContentType) IsCode() bool { return ty == BlobContentTypeCode } 120 + func (ty BlobContentType) IsMarkup() bool { return ty == BlobContentTypeMarkup } 121 + func (ty BlobContentType) IsImage() bool { return ty == BlobContentTypeImage } 122 + func (ty BlobContentType) IsSvg() bool { return ty == BlobContentTypeSvg } 123 + func (ty BlobContentType) IsVideo() bool { return ty == BlobContentTypeVideo } 124 + func (ty BlobContentType) IsSubmodule() bool { return ty == BlobContentTypeSubmodule } 125 + 126 + type BlobView struct { 127 + HasTextView bool // can show as code/text 128 + HasRenderedView bool // can show rendered (markup/image/video/submodule) 129 + HasRawView bool // can download raw (everything except submodule) 130 + 131 + // current display mode 132 + ShowingRendered bool // currently in rendered mode 133 + ShowingText bool // currently in text/code mode 134 + 135 + // content type flags 136 + ContentType BlobContentType 137 + 138 + // Content data 139 + Contents string 140 + ContentSrc string // URL for media files 141 + Lines int 142 + SizeHint uint64 143 + } 144 + 145 + // if both views are available, then show a toggle between them 146 + func (b BlobView) ShowToggle() bool { 147 + return b.HasTextView && b.HasRenderedView 148 + } 149 + 150 + func (b BlobView) IsUnsupported() bool { 151 + // no view available, only raw 152 + return !(b.HasRenderedView || b.HasTextView) 153 + }
+8
appview/models/search.go
··· 10 10 Page pagination.Page 11 11 } 12 12 13 + type PullSearchOptions struct { 14 + Keyword string 15 + RepoAt string 16 + State PullState 17 + 18 + Page pagination.Page 19 + } 20 + 13 21 // func (so *SearchOptions) ToFilters() []filter { 14 22 // var filters []filter 15 23 // if so.IsOpen != nil {
+14 -5
appview/models/star.go
··· 7 7 ) 8 8 9 9 type Star struct { 10 - StarredByDid string 11 - RepoAt syntax.ATURI 12 - Created time.Time 13 - Rkey string 10 + Did string 11 + RepoAt syntax.ATURI 12 + Created time.Time 13 + Rkey string 14 + } 14 15 15 - // optionally, populate this when querying for reverse mappings 16 + // RepoStar is used for reverse mapping to repos 17 + type RepoStar struct { 18 + Star 16 19 Repo *Repo 17 20 } 21 + 22 + // StringStar is used for reverse mapping to strings 23 + type StringStar struct { 24 + Star 25 + String *String 26 + }
+1 -1
appview/models/string.go
··· 22 22 Edited *time.Time 23 23 } 24 24 25 - func (s *String) StringAt() syntax.ATURI { 25 + func (s *String) AtUri() syntax.ATURI { 26 26 return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", s.Did, tangled.StringNSID, s.Rkey)) 27 27 } 28 28
+1 -1
appview/models/timeline.go
··· 5 5 type TimelineEvent struct { 6 6 *Repo 7 7 *Follow 8 - *Star 8 + *RepoStar 9 9 10 10 EventAt time.Time 11 11
+5 -4
appview/notifications/notifications.go
··· 11 11 "tangled.org/core/appview/oauth" 12 12 "tangled.org/core/appview/pages" 13 13 "tangled.org/core/appview/pagination" 14 + "tangled.org/core/orm" 14 15 ) 15 16 16 17 type Notifications struct { ··· 53 54 54 55 total, err := db.CountNotifications( 55 56 n.db, 56 - db.FilterEq("recipient_did", user.Did), 57 + orm.FilterEq("recipient_did", user.Did), 57 58 ) 58 59 if err != nil { 59 60 l.Error("failed to get total notifications", "err", err) ··· 64 65 notifications, err := db.GetNotificationsWithEntities( 65 66 n.db, 66 67 page, 67 - db.FilterEq("recipient_did", user.Did), 68 + orm.FilterEq("recipient_did", user.Did), 68 69 ) 69 70 if err != nil { 70 71 l.Error("failed to get notifications", "err", err) ··· 96 97 97 98 count, err := db.CountNotifications( 98 99 n.db, 99 - db.FilterEq("recipient_did", user.Did), 100 - db.FilterEq("read", 0), 100 + orm.FilterEq("recipient_did", user.Did), 101 + orm.FilterEq("read", 0), 101 102 ) 102 103 if err != nil { 103 104 http.Error(w, "Failed to get unread count", http.StatusInternalServerError)
+145 -121
appview/notify/db/db.go
··· 3 3 import ( 4 4 "context" 5 5 "log" 6 - "maps" 7 6 "slices" 8 7 9 8 "github.com/bluesky-social/indigo/atproto/syntax" 9 + "tangled.org/core/api/tangled" 10 10 "tangled.org/core/appview/db" 11 11 "tangled.org/core/appview/models" 12 12 "tangled.org/core/appview/notify" 13 13 "tangled.org/core/idresolver" 14 + "tangled.org/core/orm" 15 + "tangled.org/core/sets" 16 + ) 17 + 18 + const ( 19 + maxMentions = 8 14 20 ) 15 21 16 22 type databaseNotifier struct { ··· 32 38 } 33 39 34 40 func (n *databaseNotifier) NewStar(ctx context.Context, star *models.Star) { 41 + if star.RepoAt.Collection().String() != tangled.RepoNSID { 42 + // skip string stars for now 43 + return 44 + } 35 45 var err error 36 - repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(star.RepoAt))) 46 + repo, err := db.GetRepo(n.db, orm.FilterEq("at_uri", string(star.RepoAt))) 37 47 if err != nil { 38 48 log.Printf("NewStar: failed to get repos: %v", err) 39 49 return 40 50 } 41 51 42 - actorDid := syntax.DID(star.StarredByDid) 43 - recipients := []syntax.DID{syntax.DID(repo.Did)} 52 + actorDid := syntax.DID(star.Did) 53 + recipients := sets.Singleton(syntax.DID(repo.Did)) 44 54 eventType := models.NotificationTypeRepoStarred 45 55 entityType := "repo" 46 56 entityId := star.RepoAt.String() ··· 64 74 // no-op 65 75 } 66 76 67 - func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue) { 77 + func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) { 78 + collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_at", issue.Repo.RepoAt())) 79 + if err != nil { 80 + log.Printf("failed to fetch collaborators: %v", err) 81 + return 82 + } 68 83 69 84 // build the recipients list 70 85 // - owner of the repo 71 86 // - collaborators in the repo 72 - var recipients []syntax.DID 73 - recipients = append(recipients, syntax.DID(issue.Repo.Did)) 74 - collaborators, err := db.GetCollaborators(n.db, db.FilterEq("repo_at", issue.Repo.RepoAt())) 75 - if err != nil { 76 - log.Printf("failed to fetch collaborators: %v", err) 77 - return 87 + // - remove users already mentioned 88 + recipients := sets.Singleton(syntax.DID(issue.Repo.Did)) 89 + for _, c := range collaborators { 90 + recipients.Insert(c.SubjectDid) 78 91 } 79 - for _, c := range collaborators { 80 - recipients = append(recipients, c.SubjectDid) 92 + for _, m := range mentions { 93 + recipients.Remove(m) 81 94 } 82 95 83 96 actorDid := syntax.DID(issue.Did) 84 - eventType := models.NotificationTypeIssueCreated 85 97 entityType := "issue" 86 98 entityId := issue.AtUri().String() 87 99 repoId := &issue.Repo.Id ··· 91 103 n.notifyEvent( 92 104 actorDid, 93 105 recipients, 94 - eventType, 106 + models.NotificationTypeIssueCreated, 107 + entityType, 108 + entityId, 109 + repoId, 110 + issueId, 111 + pullId, 112 + ) 113 + n.notifyEvent( 114 + actorDid, 115 + sets.Collect(slices.Values(mentions)), 116 + models.NotificationTypeUserMentioned, 95 117 entityType, 96 118 entityId, 97 119 repoId, ··· 100 122 ) 101 123 } 102 124 103 - func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) { 104 - issues, err := db.GetIssues(n.db, db.FilterEq("at_uri", comment.IssueAt)) 125 + func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { 126 + issues, err := db.GetIssues(n.db, orm.FilterEq("at_uri", comment.IssueAt)) 105 127 if err != nil { 106 128 log.Printf("NewIssueComment: failed to get issues: %v", err) 107 129 return ··· 112 134 } 113 135 issue := issues[0] 114 136 115 - var recipients []syntax.DID 116 - recipients = append(recipients, syntax.DID(issue.Repo.Did)) 137 + // built the recipients list: 138 + // - the owner of the repo 139 + // - | if the comment is a reply -> everybody on that thread 140 + // | if the comment is a top level -> just the issue owner 141 + // - remove mentioned users from the recipients list 142 + recipients := sets.Singleton(syntax.DID(issue.Repo.Did)) 117 143 118 144 if comment.IsReply() { 119 145 // if this comment is a reply, then notify everybody in that thread 120 146 parentAtUri := *comment.ReplyTo 121 - allThreads := issue.CommentList() 122 147 123 148 // find the parent thread, and add all DIDs from here to the recipient list 124 - for _, t := range allThreads { 149 + for _, t := range issue.CommentList() { 125 150 if t.Self.AtUri().String() == parentAtUri { 126 - recipients = append(recipients, t.Participants()...) 151 + for _, p := range t.Participants() { 152 + recipients.Insert(p) 153 + } 127 154 } 128 155 } 129 156 } else { 130 157 // not a reply, notify just the issue author 131 - recipients = append(recipients, syntax.DID(issue.Did)) 158 + recipients.Insert(syntax.DID(issue.Did)) 159 + } 160 + 161 + for _, m := range mentions { 162 + recipients.Remove(m) 132 163 } 133 164 134 165 actorDid := syntax.DID(comment.Did) 135 - eventType := models.NotificationTypeIssueCommented 136 166 entityType := "issue" 137 167 entityId := issue.AtUri().String() 138 168 repoId := &issue.Repo.Id ··· 142 172 n.notifyEvent( 143 173 actorDid, 144 174 recipients, 145 - eventType, 175 + models.NotificationTypeIssueCommented, 176 + entityType, 177 + entityId, 178 + repoId, 179 + issueId, 180 + pullId, 181 + ) 182 + n.notifyEvent( 183 + actorDid, 184 + sets.Collect(slices.Values(mentions)), 185 + models.NotificationTypeUserMentioned, 146 186 entityType, 147 187 entityId, 148 188 repoId, ··· 151 191 ) 152 192 } 153 193 194 + func (n *databaseNotifier) DeleteIssue(ctx context.Context, issue *models.Issue) { 195 + // no-op for now 196 + } 197 + 154 198 func (n *databaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) { 155 199 actorDid := syntax.DID(follow.UserDid) 156 - recipients := []syntax.DID{syntax.DID(follow.SubjectDid)} 200 + recipients := sets.Singleton(syntax.DID(follow.SubjectDid)) 157 201 eventType := models.NotificationTypeFollowed 158 202 entityType := "follow" 159 203 entityId := follow.UserDid ··· 176 220 } 177 221 178 222 func (n *databaseNotifier) NewPull(ctx context.Context, pull *models.Pull) { 179 - repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt))) 223 + repo, err := db.GetRepo(n.db, orm.FilterEq("at_uri", string(pull.RepoAt))) 180 224 if err != nil { 181 225 log.Printf("NewPull: failed to get repos: %v", err) 182 226 return 183 227 } 184 - 185 - // build the recipients list 186 - // - owner of the repo 187 - // - collaborators in the repo 188 - var recipients []syntax.DID 189 - recipients = append(recipients, syntax.DID(repo.Did)) 190 - collaborators, err := db.GetCollaborators(n.db, db.FilterEq("repo_at", repo.RepoAt())) 228 + collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_at", repo.RepoAt())) 191 229 if err != nil { 192 230 log.Printf("failed to fetch collaborators: %v", err) 193 231 return 194 232 } 233 + 234 + // build the recipients list 235 + // - owner of the repo 236 + // - collaborators in the repo 237 + recipients := sets.Singleton(syntax.DID(repo.Did)) 195 238 for _, c := range collaborators { 196 - recipients = append(recipients, c.SubjectDid) 239 + recipients.Insert(c.SubjectDid) 197 240 } 198 241 199 242 actorDid := syntax.DID(pull.OwnerDid) 200 243 eventType := models.NotificationTypePullCreated 201 244 entityType := "pull" 202 - entityId := pull.PullAt().String() 245 + entityId := pull.AtUri().String() 203 246 repoId := &repo.Id 204 247 var issueId *int64 205 248 p := int64(pull.ID) ··· 217 260 ) 218 261 } 219 262 220 - func (n *databaseNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) { 263 + func (n *databaseNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) { 221 264 pull, err := db.GetPull(n.db, 222 265 syntax.ATURI(comment.RepoAt), 223 266 comment.PullId, ··· 227 270 return 228 271 } 229 272 230 - repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", comment.RepoAt)) 273 + repo, err := db.GetRepo(n.db, orm.FilterEq("at_uri", comment.RepoAt)) 231 274 if err != nil { 232 275 log.Printf("NewPullComment: failed to get repos: %v", err) 233 276 return ··· 236 279 // build up the recipients list: 237 280 // - repo owner 238 281 // - all pull participants 239 - var recipients []syntax.DID 240 - recipients = append(recipients, syntax.DID(repo.Did)) 282 + // - remove those already mentioned 283 + recipients := sets.Singleton(syntax.DID(repo.Did)) 241 284 for _, p := range pull.Participants() { 242 - recipients = append(recipients, syntax.DID(p)) 285 + recipients.Insert(syntax.DID(p)) 286 + } 287 + for _, m := range mentions { 288 + recipients.Remove(m) 243 289 } 244 290 245 291 actorDid := syntax.DID(comment.OwnerDid) 246 292 eventType := models.NotificationTypePullCommented 247 293 entityType := "pull" 248 - entityId := pull.PullAt().String() 294 + entityId := pull.AtUri().String() 249 295 repoId := &repo.Id 250 296 var issueId *int64 251 297 p := int64(pull.ID) ··· 261 307 issueId, 262 308 pullId, 263 309 ) 310 + n.notifyEvent( 311 + actorDid, 312 + sets.Collect(slices.Values(mentions)), 313 + models.NotificationTypeUserMentioned, 314 + entityType, 315 + entityId, 316 + repoId, 317 + issueId, 318 + pullId, 319 + ) 264 320 } 265 321 266 322 func (n *databaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) { ··· 279 335 // no-op 280 336 } 281 337 282 - func (n *databaseNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) { 283 - // build up the recipients list: 284 - // - repo owner 285 - // - repo collaborators 286 - // - all issue participants 287 - var recipients []syntax.DID 288 - recipients = append(recipients, syntax.DID(issue.Repo.Did)) 289 - collaborators, err := db.GetCollaborators(n.db, db.FilterEq("repo_at", issue.Repo.RepoAt())) 338 + func (n *databaseNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) { 339 + collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_at", issue.Repo.RepoAt())) 290 340 if err != nil { 291 341 log.Printf("failed to fetch collaborators: %v", err) 292 342 return 293 343 } 344 + 345 + // build up the recipients list: 346 + // - repo owner 347 + // - repo collaborators 348 + // - all issue participants 349 + recipients := sets.Singleton(syntax.DID(issue.Repo.Did)) 294 350 for _, c := range collaborators { 295 - recipients = append(recipients, c.SubjectDid) 351 + recipients.Insert(c.SubjectDid) 296 352 } 297 353 for _, p := range issue.Participants() { 298 - recipients = append(recipients, syntax.DID(p)) 354 + recipients.Insert(syntax.DID(p)) 299 355 } 300 356 301 - actorDid := syntax.DID(issue.Repo.Did) 302 - eventType := models.NotificationTypeIssueClosed 303 357 entityType := "pull" 304 358 entityId := issue.AtUri().String() 305 359 repoId := &issue.Repo.Id 306 360 issueId := &issue.Id 307 361 var pullId *int64 362 + var eventType models.NotificationType 363 + 364 + if issue.Open { 365 + eventType = models.NotificationTypeIssueReopen 366 + } else { 367 + eventType = models.NotificationTypeIssueClosed 368 + } 308 369 309 370 n.notifyEvent( 310 - actorDid, 371 + actor, 311 372 recipients, 312 373 eventType, 313 374 entityType, ··· 318 379 ) 319 380 } 320 381 321 - func (n *databaseNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) { 382 + func (n *databaseNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) { 322 383 // Get repo details 323 - repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt))) 384 + repo, err := db.GetRepo(n.db, orm.FilterEq("at_uri", string(pull.RepoAt))) 324 385 if err != nil { 325 - log.Printf("NewPullMerged: failed to get repos: %v", err) 386 + log.Printf("NewPullState: failed to get repos: %v", err) 326 387 return 327 388 } 328 389 329 - // build up the recipients list: 330 - // - repo owner 331 - // - all pull participants 332 - var recipients []syntax.DID 333 - recipients = append(recipients, syntax.DID(repo.Did)) 334 - collaborators, err := db.GetCollaborators(n.db, db.FilterEq("repo_at", repo.RepoAt())) 390 + collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_at", repo.RepoAt())) 335 391 if err != nil { 336 392 log.Printf("failed to fetch collaborators: %v", err) 337 393 return 338 394 } 339 - for _, c := range collaborators { 340 - recipients = append(recipients, c.SubjectDid) 341 - } 342 - for _, p := range pull.Participants() { 343 - recipients = append(recipients, syntax.DID(p)) 344 - } 345 - 346 - actorDid := syntax.DID(repo.Did) 347 - eventType := models.NotificationTypePullMerged 348 - entityType := "pull" 349 - entityId := pull.PullAt().String() 350 - repoId := &repo.Id 351 - var issueId *int64 352 - p := int64(pull.ID) 353 - pullId := &p 354 - 355 - n.notifyEvent( 356 - actorDid, 357 - recipients, 358 - eventType, 359 - entityType, 360 - entityId, 361 - repoId, 362 - issueId, 363 - pullId, 364 - ) 365 - } 366 - 367 - func (n *databaseNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) { 368 - // Get repo details 369 - repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt))) 370 - if err != nil { 371 - log.Printf("NewPullMerged: failed to get repos: %v", err) 372 - return 373 - } 374 395 375 396 // build up the recipients list: 376 397 // - repo owner 377 398 // - all pull participants 378 - var recipients []syntax.DID 379 - recipients = append(recipients, syntax.DID(repo.Did)) 380 - collaborators, err := db.GetCollaborators(n.db, db.FilterEq("repo_at", repo.RepoAt())) 381 - if err != nil { 382 - log.Printf("failed to fetch collaborators: %v", err) 383 - return 384 - } 399 + recipients := sets.Singleton(syntax.DID(repo.Did)) 385 400 for _, c := range collaborators { 386 - recipients = append(recipients, c.SubjectDid) 401 + recipients.Insert(c.SubjectDid) 387 402 } 388 403 for _, p := range pull.Participants() { 389 - recipients = append(recipients, syntax.DID(p)) 404 + recipients.Insert(syntax.DID(p)) 390 405 } 391 406 392 - actorDid := syntax.DID(repo.Did) 393 - eventType := models.NotificationTypePullClosed 394 407 entityType := "pull" 395 - entityId := pull.PullAt().String() 408 + entityId := pull.AtUri().String() 396 409 repoId := &repo.Id 397 410 var issueId *int64 411 + var eventType models.NotificationType 412 + switch pull.State { 413 + case models.PullClosed: 414 + eventType = models.NotificationTypePullClosed 415 + case models.PullOpen: 416 + eventType = models.NotificationTypePullReopen 417 + case models.PullMerged: 418 + eventType = models.NotificationTypePullMerged 419 + default: 420 + log.Println("NewPullState: unexpected new PR state:", pull.State) 421 + return 422 + } 398 423 p := int64(pull.ID) 399 424 pullId := &p 400 425 401 426 n.notifyEvent( 402 - actorDid, 427 + actor, 403 428 recipients, 404 429 eventType, 405 430 entityType, ··· 412 437 413 438 func (n *databaseNotifier) notifyEvent( 414 439 actorDid syntax.DID, 415 - recipients []syntax.DID, 440 + recipients sets.Set[syntax.DID], 416 441 eventType models.NotificationType, 417 442 entityType string, 418 443 entityId string, ··· 420 445 issueId *int64, 421 446 pullId *int64, 422 447 ) { 423 - recipientSet := make(map[syntax.DID]struct{}) 424 - for _, did := range recipients { 425 - // everybody except actor themselves 426 - if did != actorDid { 427 - recipientSet[did] = struct{}{} 428 - } 448 + // if the user is attempting to mention >maxMentions users, this is probably spam, do not mention anybody 449 + if eventType == models.NotificationTypeUserMentioned && recipients.Len() > maxMentions { 450 + return 429 451 } 430 452 453 + recipients.Remove(actorDid) 454 + 431 455 prefMap, err := db.GetNotificationPreferences( 432 456 n.db, 433 - db.FilterIn("user_did", slices.Collect(maps.Keys(recipientSet))), 457 + orm.FilterIn("user_did", slices.Collect(recipients.All())), 434 458 ) 435 459 if err != nil { 436 460 // failed to get prefs for users ··· 446 470 defer tx.Rollback() 447 471 448 472 // filter based on preferences 449 - for recipientDid := range recipientSet { 473 + for recipientDid := range recipients.All() { 450 474 prefs, ok := prefMap[recipientDid] 451 475 if !ok { 452 476 prefs = models.DefaultNotificationPreferences(recipientDid)
+25 -20
appview/notify/merged_notifier.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "log/slog" 5 6 "reflect" 6 7 "sync" 7 8 9 + "github.com/bluesky-social/indigo/atproto/syntax" 8 10 "tangled.org/core/appview/models" 11 + "tangled.org/core/log" 9 12 ) 10 13 11 14 type mergedNotifier struct { 12 15 notifiers []Notifier 16 + logger *slog.Logger 13 17 } 14 18 15 - func NewMergedNotifier(notifiers ...Notifier) Notifier { 16 - return &mergedNotifier{notifiers} 19 + func NewMergedNotifier(notifiers []Notifier, logger *slog.Logger) Notifier { 20 + return &mergedNotifier{notifiers, logger} 17 21 } 18 22 19 23 var _ Notifier = &mergedNotifier{} 20 24 21 25 // fanout calls the same method on all notifiers concurrently 22 - func (m *mergedNotifier) fanout(method string, args ...any) { 26 + func (m *mergedNotifier) fanout(method string, ctx context.Context, args ...any) { 27 + ctx = log.IntoContext(ctx, m.logger.With("method", method)) 23 28 var wg sync.WaitGroup 24 29 for _, n := range m.notifiers { 25 30 wg.Add(1) 26 31 go func(notifier Notifier) { 27 32 defer wg.Done() 28 33 v := reflect.ValueOf(notifier).MethodByName(method) 29 - in := make([]reflect.Value, len(args)) 34 + in := make([]reflect.Value, len(args)+1) 35 + in[0] = reflect.ValueOf(ctx) 30 36 for i, arg := range args { 31 - in[i] = reflect.ValueOf(arg) 37 + in[i+1] = reflect.ValueOf(arg) 32 38 } 33 39 v.Call(in) 34 40 }(n) 35 41 } 36 - wg.Wait() 37 42 } 38 43 39 44 func (m *mergedNotifier) NewRepo(ctx context.Context, repo *models.Repo) { ··· 48 53 m.fanout("DeleteStar", ctx, star) 49 54 } 50 55 51 - func (m *mergedNotifier) NewIssue(ctx context.Context, issue *models.Issue) { 52 - m.fanout("NewIssue", ctx, issue) 56 + func (m *mergedNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) { 57 + m.fanout("NewIssue", ctx, issue, mentions) 58 + } 59 + 60 + func (m *mergedNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { 61 + m.fanout("NewIssueComment", ctx, comment, mentions) 53 62 } 54 63 55 - func (m *mergedNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) { 56 - m.fanout("NewIssueComment", ctx, comment) 64 + func (m *mergedNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) { 65 + m.fanout("NewIssueState", ctx, actor, issue) 57 66 } 58 67 59 - func (m *mergedNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) { 60 - m.fanout("NewIssueClosed", ctx, issue) 68 + func (m *mergedNotifier) DeleteIssue(ctx context.Context, issue *models.Issue) { 69 + m.fanout("DeleteIssue", ctx, issue) 61 70 } 62 71 63 72 func (m *mergedNotifier) NewFollow(ctx context.Context, follow *models.Follow) { ··· 72 81 m.fanout("NewPull", ctx, pull) 73 82 } 74 83 75 - func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) { 76 - m.fanout("NewPullComment", ctx, comment) 84 + func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) { 85 + m.fanout("NewPullComment", ctx, comment, mentions) 77 86 } 78 87 79 - func (m *mergedNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) { 80 - m.fanout("NewPullMerged", ctx, pull) 81 - } 82 - 83 - func (m *mergedNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) { 84 - m.fanout("NewPullClosed", ctx, pull) 88 + func (m *mergedNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) { 89 + m.fanout("NewPullState", ctx, actor, pull) 85 90 } 86 91 87 92 func (m *mergedNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {
+16 -13
appview/notify/notifier.go
··· 3 3 import ( 4 4 "context" 5 5 6 + "github.com/bluesky-social/indigo/atproto/syntax" 6 7 "tangled.org/core/appview/models" 7 8 ) 8 9 ··· 12 13 NewStar(ctx context.Context, star *models.Star) 13 14 DeleteStar(ctx context.Context, star *models.Star) 14 15 15 - NewIssue(ctx context.Context, issue *models.Issue) 16 - NewIssueComment(ctx context.Context, comment *models.IssueComment) 17 - NewIssueClosed(ctx context.Context, issue *models.Issue) 16 + NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) 17 + NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) 18 + NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) 19 + DeleteIssue(ctx context.Context, issue *models.Issue) 18 20 19 21 NewFollow(ctx context.Context, follow *models.Follow) 20 22 DeleteFollow(ctx context.Context, follow *models.Follow) 21 23 22 24 NewPull(ctx context.Context, pull *models.Pull) 23 - NewPullComment(ctx context.Context, comment *models.PullComment) 24 - NewPullMerged(ctx context.Context, pull *models.Pull) 25 - NewPullClosed(ctx context.Context, pull *models.Pull) 25 + NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) 26 + NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) 26 27 27 28 UpdateProfile(ctx context.Context, profile *models.Profile) 28 29 ··· 41 42 func (m *BaseNotifier) NewStar(ctx context.Context, star *models.Star) {} 42 43 func (m *BaseNotifier) DeleteStar(ctx context.Context, star *models.Star) {} 43 44 44 - func (m *BaseNotifier) NewIssue(ctx context.Context, issue *models.Issue) {} 45 - func (m *BaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) {} 46 - func (m *BaseNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) {} 45 + func (m *BaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {} 46 + func (m *BaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { 47 + } 48 + func (m *BaseNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) {} 49 + func (m *BaseNotifier) DeleteIssue(ctx context.Context, issue *models.Issue) {} 47 50 48 51 func (m *BaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) {} 49 52 func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {} 50 53 51 - func (m *BaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {} 52 - func (m *BaseNotifier) NewPullComment(ctx context.Context, models *models.PullComment) {} 53 - func (m *BaseNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) {} 54 - func (m *BaseNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) {} 54 + func (m *BaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {} 55 + func (m *BaseNotifier) NewPullComment(ctx context.Context, models *models.PullComment, mentions []syntax.DID) { 56 + } 57 + func (m *BaseNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) {} 55 58 56 59 func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {} 57 60
+35 -11
appview/notify/posthog/notifier.go
··· 4 4 "context" 5 5 "log" 6 6 7 + "github.com/bluesky-social/indigo/atproto/syntax" 7 8 "github.com/posthog/posthog-go" 8 9 "tangled.org/core/appview/models" 9 10 "tangled.org/core/appview/notify" ··· 36 37 37 38 func (n *posthogNotifier) NewStar(ctx context.Context, star *models.Star) { 38 39 err := n.client.Enqueue(posthog.Capture{ 39 - DistinctId: star.StarredByDid, 40 + DistinctId: star.Did, 40 41 Event: "star", 41 42 Properties: posthog.Properties{"repo_at": star.RepoAt.String()}, 42 43 }) ··· 47 48 48 49 func (n *posthogNotifier) DeleteStar(ctx context.Context, star *models.Star) { 49 50 err := n.client.Enqueue(posthog.Capture{ 50 - DistinctId: star.StarredByDid, 51 + DistinctId: star.Did, 51 52 Event: "unstar", 52 53 Properties: posthog.Properties{"repo_at": star.RepoAt.String()}, 53 54 }) ··· 56 57 } 57 58 } 58 59 59 - func (n *posthogNotifier) NewIssue(ctx context.Context, issue *models.Issue) { 60 + func (n *posthogNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) { 60 61 err := n.client.Enqueue(posthog.Capture{ 61 62 DistinctId: issue.Did, 62 63 Event: "new_issue", 63 64 Properties: posthog.Properties{ 64 65 "repo_at": issue.RepoAt.String(), 65 66 "issue_id": issue.IssueId, 67 + "mentions": mentions, 66 68 }, 67 69 }) 68 70 if err != nil { ··· 84 86 } 85 87 } 86 88 87 - func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) { 89 + func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) { 88 90 err := n.client.Enqueue(posthog.Capture{ 89 91 DistinctId: comment.OwnerDid, 90 92 Event: "new_pull_comment", 91 93 Properties: posthog.Properties{ 92 - "repo_at": comment.RepoAt, 93 - "pull_id": comment.PullId, 94 + "repo_at": comment.RepoAt, 95 + "pull_id": comment.PullId, 96 + "mentions": mentions, 94 97 }, 95 98 }) 96 99 if err != nil { ··· 177 180 } 178 181 } 179 182 180 - func (n *posthogNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) { 183 + func (n *posthogNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { 181 184 err := n.client.Enqueue(posthog.Capture{ 182 185 DistinctId: comment.Did, 183 186 Event: "new_issue_comment", 184 187 Properties: posthog.Properties{ 185 188 "issue_at": comment.IssueAt, 189 + "mentions": mentions, 186 190 }, 187 191 }) 188 192 if err != nil { ··· 190 194 } 191 195 } 192 196 193 - func (n *posthogNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) { 197 + func (n *posthogNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) { 198 + var event string 199 + if issue.Open { 200 + event = "issue_reopen" 201 + } else { 202 + event = "issue_closed" 203 + } 194 204 err := n.client.Enqueue(posthog.Capture{ 195 205 DistinctId: issue.Did, 196 - Event: "issue_closed", 206 + Event: event, 197 207 Properties: posthog.Properties{ 198 208 "repo_at": issue.RepoAt.String(), 209 + "actor": actor, 199 210 "issue_id": issue.IssueId, 200 211 }, 201 212 }) ··· 204 215 } 205 216 } 206 217 207 - func (n *posthogNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) { 218 + func (n *posthogNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) { 219 + var event string 220 + switch pull.State { 221 + case models.PullClosed: 222 + event = "pull_closed" 223 + case models.PullOpen: 224 + event = "pull_reopen" 225 + case models.PullMerged: 226 + event = "pull_merged" 227 + default: 228 + log.Println("posthog: unexpected new PR state:", pull.State) 229 + return 230 + } 208 231 err := n.client.Enqueue(posthog.Capture{ 209 232 DistinctId: pull.OwnerDid, 210 - Event: "pull_merged", 233 + Event: event, 211 234 Properties: posthog.Properties{ 212 235 "repo_at": pull.RepoAt, 213 236 "pull_id": pull.PullId, 237 + "actor": actor, 214 238 }, 215 239 }) 216 240 if err != nil {
+3 -2
appview/oauth/handler.go
··· 16 16 "tangled.org/core/api/tangled" 17 17 "tangled.org/core/appview/db" 18 18 "tangled.org/core/consts" 19 + "tangled.org/core/orm" 19 20 "tangled.org/core/tid" 20 21 ) 21 22 ··· 97 98 // and create an sh.tangled.spindle.member record with that 98 99 spindleMembers, err := db.GetSpindleMembers( 99 100 o.Db, 100 - db.FilterEq("instance", "spindle.tangled.sh"), 101 - db.FilterEq("subject", did), 101 + orm.FilterEq("instance", "spindle.tangled.sh"), 102 + orm.FilterEq("subject", did), 102 103 ) 103 104 if err != nil { 104 105 l.Error("failed to get spindle members", "err", err)
+20 -2
appview/oauth/oauth.go
··· 74 74 75 75 clientApp := oauth.NewClientApp(&oauthConfig, authStore) 76 76 clientApp.Dir = res.Directory() 77 + // allow non-public transports in dev mode 78 + if config.Core.Dev { 79 + clientApp.Resolver.Client.Transport = http.DefaultTransport 80 + } 77 81 78 82 clientName := config.Core.AppviewName 79 83 84 + logger.Info("oauth setup successfully", "IsConfidential", clientApp.Config.IsConfidential()) 80 85 return &OAuth{ 81 86 ClientApp: clientApp, 82 87 Config: config, ··· 197 202 exp int64 198 203 lxm string 199 204 dev bool 205 + timeout time.Duration 200 206 } 201 207 202 208 type ServiceClientOpt func(*ServiceClientOpts) 209 + 210 + func DefaultServiceClientOpts() ServiceClientOpts { 211 + return ServiceClientOpts{ 212 + timeout: time.Second * 5, 213 + } 214 + } 203 215 204 216 func WithService(service string) ServiceClientOpt { 205 217 return func(s *ServiceClientOpts) { ··· 228 240 } 229 241 } 230 242 243 + func WithTimeout(timeout time.Duration) ServiceClientOpt { 244 + return func(s *ServiceClientOpts) { 245 + s.timeout = timeout 246 + } 247 + } 248 + 231 249 func (s *ServiceClientOpts) Audience() string { 232 250 return fmt.Sprintf("did:web:%s", s.service) 233 251 } ··· 242 260 } 243 261 244 262 func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*xrpc.Client, error) { 245 - opts := ServiceClientOpts{} 263 + opts := DefaultServiceClientOpts() 246 264 for _, o := range os { 247 265 o(&opts) 248 266 } ··· 269 287 }, 270 288 Host: opts.Host(), 271 289 Client: &http.Client{ 272 - Timeout: time.Second * 5, 290 + Timeout: opts.timeout, 273 291 }, 274 292 }, nil 275 293 }
+63 -14
appview/ogcard/card.go
··· 7 7 import ( 8 8 "bytes" 9 9 "fmt" 10 + "html/template" 10 11 "image" 11 12 "image/color" 12 13 "io" ··· 279 280 return width, nil 280 281 } 281 282 282 - // DrawSVGIcon draws an SVG icon from the embedded files at the specified position 283 - func (c *Card) DrawSVGIcon(svgPath string, x, y, size int, iconColor color.Color) error { 284 - svgData, err := pages.Files.ReadFile(svgPath) 285 - if err != nil { 286 - return fmt.Errorf("failed to read SVG file %s: %w", svgPath, err) 287 - } 288 - 283 + func BuildSVGIconFromData(svgData []byte, iconColor color.Color) (*oksvg.SvgIcon, error) { 289 284 // Convert color to hex string for SVG 290 285 rgba, isRGBA := iconColor.(color.RGBA) 291 286 if !isRGBA { ··· 304 299 // Parse SVG 305 300 icon, err := oksvg.ReadIconStream(strings.NewReader(svgString)) 306 301 if err != nil { 307 - return fmt.Errorf("failed to parse SVG %s: %w", svgPath, err) 302 + return nil, fmt.Errorf("failed to parse SVG: %w", err) 303 + } 304 + 305 + return icon, nil 306 + } 307 + 308 + func BuildSVGIconFromPath(svgPath string, iconColor color.Color) (*oksvg.SvgIcon, error) { 309 + svgData, err := pages.Files.ReadFile(svgPath) 310 + if err != nil { 311 + return nil, fmt.Errorf("failed to read SVG file %s: %w", svgPath, err) 308 312 } 309 313 314 + icon, err := BuildSVGIconFromData(svgData, iconColor) 315 + if err != nil { 316 + return nil, fmt.Errorf("failed to build SVG icon %s: %w", svgPath, err) 317 + } 318 + 319 + return icon, nil 320 + } 321 + 322 + func BuildLucideIcon(name string, iconColor color.Color) (*oksvg.SvgIcon, error) { 323 + return BuildSVGIconFromPath(fmt.Sprintf("static/icons/%s.svg", name), iconColor) 324 + } 325 + 326 + func (c *Card) DrawLucideIcon(name string, x, y, size int, iconColor color.Color) error { 327 + icon, err := BuildSVGIconFromPath(fmt.Sprintf("static/icons/%s.svg", name), iconColor) 328 + if err != nil { 329 + return err 330 + } 331 + 332 + c.DrawSVGIcon(icon, x, y, size) 333 + 334 + return nil 335 + } 336 + 337 + func (c *Card) DrawDolly(x, y, size int, iconColor color.Color) error { 338 + tpl, err := template.New("dolly"). 339 + ParseFS(pages.Files, "templates/fragments/dolly/logo.html") 340 + if err != nil { 341 + return fmt.Errorf("failed to read dolly template: %w", err) 342 + } 343 + 344 + var svgData bytes.Buffer 345 + if err = tpl.ExecuteTemplate(&svgData, "fragments/dolly/logo", nil); err != nil { 346 + return fmt.Errorf("failed to execute dolly template: %w", err) 347 + } 348 + 349 + icon, err := BuildSVGIconFromData(svgData.Bytes(), iconColor) 350 + if err != nil { 351 + return err 352 + } 353 + 354 + c.DrawSVGIcon(icon, x, y, size) 355 + 356 + return nil 357 + } 358 + 359 + // DrawSVGIcon draws an SVG icon from the embedded files at the specified position 360 + func (c *Card) DrawSVGIcon(icon *oksvg.SvgIcon, x, y, size int) { 310 361 // Set the icon size 311 362 w, h := float64(size), float64(size) 312 363 icon.SetTarget(0, 0, w, h) ··· 334 385 } 335 386 336 387 draw.Draw(c.Img, destRect, iconImg, image.Point{}, draw.Over) 337 - 338 - return nil 339 388 } 340 389 341 390 // DrawImage fills the card with an image, scaled to maintain the original aspect ratio and centered with respect to the non-filled dimension ··· 404 453 405 454 // Handle SVG separately 406 455 if contentType == "image/svg+xml" || strings.HasSuffix(url, ".svg") { 407 - return c.convertSVGToPNG(bodyBytes) 456 + return convertSVGToPNG(bodyBytes) 408 457 } 409 458 410 459 // Support content types are in-sync with the allowed custom avatar file types ··· 444 493 } 445 494 446 495 // convertSVGToPNG converts SVG data to a PNG image 447 - func (c *Card) convertSVGToPNG(svgData []byte) (image.Image, bool) { 496 + func convertSVGToPNG(svgData []byte) (image.Image, bool) { 448 497 // Parse the SVG 449 498 icon, err := oksvg.ReadIconStream(bytes.NewReader(svgData)) 450 499 if err != nil { ··· 498 547 draw.CatmullRom.Scale(scaledImg, scaledImg.Bounds(), img, srcBounds, draw.Src, nil) 499 548 500 549 // Draw the image with circular clipping 501 - for cy := 0; cy < size; cy++ { 502 - for cx := 0; cx < size; cx++ { 550 + for cy := range size { 551 + for cx := range size { 503 552 // Calculate distance from center 504 553 dx := float64(cx - center) 505 554 dy := float64(cy - center)
+104 -11
appview/pages/funcmap.go
··· 1 1 package pages 2 2 3 3 import ( 4 + "bytes" 4 5 "context" 5 6 "crypto/hmac" 6 7 "crypto/sha256" ··· 17 18 "strings" 18 19 "time" 19 20 21 + "github.com/alecthomas/chroma/v2" 22 + chromahtml "github.com/alecthomas/chroma/v2/formatters/html" 23 + "github.com/alecthomas/chroma/v2/lexers" 24 + "github.com/alecthomas/chroma/v2/styles" 20 25 "github.com/dustin/go-humanize" 21 26 "github.com/go-enry/go-enry/v2" 27 + "github.com/yuin/goldmark" 28 + emoji "github.com/yuin/goldmark-emoji" 22 29 "tangled.org/core/appview/filetree" 30 + "tangled.org/core/appview/models" 23 31 "tangled.org/core/appview/pages/markup" 24 32 "tangled.org/core/crypto" 25 33 ) ··· 38 46 "contains": func(s string, target string) bool { 39 47 return strings.Contains(s, target) 40 48 }, 49 + "stripPort": func(hostname string) string { 50 + if strings.Contains(hostname, ":") { 51 + return strings.Split(hostname, ":")[0] 52 + } 53 + return hostname 54 + }, 41 55 "mapContains": func(m any, key any) bool { 42 56 mapValue := reflect.ValueOf(m) 43 57 if mapValue.Kind() != reflect.Map { ··· 57 71 return "handle.invalid" 58 72 } 59 73 60 - return "@" + identity.Handle.String() 74 + return identity.Handle.String() 75 + }, 76 + "ownerSlashRepo": func(repo *models.Repo) string { 77 + ownerId, err := p.resolver.ResolveIdent(context.Background(), repo.Did) 78 + if err != nil { 79 + return repo.DidSlashRepo() 80 + } 81 + handle := ownerId.Handle 82 + if handle != "" && !handle.IsInvalidHandle() { 83 + return string(handle) + "/" + repo.Name 84 + } 85 + return repo.DidSlashRepo() 61 86 }, 62 87 "truncateAt30": func(s string) string { 63 88 if len(s) <= 30 { ··· 68 93 "splitOn": func(s, sep string) []string { 69 94 return strings.Split(s, sep) 70 95 }, 96 + "string": func(v any) string { 97 + return fmt.Sprint(v) 98 + }, 71 99 "int64": func(a int) int64 { 72 100 return int64(a) 73 101 }, ··· 83 111 }, 84 112 "sub": func(a, b int) int { 85 113 return a - b 114 + }, 115 + "mul": func(a, b int) int { 116 + return a * b 117 + }, 118 + "div": func(a, b int) int { 119 + return a / b 120 + }, 121 + "mod": func(a, b int) int { 122 + return a % b 86 123 }, 87 124 "f64": func(a int) float64 { 88 125 return float64(a) ··· 116 153 117 154 return b 118 155 }, 119 - "didOrHandle": func(did, handle string) string { 120 - if handle != "" { 121 - return fmt.Sprintf("@%s", handle) 122 - } else { 123 - return did 124 - } 125 - }, 126 156 "assoc": func(values ...string) ([][]string, error) { 127 157 if len(values)%2 != 0 { 128 158 return nil, fmt.Errorf("invalid assoc call, must have an even number of arguments") ··· 133 163 } 134 164 return pairs, nil 135 165 }, 136 - "append": func(s []string, values ...string) []string { 166 + "append": func(s []any, values ...any) []any { 137 167 s = append(s, values...) 138 168 return s 139 169 }, ··· 232 262 }, 233 263 "description": func(text string) template.HTML { 234 264 p.rctx.RendererType = markup.RendererTypeDefault 265 + htmlString := p.rctx.RenderMarkdownWith(text, goldmark.New( 266 + goldmark.WithExtensions( 267 + emoji.Emoji, 268 + ), 269 + )) 270 + sanitized := p.rctx.SanitizeDescription(htmlString) 271 + return template.HTML(sanitized) 272 + }, 273 + "readme": func(text string) template.HTML { 274 + p.rctx.RendererType = markup.RendererTypeRepoMarkdown 235 275 htmlString := p.rctx.RenderMarkdown(text) 236 - sanitized := p.rctx.SanitizeDescription(htmlString) 276 + sanitized := p.rctx.SanitizeDefault(htmlString) 237 277 return template.HTML(sanitized) 238 278 }, 279 + "code": func(content, path string) string { 280 + var style *chroma.Style = styles.Get("catpuccin-latte") 281 + formatter := chromahtml.New( 282 + chromahtml.InlineCode(false), 283 + chromahtml.WithLineNumbers(true), 284 + chromahtml.WithLinkableLineNumbers(true, "L"), 285 + chromahtml.Standalone(false), 286 + chromahtml.WithClasses(true), 287 + ) 288 + 289 + lexer := lexers.Get(filepath.Base(path)) 290 + if lexer == nil { 291 + lexer = lexers.Fallback 292 + } 293 + 294 + iterator, err := lexer.Tokenise(nil, content) 295 + if err != nil { 296 + p.logger.Error("chroma tokenize", "err", "err") 297 + return "" 298 + } 299 + 300 + var code bytes.Buffer 301 + err = formatter.Format(&code, style, iterator) 302 + if err != nil { 303 + p.logger.Error("chroma format", "err", "err") 304 + return "" 305 + } 306 + 307 + return code.String() 308 + }, 309 + "trimUriScheme": func(text string) string { 310 + text = strings.TrimPrefix(text, "https://") 311 + text = strings.TrimPrefix(text, "http://") 312 + return text 313 + }, 239 314 "isNil": func(t any) bool { 240 315 // returns false for other "zero" values 241 316 return t == nil ··· 281 356 u, _ := url.PathUnescape(s) 282 357 return u 283 358 }, 284 - 359 + "safeUrl": func(s string) template.URL { 360 + return template.URL(s) 361 + }, 285 362 "tinyAvatar": func(handle string) string { 286 363 return p.AvatarUrl(handle, "tiny") 287 364 }, ··· 311 388 } 312 389 } 313 390 391 + func (p *Pages) resolveDid(did string) string { 392 + identity, err := p.resolver.ResolveIdent(context.Background(), did) 393 + 394 + if err != nil { 395 + return did 396 + } 397 + 398 + if identity.Handle.IsInvalidHandle() { 399 + return "handle.invalid" 400 + } 401 + 402 + return identity.Handle.String() 403 + } 404 + 314 405 func (p *Pages) AvatarUrl(handle, size string) string { 315 406 handle = strings.TrimPrefix(handle, "@") 407 + 408 + handle = p.resolveDid(handle) 316 409 317 410 secret := p.avatar.SharedSecret 318 411 h := hmac.New(sha256.New, []byte(secret))
+121
appview/pages/markup/extension/atlink.go
··· 1 + // heavily inspired by: https://github.com/kaleocheng/goldmark-extensions 2 + 3 + package extension 4 + 5 + import ( 6 + "regexp" 7 + 8 + "github.com/yuin/goldmark" 9 + "github.com/yuin/goldmark/ast" 10 + "github.com/yuin/goldmark/parser" 11 + "github.com/yuin/goldmark/renderer" 12 + "github.com/yuin/goldmark/renderer/html" 13 + "github.com/yuin/goldmark/text" 14 + "github.com/yuin/goldmark/util" 15 + ) 16 + 17 + // An AtNode struct represents an AtNode 18 + type AtNode struct { 19 + Handle string 20 + ast.BaseInline 21 + } 22 + 23 + var _ ast.Node = &AtNode{} 24 + 25 + // Dump implements Node.Dump. 26 + func (n *AtNode) Dump(source []byte, level int) { 27 + ast.DumpHelper(n, source, level, nil, nil) 28 + } 29 + 30 + // KindAt is a NodeKind of the At node. 31 + var KindAt = ast.NewNodeKind("At") 32 + 33 + // Kind implements Node.Kind. 34 + func (n *AtNode) Kind() ast.NodeKind { 35 + return KindAt 36 + } 37 + 38 + var atRegexp = regexp.MustCompile(`(^|\s|\()(@)([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\b)`) 39 + var markdownLinkRegexp = regexp.MustCompile(`(?ms)\[.*\]\(.*\)`) 40 + 41 + type atParser struct{} 42 + 43 + // NewAtParser return a new InlineParser that parses 44 + // at expressions. 45 + func NewAtParser() parser.InlineParser { 46 + return &atParser{} 47 + } 48 + 49 + func (s *atParser) Trigger() []byte { 50 + return []byte{'@'} 51 + } 52 + 53 + func (s *atParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node { 54 + line, segment := block.PeekLine() 55 + m := atRegexp.FindSubmatchIndex(line) 56 + if m == nil { 57 + return nil 58 + } 59 + 60 + // Check for all links in the markdown to see if the handle found is inside one 61 + linksIndexes := markdownLinkRegexp.FindAllIndex(block.Source(), -1) 62 + for _, linkMatch := range linksIndexes { 63 + if linkMatch[0] < segment.Start && segment.Start < linkMatch[1] { 64 + return nil 65 + } 66 + } 67 + 68 + atSegment := text.NewSegment(segment.Start, segment.Start+m[1]) 69 + block.Advance(m[1]) 70 + node := &AtNode{} 71 + node.AppendChild(node, ast.NewTextSegment(atSegment)) 72 + node.Handle = string(atSegment.Value(block.Source())[1:]) 73 + return node 74 + } 75 + 76 + // atHtmlRenderer is a renderer.NodeRenderer implementation that 77 + // renders At nodes. 78 + type atHtmlRenderer struct { 79 + html.Config 80 + } 81 + 82 + // NewAtHTMLRenderer returns a new AtHTMLRenderer. 83 + func NewAtHTMLRenderer(opts ...html.Option) renderer.NodeRenderer { 84 + r := &atHtmlRenderer{ 85 + Config: html.NewConfig(), 86 + } 87 + for _, opt := range opts { 88 + opt.SetHTMLOption(&r.Config) 89 + } 90 + return r 91 + } 92 + 93 + // RegisterFuncs implements renderer.NodeRenderer.RegisterFuncs. 94 + func (r *atHtmlRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { 95 + reg.Register(KindAt, r.renderAt) 96 + } 97 + 98 + func (r *atHtmlRenderer) renderAt(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) { 99 + if entering { 100 + w.WriteString(`<a href="/`) 101 + w.WriteString(n.(*AtNode).Handle) 102 + w.WriteString(`" class="mention">`) 103 + } else { 104 + w.WriteString("</a>") 105 + } 106 + return ast.WalkContinue, nil 107 + } 108 + 109 + type atExt struct{} 110 + 111 + // At is an extension that allow you to use at expression like '@user.bsky.social' . 112 + var AtExt = &atExt{} 113 + 114 + func (e *atExt) Extend(m goldmark.Markdown) { 115 + m.Parser().AddOptions(parser.WithInlineParsers( 116 + util.Prioritized(NewAtParser(), 500), 117 + )) 118 + m.Renderer().AddOptions(renderer.WithNodeRenderers( 119 + util.Prioritized(NewAtHTMLRenderer(), 500), 120 + )) 121 + }
+13 -4
appview/pages/markup/markdown.go
··· 12 12 13 13 chromahtml "github.com/alecthomas/chroma/v2/formatters/html" 14 14 "github.com/alecthomas/chroma/v2/styles" 15 - treeblood "github.com/wyatt915/goldmark-treeblood" 16 15 "github.com/yuin/goldmark" 16 + "github.com/yuin/goldmark-emoji" 17 17 highlighting "github.com/yuin/goldmark-highlighting/v2" 18 18 "github.com/yuin/goldmark/ast" 19 19 "github.com/yuin/goldmark/extension" ··· 25 25 htmlparse "golang.org/x/net/html" 26 26 27 27 "tangled.org/core/api/tangled" 28 + textension "tangled.org/core/appview/pages/markup/extension" 28 29 "tangled.org/core/appview/pages/repoinfo" 29 30 ) 30 31 ··· 50 51 Files fs.FS 51 52 } 52 53 53 - func (rctx *RenderContext) RenderMarkdown(source string) string { 54 + func NewMarkdown() goldmark.Markdown { 54 55 md := goldmark.New( 55 56 goldmark.WithExtensions( 56 57 extension.GFM, ··· 64 65 extension.NewFootnote( 65 66 extension.WithFootnoteIDPrefix([]byte("footnote")), 66 67 ), 67 - treeblood.MathML(), 68 68 callout.CalloutExtention, 69 + textension.AtExt, 70 + emoji.Emoji, 69 71 ), 70 72 goldmark.WithParserOptions( 71 73 parser.WithAutoHeadingID(), 72 74 ), 73 75 goldmark.WithRendererOptions(html.WithUnsafe()), 74 76 ) 77 + return md 78 + } 75 79 80 + func (rctx *RenderContext) RenderMarkdown(source string) string { 81 + return rctx.RenderMarkdownWith(source, NewMarkdown()) 82 + } 83 + 84 + func (rctx *RenderContext) RenderMarkdownWith(source string, md goldmark.Markdown) string { 76 85 if rctx != nil { 77 86 var transformers []util.PrioritizedValue 78 87 ··· 240 249 repoName := fmt.Sprintf("%s/%s", rctx.RepoInfo.OwnerDid, rctx.RepoInfo.Name) 241 250 242 251 query := fmt.Sprintf("repo=%s&ref=%s&path=%s&raw=true", 243 - url.PathEscape(repoName), url.PathEscape(rctx.RepoInfo.Ref), actualPath) 252 + url.QueryEscape(repoName), url.QueryEscape(rctx.RepoInfo.Ref), actualPath) 244 253 245 254 parsedURL := &url.URL{ 246 255 Scheme: scheme,
+121
appview/pages/markup/markdown_test.go
··· 1 + package markup 2 + 3 + import ( 4 + "bytes" 5 + "testing" 6 + ) 7 + 8 + func TestAtExtension_Rendering(t *testing.T) { 9 + tests := []struct { 10 + name string 11 + markdown string 12 + expected string 13 + }{ 14 + { 15 + name: "renders simple at mention", 16 + markdown: "Hello @user.tngl.sh!", 17 + expected: `<p>Hello <a href="/user.tngl.sh" class="mention">@user.tngl.sh</a>!</p>`, 18 + }, 19 + { 20 + name: "renders multiple at mentions", 21 + markdown: "Hi @alice.tngl.sh and @bob.example.com", 22 + expected: `<p>Hi <a href="/alice.tngl.sh" class="mention">@alice.tngl.sh</a> and <a href="/bob.example.com" class="mention">@bob.example.com</a></p>`, 23 + }, 24 + { 25 + name: "renders at mention in parentheses", 26 + markdown: "Check this out (@user.tngl.sh)", 27 + expected: `<p>Check this out (<a href="/user.tngl.sh" class="mention">@user.tngl.sh</a>)</p>`, 28 + }, 29 + { 30 + name: "does not render email", 31 + markdown: "Contact me at test@example.com", 32 + expected: `<p>Contact me at <a href="mailto:test@example.com">test@example.com</a></p>`, 33 + }, 34 + { 35 + name: "renders at mention with hyphen", 36 + markdown: "Follow @user-name.tngl.sh", 37 + expected: `<p>Follow <a href="/user-name.tngl.sh" class="mention">@user-name.tngl.sh</a></p>`, 38 + }, 39 + { 40 + name: "renders at mention with numbers", 41 + markdown: "@user123.test456.social", 42 + expected: `<p><a href="/user123.test456.social" class="mention">@user123.test456.social</a></p>`, 43 + }, 44 + { 45 + name: "at mention at start of line", 46 + markdown: "@user.tngl.sh is cool", 47 + expected: `<p><a href="/user.tngl.sh" class="mention">@user.tngl.sh</a> is cool</p>`, 48 + }, 49 + } 50 + 51 + for _, tt := range tests { 52 + t.Run(tt.name, func(t *testing.T) { 53 + md := NewMarkdown() 54 + 55 + var buf bytes.Buffer 56 + if err := md.Convert([]byte(tt.markdown), &buf); err != nil { 57 + t.Fatalf("failed to convert markdown: %v", err) 58 + } 59 + 60 + result := buf.String() 61 + if result != tt.expected+"\n" { 62 + t.Errorf("expected:\n%s\ngot:\n%s", tt.expected, result) 63 + } 64 + }) 65 + } 66 + } 67 + 68 + func TestAtExtension_WithOtherMarkdown(t *testing.T) { 69 + tests := []struct { 70 + name string 71 + markdown string 72 + contains string 73 + }{ 74 + { 75 + name: "at mention with bold", 76 + markdown: "**Hello @user.tngl.sh**", 77 + contains: `<strong>Hello <a href="/user.tngl.sh" class="mention">@user.tngl.sh</a></strong>`, 78 + }, 79 + { 80 + name: "at mention with italic", 81 + markdown: "*Check @user.tngl.sh*", 82 + contains: `<em>Check <a href="/user.tngl.sh" class="mention">@user.tngl.sh</a></em>`, 83 + }, 84 + { 85 + name: "at mention in list", 86 + markdown: "- Item 1\n- @user.tngl.sh\n- Item 3", 87 + contains: `<a href="/user.tngl.sh" class="mention">@user.tngl.sh</a>`, 88 + }, 89 + { 90 + name: "at mention in link", 91 + markdown: "[@regnault.dev](https://regnault.dev)", 92 + contains: `<a href="https://regnault.dev">@regnault.dev</a>`, 93 + }, 94 + { 95 + name: "at mention in link again", 96 + markdown: "[check out @regnault.dev](https://regnault.dev)", 97 + contains: `<a href="https://regnault.dev">check out @regnault.dev</a>`, 98 + }, 99 + { 100 + name: "at mention in link again, multiline", 101 + markdown: "[\ncheck out @regnault.dev](https://regnault.dev)", 102 + contains: "<a href=\"https://regnault.dev\">\ncheck out @regnault.dev</a>", 103 + }, 104 + } 105 + 106 + for _, tt := range tests { 107 + t.Run(tt.name, func(t *testing.T) { 108 + md := NewMarkdown() 109 + 110 + var buf bytes.Buffer 111 + if err := md.Convert([]byte(tt.markdown), &buf); err != nil { 112 + t.Fatalf("failed to convert markdown: %v", err) 113 + } 114 + 115 + result := buf.String() 116 + if !bytes.Contains([]byte(result), []byte(tt.contains)) { 117 + t.Errorf("expected output to contain:\n%s\ngot:\n%s", tt.contains, result) 118 + } 119 + }) 120 + } 121 + }
+124
appview/pages/markup/reference_link.go
··· 1 + package markup 2 + 3 + import ( 4 + "maps" 5 + "net/url" 6 + "path" 7 + "slices" 8 + "strconv" 9 + "strings" 10 + 11 + "github.com/yuin/goldmark/ast" 12 + "github.com/yuin/goldmark/text" 13 + "tangled.org/core/appview/models" 14 + textension "tangled.org/core/appview/pages/markup/extension" 15 + ) 16 + 17 + // FindReferences collects all links referencing tangled-related objects 18 + // like issues, PRs, comments or even @-mentions 19 + // This funciton doesn't actually check for the existence of records in the DB 20 + // or the PDS; it merely returns a list of what are presumed to be references. 21 + func FindReferences(baseUrl string, source string) ([]string, []models.ReferenceLink) { 22 + var ( 23 + refLinkSet = make(map[models.ReferenceLink]struct{}) 24 + mentionsSet = make(map[string]struct{}) 25 + md = NewMarkdown() 26 + sourceBytes = []byte(source) 27 + root = md.Parser().Parse(text.NewReader(sourceBytes)) 28 + ) 29 + // trim url scheme. the SSL shouldn't matter 30 + baseUrl = strings.TrimPrefix(baseUrl, "https://") 31 + baseUrl = strings.TrimPrefix(baseUrl, "http://") 32 + 33 + ast.Walk(root, func(n ast.Node, entering bool) (ast.WalkStatus, error) { 34 + if !entering { 35 + return ast.WalkContinue, nil 36 + } 37 + switch n.Kind() { 38 + case textension.KindAt: 39 + handle := n.(*textension.AtNode).Handle 40 + mentionsSet[handle] = struct{}{} 41 + return ast.WalkSkipChildren, nil 42 + case ast.KindLink: 43 + dest := string(n.(*ast.Link).Destination) 44 + ref := parseTangledLink(baseUrl, dest) 45 + if ref != nil { 46 + refLinkSet[*ref] = struct{}{} 47 + } 48 + return ast.WalkSkipChildren, nil 49 + case ast.KindAutoLink: 50 + an := n.(*ast.AutoLink) 51 + if an.AutoLinkType == ast.AutoLinkURL { 52 + dest := string(an.URL(sourceBytes)) 53 + ref := parseTangledLink(baseUrl, dest) 54 + if ref != nil { 55 + refLinkSet[*ref] = struct{}{} 56 + } 57 + } 58 + return ast.WalkSkipChildren, nil 59 + } 60 + return ast.WalkContinue, nil 61 + }) 62 + mentions := slices.Collect(maps.Keys(mentionsSet)) 63 + references := slices.Collect(maps.Keys(refLinkSet)) 64 + return mentions, references 65 + } 66 + 67 + func parseTangledLink(baseHost string, urlStr string) *models.ReferenceLink { 68 + u, err := url.Parse(urlStr) 69 + if err != nil { 70 + return nil 71 + } 72 + 73 + if u.Host != "" && !strings.EqualFold(u.Host, baseHost) { 74 + return nil 75 + } 76 + 77 + p := path.Clean(u.Path) 78 + parts := strings.FieldsFunc(p, func(r rune) bool { return r == '/' }) 79 + if len(parts) < 4 { 80 + // need at least: handle / repo / kind / id 81 + return nil 82 + } 83 + 84 + var ( 85 + handle = parts[0] 86 + repo = parts[1] 87 + kindSeg = parts[2] 88 + subjectSeg = parts[3] 89 + ) 90 + 91 + handle = strings.TrimPrefix(handle, "@") 92 + 93 + var kind models.RefKind 94 + switch kindSeg { 95 + case "issues": 96 + kind = models.RefKindIssue 97 + case "pulls": 98 + kind = models.RefKindPull 99 + default: 100 + return nil 101 + } 102 + 103 + subjectId, err := strconv.Atoi(subjectSeg) 104 + if err != nil { 105 + return nil 106 + } 107 + var commentId *int 108 + if u.Fragment != "" { 109 + if strings.HasPrefix(u.Fragment, "comment-") { 110 + commentIdStr := u.Fragment[len("comment-"):] 111 + if id, err := strconv.Atoi(commentIdStr); err == nil { 112 + commentId = &id 113 + } 114 + } 115 + } 116 + 117 + return &models.ReferenceLink{ 118 + Handle: handle, 119 + Repo: repo, 120 + Kind: kind, 121 + SubjectId: subjectId, 122 + CommentId: commentId, 123 + } 124 + }
+3
appview/pages/markup/sanitizer.go
··· 77 77 policy.AllowAttrs("class").Matching(regexp.MustCompile(`heading`)).OnElements("h1", "h2", "h3", "h4", "h5", "h6", "h7", "h8") 78 78 policy.AllowAttrs("class").Matching(regexp.MustCompile(strings.Join(slices.Collect(maps.Values(chroma.StandardTypes)), "|"))).OnElements("span") 79 79 80 + // at-mentions 81 + policy.AllowAttrs("class").Matching(regexp.MustCompile(`mention`)).OnElements("a") 82 + 80 83 // centering content 81 84 policy.AllowElements("center") 82 85
+85 -149
appview/pages/pages.go
··· 1 1 package pages 2 2 3 3 import ( 4 - "bytes" 5 4 "crypto/sha256" 6 5 "embed" 7 6 "encoding/hex" ··· 15 14 "path/filepath" 16 15 "strings" 17 16 "sync" 17 + "time" 18 18 19 19 "tangled.org/core/api/tangled" 20 20 "tangled.org/core/appview/commitverify" ··· 28 28 "tangled.org/core/patchutil" 29 29 "tangled.org/core/types" 30 30 31 - "github.com/alecthomas/chroma/v2" 32 - chromahtml "github.com/alecthomas/chroma/v2/formatters/html" 33 - "github.com/alecthomas/chroma/v2/lexers" 34 - "github.com/alecthomas/chroma/v2/styles" 35 31 "github.com/bluesky-social/indigo/atproto/identity" 36 32 "github.com/bluesky-social/indigo/atproto/syntax" 37 33 "github.com/go-git/go-git/v5/plumbing" 38 - "github.com/go-git/go-git/v5/plumbing/object" 39 34 ) 40 35 41 36 //go:embed templates/* static legal ··· 215 210 return tpl.ExecuteTemplate(w, "layouts/base", params) 216 211 } 217 212 213 + type DollyParams struct { 214 + Classes string 215 + FillColor string 216 + } 217 + 218 + func (p *Pages) Dolly(w io.Writer, params DollyParams) error { 219 + return p.executePlain("fragments/dolly/logo", w, params) 220 + } 221 + 218 222 func (p *Pages) Favicon(w io.Writer) error { 219 - return p.executePlain("fragments/dolly/silhouette", w, nil) 223 + return p.Dolly(w, DollyParams{ 224 + Classes: "text-black dark:text-white", 225 + }) 220 226 } 221 227 222 228 type LoginParams struct { ··· 411 417 type KnotsParams struct { 412 418 LoggedInUser *oauth.User 413 419 Registrations []models.Registration 420 + Tabs []map[string]any 421 + Tab string 414 422 } 415 423 416 424 func (p *Pages) Knots(w io.Writer, params KnotsParams) error { ··· 423 431 Members []string 424 432 Repos map[string][]models.Repo 425 433 IsOwner bool 434 + Tabs []map[string]any 435 + Tab string 426 436 } 427 437 428 438 func (p *Pages) Knot(w io.Writer, params KnotParams) error { ··· 440 450 type SpindlesParams struct { 441 451 LoggedInUser *oauth.User 442 452 Spindles []models.Spindle 453 + Tabs []map[string]any 454 + Tab string 443 455 } 444 456 445 457 func (p *Pages) Spindles(w io.Writer, params SpindlesParams) error { ··· 448 460 449 461 type SpindleListingParams struct { 450 462 models.Spindle 463 + Tabs []map[string]any 464 + Tab string 451 465 } 452 466 453 467 func (p *Pages) SpindleListing(w io.Writer, params SpindleListingParams) error { ··· 459 473 Spindle models.Spindle 460 474 Members []string 461 475 Repos map[string][]models.Repo 476 + Tabs []map[string]any 477 + Tab string 462 478 } 463 479 464 480 func (p *Pages) SpindleDashboard(w io.Writer, params SpindleDashboardParams) error { ··· 486 502 487 503 type ProfileCard struct { 488 504 UserDid string 489 - UserHandle string 490 505 FollowStatus models.FollowStatus 491 506 Punchcard *models.Punchcard 492 507 Profile *models.Profile ··· 629 644 return p.executePlain("user/fragments/editPins", w, params) 630 645 } 631 646 632 - type RepoStarFragmentParams struct { 647 + type StarBtnFragmentParams struct { 633 648 IsStarred bool 634 - RepoAt syntax.ATURI 635 - Stats models.RepoStats 636 - } 637 - 638 - func (p *Pages) RepoStarFragment(w io.Writer, params RepoStarFragmentParams) error { 639 - return p.executePlain("repo/fragments/repoStar", w, params) 640 - } 641 - 642 - type RepoDescriptionParams struct { 643 - RepoInfo repoinfo.RepoInfo 644 - } 645 - 646 - func (p *Pages) EditRepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error { 647 - return p.executePlain("repo/fragments/editRepoDescription", w, params) 649 + SubjectAt syntax.ATURI 650 + StarCount int 648 651 } 649 652 650 - func (p *Pages) RepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error { 651 - return p.executePlain("repo/fragments/repoDescription", w, params) 653 + func (p *Pages) StarBtnFragment(w io.Writer, params StarBtnFragmentParams) error { 654 + return p.executePlain("fragments/starBtn-oob", w, params) 652 655 } 653 656 654 657 type RepoIndexParams struct { ··· 656 659 RepoInfo repoinfo.RepoInfo 657 660 Active string 658 661 TagMap map[string][]string 659 - CommitsTrunc []*object.Commit 662 + CommitsTrunc []types.Commit 660 663 TagsTrunc []*types.TagReference 661 664 BranchesTrunc []types.Branch 662 665 // ForkInfo *types.ForkInfo 663 - HTMLReadme template.HTML 664 - Raw bool 665 - EmailToDidOrHandle map[string]string 666 - VerifiedCommits commitverify.VerifiedCommits 667 - Languages []types.RepoLanguageDetails 668 - Pipelines map[string]models.Pipeline 669 - NeedsKnotUpgrade bool 666 + HTMLReadme template.HTML 667 + Raw bool 668 + EmailToDid map[string]string 669 + VerifiedCommits commitverify.VerifiedCommits 670 + Languages []types.RepoLanguageDetails 671 + Pipelines map[string]models.Pipeline 672 + NeedsKnotUpgrade bool 670 673 types.RepoIndexResponse 671 674 } 672 675 ··· 701 704 } 702 705 703 706 type RepoLogParams struct { 704 - LoggedInUser *oauth.User 705 - RepoInfo repoinfo.RepoInfo 706 - TagMap map[string][]string 707 + LoggedInUser *oauth.User 708 + RepoInfo repoinfo.RepoInfo 709 + TagMap map[string][]string 710 + Active string 711 + EmailToDid map[string]string 712 + VerifiedCommits commitverify.VerifiedCommits 713 + Pipelines map[string]models.Pipeline 714 + 707 715 types.RepoLogResponse 708 - Active string 709 - EmailToDidOrHandle map[string]string 710 - VerifiedCommits commitverify.VerifiedCommits 711 - Pipelines map[string]models.Pipeline 712 716 } 713 717 714 718 func (p *Pages) RepoLog(w io.Writer, params RepoLogParams) error { ··· 717 721 } 718 722 719 723 type RepoCommitParams struct { 720 - LoggedInUser *oauth.User 721 - RepoInfo repoinfo.RepoInfo 722 - Active string 723 - EmailToDidOrHandle map[string]string 724 - Pipeline *models.Pipeline 725 - DiffOpts types.DiffOpts 724 + LoggedInUser *oauth.User 725 + RepoInfo repoinfo.RepoInfo 726 + Active string 727 + EmailToDid map[string]string 728 + Pipeline *models.Pipeline 729 + DiffOpts types.DiffOpts 726 730 727 731 // singular because it's always going to be just one 728 732 VerifiedCommit commitverify.VerifiedCommits ··· 754 758 func (r RepoTreeParams) TreeStats() RepoTreeStats { 755 759 numFolders, numFiles := 0, 0 756 760 for _, f := range r.Files { 757 - if !f.IsFile { 761 + if !f.IsFile() { 758 762 numFolders += 1 759 - } else if f.IsFile { 763 + } else if f.IsFile() { 760 764 numFiles += 1 761 765 } 762 766 } ··· 827 831 } 828 832 829 833 type RepoBlobParams struct { 830 - LoggedInUser *oauth.User 831 - RepoInfo repoinfo.RepoInfo 832 - Active string 833 - Unsupported bool 834 - IsImage bool 835 - IsVideo bool 836 - ContentSrc string 837 - BreadCrumbs [][]string 838 - ShowRendered bool 839 - RenderToggle bool 840 - RenderedContents template.HTML 834 + LoggedInUser *oauth.User 835 + RepoInfo repoinfo.RepoInfo 836 + Active string 837 + BreadCrumbs [][]string 838 + BlobView models.BlobView 841 839 *tangled.RepoBlob_Output 842 - // Computed fields for template compatibility 843 - Contents string 844 - Lines int 845 - SizeHint uint64 846 - IsBinary bool 847 840 } 848 841 849 842 func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error { 850 - var style *chroma.Style = styles.Get("catpuccin-latte") 851 - 852 - if params.ShowRendered { 853 - switch markup.GetFormat(params.Path) { 854 - case markup.FormatMarkdown: 855 - p.rctx.RepoInfo = params.RepoInfo 856 - p.rctx.RendererType = markup.RendererTypeRepoMarkdown 857 - htmlString := p.rctx.RenderMarkdown(params.Contents) 858 - sanitized := p.rctx.SanitizeDefault(htmlString) 859 - params.RenderedContents = template.HTML(sanitized) 860 - } 843 + switch params.BlobView.ContentType { 844 + case models.BlobContentTypeMarkup: 845 + p.rctx.RepoInfo = params.RepoInfo 861 846 } 862 847 863 - c := params.Contents 864 - formatter := chromahtml.New( 865 - chromahtml.InlineCode(false), 866 - chromahtml.WithLineNumbers(true), 867 - chromahtml.WithLinkableLineNumbers(true, "L"), 868 - chromahtml.Standalone(false), 869 - chromahtml.WithClasses(true), 870 - ) 871 - 872 - lexer := lexers.Get(filepath.Base(params.Path)) 873 - if lexer == nil { 874 - lexer = lexers.Fallback 875 - } 876 - 877 - iterator, err := lexer.Tokenise(nil, c) 878 - if err != nil { 879 - return fmt.Errorf("chroma tokenize: %w", err) 880 - } 881 - 882 - var code bytes.Buffer 883 - err = formatter.Format(&code, style, iterator) 884 - if err != nil { 885 - return fmt.Errorf("chroma format: %w", err) 886 - } 887 - 888 - params.Contents = code.String() 889 848 params.Active = "overview" 890 849 return p.executeRepo("repo/blob", w, params) 891 850 } 892 851 893 852 type Collaborator struct { 894 - Did string 895 - Handle string 896 - Role string 853 + Did string 854 + Role string 897 855 } 898 856 899 857 type RepoSettingsParams struct { ··· 968 926 RepoInfo repoinfo.RepoInfo 969 927 Active string 970 928 Issues []models.Issue 929 + IssueCount int 971 930 LabelDefs map[string]*models.LabelDefinition 972 931 Page pagination.Page 973 932 FilteringByOpen bool ··· 985 944 Active string 986 945 Issue *models.Issue 987 946 CommentList []models.CommentListItem 947 + Backlinks []models.RichReferenceLink 988 948 LabelDefs map[string]*models.LabelDefinition 989 949 990 950 OrderedReactionKinds []models.ReactionKind ··· 1102 1062 Pulls []*models.Pull 1103 1063 Active string 1104 1064 FilteringBy models.PullState 1065 + FilterQuery string 1105 1066 Stacks map[string]models.Stack 1106 1067 Pipelines map[string]models.Pipeline 1107 1068 LabelDefs map[string]*models.LabelDefinition ··· 1137 1098 Pull *models.Pull 1138 1099 Stack models.Stack 1139 1100 AbandonedPulls []*models.Pull 1101 + Backlinks []models.RichReferenceLink 1140 1102 BranchDeleteStatus *models.BranchDeleteStatus 1141 1103 MergeCheck types.MergeCheckResponse 1142 1104 ResubmitCheck ResubmitResult ··· 1308 1270 return p.executePlain("repo/fragments/compareAllowPull", w, params) 1309 1271 } 1310 1272 1311 - type RepoCompareDiffParams struct { 1312 - LoggedInUser *oauth.User 1313 - RepoInfo repoinfo.RepoInfo 1314 - Diff types.NiceDiff 1273 + type RepoCompareDiffFragmentParams struct { 1274 + Diff types.NiceDiff 1275 + DiffOpts types.DiffOpts 1315 1276 } 1316 1277 1317 - func (p *Pages) RepoCompareDiff(w io.Writer, params RepoCompareDiffParams) error { 1318 - return p.executePlain("repo/fragments/diff", w, []any{params.RepoInfo.FullName, &params.Diff}) 1278 + func (p *Pages) RepoCompareDiffFragment(w io.Writer, params RepoCompareDiffFragmentParams) error { 1279 + return p.executePlain("repo/fragments/diff", w, []any{&params.Diff, &params.DiffOpts}) 1319 1280 } 1320 1281 1321 1282 type LabelPanelParams struct { ··· 1359 1320 Name string 1360 1321 Command string 1361 1322 Collapsed bool 1323 + StartTime time.Time 1362 1324 } 1363 1325 1364 1326 func (p *Pages) LogBlock(w io.Writer, params LogBlockParams) error { 1365 1327 return p.executePlain("repo/pipelines/fragments/logBlock", w, params) 1366 1328 } 1367 1329 1330 + type LogBlockEndParams struct { 1331 + Id int 1332 + StartTime time.Time 1333 + EndTime time.Time 1334 + } 1335 + 1336 + func (p *Pages) LogBlockEnd(w io.Writer, params LogBlockEndParams) error { 1337 + return p.executePlain("repo/pipelines/fragments/logBlockEnd", w, params) 1338 + } 1339 + 1368 1340 type LogLineParams struct { 1369 1341 Id int 1370 1342 Content string ··· 1424 1396 ShowRendered bool 1425 1397 RenderToggle bool 1426 1398 RenderedContents template.HTML 1427 - String models.String 1399 + String *models.String 1428 1400 Stats models.StringStats 1401 + IsStarred bool 1402 + StarCount int 1429 1403 Owner identity.Identity 1430 1404 } 1431 1405 1432 1406 func (p *Pages) SingleString(w io.Writer, params SingleStringParams) error { 1433 - var style *chroma.Style = styles.Get("catpuccin-latte") 1434 - 1435 - if params.ShowRendered { 1436 - switch markup.GetFormat(params.String.Filename) { 1437 - case markup.FormatMarkdown: 1438 - p.rctx.RendererType = markup.RendererTypeRepoMarkdown 1439 - htmlString := p.rctx.RenderMarkdown(params.String.Contents) 1440 - sanitized := p.rctx.SanitizeDefault(htmlString) 1441 - params.RenderedContents = template.HTML(sanitized) 1442 - } 1443 - } 1444 - 1445 - c := params.String.Contents 1446 - formatter := chromahtml.New( 1447 - chromahtml.InlineCode(false), 1448 - chromahtml.WithLineNumbers(true), 1449 - chromahtml.WithLinkableLineNumbers(true, "L"), 1450 - chromahtml.Standalone(false), 1451 - chromahtml.WithClasses(true), 1452 - ) 1453 - 1454 - lexer := lexers.Get(filepath.Base(params.String.Filename)) 1455 - if lexer == nil { 1456 - lexer = lexers.Fallback 1457 - } 1458 - 1459 - iterator, err := lexer.Tokenise(nil, c) 1460 - if err != nil { 1461 - return fmt.Errorf("chroma tokenize: %w", err) 1462 - } 1463 - 1464 - var code bytes.Buffer 1465 - err = formatter.Format(&code, style, iterator) 1466 - if err != nil { 1467 - return fmt.Errorf("chroma format: %w", err) 1468 - } 1469 - 1470 - params.String.Contents = code.String() 1471 1407 return p.execute("strings/string", w, params) 1472 1408 } 1473 1409
+27 -24
appview/pages/repoinfo/repoinfo.go
··· 4 4 "fmt" 5 5 "path" 6 6 "slices" 7 - "strings" 8 7 9 8 "github.com/bluesky-social/indigo/atproto/syntax" 9 + "tangled.org/core/api/tangled" 10 10 "tangled.org/core/appview/models" 11 11 "tangled.org/core/appview/state/userutil" 12 12 ) 13 13 14 - func (r RepoInfo) OwnerWithAt() string { 14 + func (r RepoInfo) owner() string { 15 15 if r.OwnerHandle != "" { 16 - return fmt.Sprintf("@%s", r.OwnerHandle) 16 + return r.OwnerHandle 17 17 } else { 18 18 return r.OwnerDid 19 19 } 20 20 } 21 21 22 22 func (r RepoInfo) FullName() string { 23 - return path.Join(r.OwnerWithAt(), r.Name) 23 + return path.Join(r.owner(), r.Name) 24 24 } 25 25 26 - func (r RepoInfo) OwnerWithoutAt() string { 27 - if after, ok := strings.CutPrefix(r.OwnerWithAt(), "@"); ok { 28 - return after 26 + func (r RepoInfo) ownerWithoutAt() string { 27 + if r.OwnerHandle != "" { 28 + return r.OwnerHandle 29 29 } else { 30 30 return userutil.FlattenDid(r.OwnerDid) 31 31 } 32 32 } 33 33 34 34 func (r RepoInfo) FullNameWithoutAt() string { 35 - return path.Join(r.OwnerWithoutAt(), r.Name) 35 + return path.Join(r.ownerWithoutAt(), r.Name) 36 36 } 37 37 38 38 func (r RepoInfo) GetTabs() [][]string { ··· 48 48 } 49 49 50 50 return tabs 51 + } 52 + 53 + func (r RepoInfo) RepoAt() syntax.ATURI { 54 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", r.OwnerDid, tangled.RepoNSID, r.Rkey)) 51 55 } 52 56 53 57 type RepoInfo struct { 54 - Name string 55 - Rkey string 56 - OwnerDid string 57 - OwnerHandle string 58 - Description string 59 - Knot string 60 - Spindle string 61 - RepoAt syntax.ATURI 62 - IsStarred bool 63 - Stats models.RepoStats 64 - Roles RolesInRepo 65 - Source *models.Repo 66 - SourceHandle string 67 - Ref string 68 - DisableFork bool 69 - CurrentDir string 58 + Name string 59 + Rkey string 60 + OwnerDid string 61 + OwnerHandle string 62 + Description string 63 + Website string 64 + Topics []string 65 + Knot string 66 + Spindle string 67 + IsStarred bool 68 + Stats models.RepoStats 69 + Roles RolesInRepo 70 + Source *models.Repo 71 + Ref string 72 + CurrentDir string 70 73 } 71 74 72 75 // each tab on a repo could have some metadata:
+1 -1
appview/pages/templates/banner.html
··· 30 30 <div class="mx-6"> 31 31 These services may not be fully accessible until upgraded. 32 32 <a class="underline text-red-800 dark:text-red-200" 33 - href="https://tangled.org/@tangled.org/core/tree/master/docs/migrations.md"> 33 + href="https://docs.tangled.org/migrating-knots-spindles.html#migrating-knots-spindles"> 34 34 Click to read the upgrade guide</a>. 35 35 </div> 36 36 </details>
+9 -29
appview/pages/templates/brand/brand.html
··· 4 4 <div class="grid grid-cols-10"> 5 5 <header class="col-span-full md:col-span-10 px-6 py-2 mb-4"> 6 6 <h1 class="text-2xl font-bold dark:text-white mb-1">Brand</h1> 7 - <p class="text-gray-600 dark:text-gray-400 mb-1"> 7 + <p class="text-gray-500 dark:text-gray-300 mb-1"> 8 8 Assets and guidelines for using Tangled's logo and brand elements. 9 9 </p> 10 10 </header> ··· 14 14 15 15 <!-- Introduction Section --> 16 16 <section> 17 - <p class="text-gray-600 dark:text-gray-400 mb-2"> 17 + <p class="text-gray-500 dark:text-gray-300 mb-2"> 18 18 Tangled's logo and mascot is <strong>Dolly</strong>, the first ever <em>cloned</em> mammal. Please 19 19 follow the below guidelines when using Dolly and the logotype. 20 20 </p> 21 - <p class="text-gray-600 dark:text-gray-400 mb-2"> 21 + <p class="text-gray-500 dark:text-gray-300 mb-2"> 22 22 All assets are served as SVGs, and can be downloaded by right-clicking and clicking "Save image as". 23 23 </p> 24 24 </section> ··· 34 34 </div> 35 35 <div class="order-1 lg:order-2"> 36 36 <h2 class="text-xl font-semibold dark:text-white mb-3">Black logotype</h2> 37 - <p class="text-gray-600 dark:text-gray-400 mb-4">For use on light-colored backgrounds.</p> 37 + <p class="text-gray-500 dark:text-gray-300 mb-4">For use on light-colored backgrounds.</p> 38 38 <p class="text-gray-700 dark:text-gray-300"> 39 39 This is the preferred version of the logotype, featuring dark text and elements, ideal for light 40 40 backgrounds and designs. ··· 53 53 </div> 54 54 <div class="order-1 lg:order-2"> 55 55 <h2 class="text-xl font-semibold dark:text-white mb-3">White logotype</h2> 56 - <p class="text-gray-600 dark:text-gray-400 mb-4">For use on dark-colored backgrounds.</p> 56 + <p class="text-gray-500 dark:text-gray-300 mb-4">For use on dark-colored backgrounds.</p> 57 57 <p class="text-gray-700 dark:text-gray-300"> 58 58 This version features white text and elements, ideal for dark backgrounds 59 59 and inverted designs. ··· 81 81 </div> 82 82 <div class="order-1 lg:order-2"> 83 83 <h2 class="text-xl font-semibold dark:text-white mb-3">Mark only</h2> 84 - <p class="text-gray-600 dark:text-gray-400 mb-4"> 84 + <p class="text-gray-500 dark:text-gray-300 mb-4"> 85 85 When a smaller 1:1 logo or icon is needed, Dolly's face may be used on its own. 86 86 </p> 87 87 <p class="text-gray-700 dark:text-gray-300 mb-4"> ··· 123 123 </div> 124 124 <div class="order-1 lg:order-2"> 125 125 <h2 class="text-xl font-semibold dark:text-white mb-3">Colored backgrounds</h2> 126 - <p class="text-gray-600 dark:text-gray-400 mb-4"> 126 + <p class="text-gray-500 dark:text-gray-300 mb-4"> 127 127 White logo mark on colored backgrounds. 128 128 </p> 129 129 <p class="text-gray-700 dark:text-gray-300 mb-4"> ··· 165 165 </div> 166 166 <div class="order-1 lg:order-2"> 167 167 <h2 class="text-xl font-semibold dark:text-white mb-3">Lighter backgrounds</h2> 168 - <p class="text-gray-600 dark:text-gray-400 mb-4"> 168 + <p class="text-gray-500 dark:text-gray-300 mb-4"> 169 169 Dark logo mark on lighter, pastel backgrounds. 170 170 </p> 171 171 <p class="text-gray-700 dark:text-gray-300 mb-4"> ··· 186 186 </div> 187 187 <div class="order-1 lg:order-2"> 188 188 <h2 class="text-xl font-semibold dark:text-white mb-3">Recoloring</h2> 189 - <p class="text-gray-600 dark:text-gray-400 mb-4"> 189 + <p class="text-gray-500 dark:text-gray-300 mb-4"> 190 190 Custom coloring of the logotype is permitted. 191 191 </p> 192 192 <p class="text-gray-700 dark:text-gray-300 mb-4"> ··· 194 194 </p> 195 195 <p class="text-gray-700 dark:text-gray-300 text-sm"> 196 196 <strong>Example:</strong> Gray/sand colored logotype on a light yellow/tan background. 197 - </p> 198 - </div> 199 - </section> 200 - 201 - <!-- Silhouette Section --> 202 - <section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center"> 203 - <div class="order-2 lg:order-1"> 204 - <div class="border border-gray-200 dark:border-gray-700 p-8 sm:p-16 bg-gray-50 dark:bg-gray-100 rounded"> 205 - <img src="https://assets.tangled.network/tangled_dolly_silhouette.svg" 206 - alt="Dolly silhouette" 207 - class="w-full max-w-32 mx-auto" /> 208 - </div> 209 - </div> 210 - <div class="order-1 lg:order-2"> 211 - <h2 class="text-xl font-semibold dark:text-white mb-3">Dolly silhouette</h2> 212 - <p class="text-gray-600 dark:text-gray-400 mb-4">A minimalist version of Dolly.</p> 213 - <p class="text-gray-700 dark:text-gray-300"> 214 - The silhouette can be used where a subtle brand presence is needed, 215 - or as a background element. Works on any background color with proper contrast. 216 - For example, we use this as the site's favicon. 217 197 </p> 218 198 </div> 219 199 </section>
+94 -54
appview/pages/templates/fragments/dolly/logo.html
··· 1 1 {{ define "fragments/dolly/logo" }} 2 - <svg 3 - version="1.1" 4 - id="svg1" 5 - class="{{.}}" 6 - width="25" 7 - height="25" 8 - viewBox="0 0 25 25" 9 - sodipodi:docname="tangled_dolly_face_only.png" 10 - xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" 11 - xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" 12 - xmlns:xlink="http://www.w3.org/1999/xlink" 13 - xmlns="http://www.w3.org/2000/svg" 14 - xmlns:svg="http://www.w3.org/2000/svg"> 15 - <title>Dolly</title> 16 - <defs 17 - id="defs1" /> 18 - <sodipodi:namedview 19 - id="namedview1" 20 - pagecolor="#ffffff" 21 - bordercolor="#000000" 22 - borderopacity="0.25" 23 - inkscape:showpageshadow="2" 24 - inkscape:pageopacity="0.0" 25 - inkscape:pagecheckerboard="true" 26 - inkscape:deskcolor="#d5d5d5"> 27 - <inkscape:page 28 - x="0" 29 - y="0" 30 - width="25" 31 - height="25" 32 - id="page2" 33 - margin="0" 34 - bleed="0" /> 35 - </sodipodi:namedview> 36 - <g 37 - inkscape:groupmode="layer" 38 - inkscape:label="Image" 39 - id="g1"> 40 - <image 41 - width="252.48" 42 - height="248.96001" 43 - preserveAspectRatio="none" 44 - xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAxUAAAMKCAYAAADznWlEAAABg2lDQ1BJQ0MgcHJvZmlsZQAAKJF9&#10;kT1Iw0AcxV9TpVoqDmYQcchQneyiIo61CkWoEGqFVh1MLv2CJi1Jiouj4Fpw8GOx6uDirKuDqyAI&#10;foC4C06KLlLi/5JCixgPjvvx7t7j7h0gNCtMt3rigG7YZjqZkLK5VSn0in6EISKGsMKs2pwsp+A7&#10;vu4R4OtdjGf5n/tzDGh5iwEBiTjOaqZNvEE8s2nXOO8Ti6ykaMTnxBMmXZD4keuqx2+ciy4LPFM0&#10;M+l5YpFYKnax2sWsZOrE08RRTTcoX8h6rHHe4qxX6qx9T/7CSN5YWeY6zVEksYglyJCgoo4yKrCp&#10;rzIMUiykaT/h4x9x/TK5VHKVwcixgCp0KK4f/A9+d2sVpia9pEgC6H1xnI8xILQLtBqO833sOK0T&#10;IPgMXBkdf7UJzH6S3uho0SNgcBu4uO5o6h5wuQMMP9UUU3GlIE2hUADez+ibcsDQLRBe83pr7+P0&#10;AchQV6kb4OAQGC9S9rrPu/u6e/v3TLu/H4tGcrDFxPPTAAAABmJLR0QAAAAAAAD5Q7t/AAAACXBI&#10;WXMAAC4jAAAuIwF4pT92AAAAB3RJTUUH6QkPFQ8jl/6e6wAAABl0RVh0Q29tbWVudABDcmVhdGVk&#10;IHdpdGggR0lNUFeBDhcAACAASURBVHic7N3nc5xXmqb564UjCXoripShvKNMqVSSynWXmZ6emd39&#10;NhH7R+6HjdiN6d3emZ7uru6ukqpKXUbeUKIMJZKitwBhzn64z1uZpEiJIAGkea9fRAZIgCCTQCLz&#10;3O95nueAJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmS&#10;JEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmS&#10;JEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmS&#10;JEmSJEmSJEmSJEmSJOkONYO+A5IkjYtSSgNM0Ht9LX036vvbj7fvX+77K/pfl0vTNAVJGgGGCkmS&#10;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&#10;U2QU7On6+9OkT6L5/hwwWxRFZ13erbQJGVRIknSFpqampn7961/vuf/++/ft27fvjsnJyVvKsrxp&#10;YWFh79LS0r52u33L2NjY9vHx8ZGhoSGSyNBVWgYuAB8Dh0h50wfACRJAnCc7J+bsmZDWj1c5SZKu&#10;0muvvVa0Wq3ij3/84/D09PT2bdu23XPnnXd+7Y477nhg7969e3fv3r1lbGxsBBiqqmq4LMtxYKrd&#10;bt/QarXGi6Jwq3eUZI/EMslILJKdEvMk63AceIfsljhMMhKz9d9f4m4bad05zkKSpKv06KOPVkA1&#10;MjKy9KMf/ejM+Pj4+bIs36iqarjT6QzNzMwMTU9PD09PT08uLCzsHB0dvWPbtm1f3bVr1wOTk5P7&#10;hoeHd5CSqWbpXtME3qLu1+j5vp97Nio+2wfRvJpAYh64RHZJNFuuT5JsxCf199Ok/Gmp/nMGEtIG&#10;0q8XJ0mS+snQD3/4w5Ef//jHE/fdd9/Wffv2TW3ZsmViaGhotCzL4bIsR1qt1mir1RpvtVpbgJ3A&#10;rcC++utNwBSfDUD6Qe9kpjlSxnSW9D+crl9n69dFElhcIlmIebKUbqH+8ys2XEsbl0GFJEnrr7V/&#10;//6he++9d2R8fHyCBBA3kuBiB7ANGCdlVC2gKMuyqKqqVZZlM962KIqiaLVaFEXRvJrMRxsYrf8Z&#10;k/XXYYCyLKuyLKmqqirLsvefUbXb7SaT0DQ8T9TvZ0/93qZI1UNJt1zpEgkQLtSvi6Tv4QIpZTpf&#10;f21+b5YEHIskC2HwIPUhgwpJkvpPb5lUqyiK1k9/+tPioYceKu6++2727dvH1q1bi8nJyWJoaKhF&#10;HvzHSRAwBdxAPalqaWmJxcXFanl5uVpZWSlarVZreHiYkZGRamxsbLlufl6u/503ALcAdwN3kgzK&#10;KAkoLvKPWYhpulmIWbpZhw4JRD77H2U5k9S37KmQJKn/VEDnBz/4QTk1NcXu3bu5/fbb2b59O+Pj&#10;4wwNDdFkLGoFyRAUn3v9g38yqar3Qb8gQcRWkknZTrIgcyRDMUMChyVghbr3ge5uiQp7IaSBZKZC&#10;kiR9aVVVNVmSofpV0G267gAdgwZJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ&#10;kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ&#10;kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ&#10;kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ&#10;kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ&#10;kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ&#10;kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJktbC&#10;/wcO9A7eMaXQEQAAAABJRU5ErkJggg==&#10;" 45 - id="image1" 46 - x="-233.6257" 47 - y="10.383364" 48 - style="display:none" /> 49 - <path 50 - fill="currentColor" 51 - style="stroke-width:0.111183" 52 - d="m 16.348974,24.09935 -0.06485,-0.03766 -0.202005,-0.0106 -0.202008,-0.01048 -0.275736,-0.02601 -0.275734,-0.02602 v -0.02649 -0.02648 l -0.204577,-0.04019 -0.204578,-0.04019 -0.167616,-0.08035 -0.167617,-0.08035 -0.0014,-0.04137 -0.0014,-0.04137 -0.266473,-0.143735 -0.266475,-0.143735 -0.276098,-0.20335 -0.2761,-0.203347 -0.262064,-0.251949 -0.262064,-0.25195 -0.22095,-0.284628 -0.220948,-0.284629 -0.170253,-0.284631 -0.170252,-0.284628 -0.01341,-0.0144 -0.0134,-0.0144 -0.141982,0.161297 -0.14198,0.1613 -0.22313,0.21426 -0.223132,0.214264 -0.186025,0.146053 -0.186023,0.14605 -0.252501,0.163342 -0.252502,0.163342 -0.249014,0.115348 -0.249013,0.115336 0.0053,0.03241 0.0053,0.03241 -0.1716725,0.04599 -0.171669,0.046 -0.3379966,0.101058 -0.3379972,0.101058 -0.1778925,0.04506 -0.1778935,0.04508 -0.3913655,0.02601 -0.3913643,0.02603 -0.3557868,-0.03514 -0.3557863,-0.03514 -0.037426,-0.03029 -0.037427,-0.03029 -0.076924,0.02011 -0.076924,0.02011 -0.050508,-0.05051 -0.050405,-0.05056 L 6.6604532,23.110188 6.451745,23.063961 6.1546135,22.960559 5.8574835,22.857156 5.5319879,22.694039 5.2064938,22.530922 4.8793922,22.302961 4.5522905,22.075005 4.247598,21.786585 3.9429055,21.49817 3.7185335,21.208777 3.4941628,20.919385 3.3669822,20.705914 3.239803,20.492443 3.1335213,20.278969 3.0272397,20.065499 2.9015252,19.7275 2.7758105,19.389504 2.6925225,18.998139 2.6092345,18.606774 2.6096814,17.91299 2.6101284,17.219208 2.6744634,16.90029 2.7387984,16.581374 2.8474286,16.242088 2.9560588,15.9028 3.1137374,15.583492 3.2714148,15.264182 3.3415068,15.150766 3.4115988,15.03735 3.3127798,14.96945 3.2139618,14.90157 3.0360685,14.800239 2.8581753,14.698908 2.5913347,14.503228 2.3244955,14.307547 2.0621238,14.055599 1.7997507,13.803651 1.6111953,13.56878 1.4226411,13.333906 1.2632237,13.087474 1.1038089,12.841042 0.97442,12.575195 0.8450307,12.30935 0.724603,11.971351 0.6041766,11.633356 0.52150365,11.241991 0.4388285,10.850626 0.44091592,10.156842 0.44300333,9.4630594 0.54235911,9.0369608 0.6417149,8.6108622 0.7741173,8.2694368 0.9065196,7.9280115 1.0736303,7.6214262 1.2407515,7.3148397 1.45931,7.0191718 1.6778685,6.7235039 1.9300326,6.4611321 2.1821966,6.1987592 2.4134579,6.0137228 2.6447193,5.8286865 2.8759792,5.6776409 3.1072406,5.526594 3.4282004,5.3713977 3.7491603,5.2162016 3.9263009,5.1508695 4.1034416,5.0855373 4.2813348,4.7481598 4.4592292,4.4107823 4.6718,4.108422 4.8843733,3.8060618 5.198353,3.4805372 5.5123313,3.155014 5.7685095,2.9596425 6.0246877,2.7642722 6.329187,2.5851365 6.6336863,2.406002 6.9497657,2.2751596 7.2658453,2.1443184 7.4756394,2.0772947 7.6854348,2.01027 8.0825241,1.931086 8.4796139,1.851902 l 0.5870477,0.00291 0.5870469,0.00291 0.4447315,0.092455 0.444734,0.092455 0.302419,0.1105495 0.302417,0.1105495 0.329929,0.1646046 0.32993,0.1646033 0.239329,-0.2316919 0.239329,-0.2316919 0.160103,-0.1256767 0.160105,-0.1256767 0.160102,-0.1021909 0.160105,-0.1021899 0.142315,-0.082328 0.142314,-0.082328 0.231262,-0.1090091 0.231259,-0.1090091 0.26684,-0.098743 0.266839,-0.098743 0.320208,-0.073514 0.320209,-0.073527 0.355787,-0.041833 0.355785,-0.041834 0.426942,0.023827 0.426945,0.023828 0.355785,0.071179 0.355788,0.0711791 0.284627,0.09267 0.284629,0.09267 0.28514,0.1310267 0.28514,0.1310255 0.238179,0.1446969 0.238174,0.1446979 0.259413,0.1955332 0.259413,0.1955319 0.290757,0.296774 0.290758,0.2967753 0.151736,0.1941581 0.151734,0.1941594 0.135326,0.2149951 0.135327,0.2149952 0.154755,0.3202073 0.154758,0.3202085 0.09409,0.2677358 0.09409,0.267737 0.06948,0.3319087 0.06948,0.3319099 0.01111,0.00808 0.01111,0.00808 0.444734,0.2173653 0.444734,0.2173665 0.309499,0.2161102 0.309497,0.2161101 0.309694,0.2930023 0.309694,0.2930037 0.18752,0.2348726 0.187524,0.2348727 0.166516,0.2574092 0.166519,0.2574108 0.15273,0.3260252 0.152734,0.3260262 0.08972,0.2668403 0.08971,0.2668391 0.08295,0.3913655 0.08295,0.3913652 -6.21e-4,0.6582049 -6.21e-4,0.658204 -0.06362,0.315725 -0.06362,0.315725 -0.09046,0.289112 -0.09046,0.289112 -0.122759,0.281358 -0.12276,0.281356 -0.146626,0.252323 -0.146629,0.252322 -0.190443,0.258668 -0.190448,0.258671 -0.254911,0.268356 -0.254911,0.268355 -0.286872,0.223127 -0.286874,0.223127 -0.320203,0.187693 -0.320209,0.187693 -0.04347,0.03519 -0.04347,0.03521 0.0564,0.12989 0.0564,0.129892 0.08728,0.213472 0.08728,0.213471 0.189755,0.729363 0.189753,0.729362 0.0652,0.302417 0.0652,0.302419 -0.0018,0.675994 -0.0018,0.675995 -0.0801,0.373573 -0.08009,0.373577 -0.09,0.266839 -0.09,0.26684 -0.190389,0.391364 -0.19039,0.391366 -0.223169,0.320207 -0.223167,0.320209 -0.303585,0.315294 -0.303584,0.315291 -0.284631,0.220665 -0.284629,0.220663 -0.220128,0.132359 -0.220127,0.132358 -0.242395,0.106698 -0.242394,0.106699 -0.08895,0.04734 -0.08895,0.04733 -0.249052,0.07247 -0.24905,0.07247 -0.322042,0.0574 -0.322044,0.0574 -0.282794,-0.003 -0.282795,-0.003 -0.07115,-0.0031 -0.07115,-0.0031 -0.177894,-0.0033 -0.177893,-0.0033 -0.124528,0.02555 -0.124528,0.02555 z m -4.470079,-5.349839 0.214838,-0.01739 0.206601,-0.06782 0.206602,-0.06782 0.244389,-0.117874 0.244393,-0.11786 0.274473,-0.206822 0.27447,-0.20682 0.229308,-0.257201 0.229306,-0.2572 0.219161,-0.28463 0.219159,-0.284629 0.188541,-0.284628 0.188543,-0.28463 0.214594,-0.373574 0.214593,-0.373577 0.133861,-0.312006 0.133865,-0.312007 0.02861,-0.01769 0.02861,-0.01769 0.197275,0.26212 0.197278,0.262119 0.163613,0.150814 0.163614,0.150814 0.201914,0.09276 0.201914,0.09276 0.302417,0.01421 0.302418,0.01421 0.213472,-0.08025 0.213471,-0.08025 0.200606,-0.204641 0.200606,-0.204642 0.09242,-0.278887 0.09241,-0.278888 0.05765,-0.302418 0.05764,-0.302416 L 18.41327,13.768114 18.39502,13.34117 18.31849,12.915185 18.24196,12.4892 18.15595,12.168033 18.06994,11.846867 17.928869,11.444534 17.787801,11.042201 17.621278,10.73296 17.454757,10.423723 17.337388,10.263619 17.220021,10.103516 17.095645,9.9837986 16.971268,9.8640816 16.990048,9.6813736 17.008828,9.4986654 16.947568,9.249616 16.886308,9.0005655 16.752419,8.7159355 16.618521,8.4313217 16.435707,8.2294676 16.252892,8.0276114 16.079629,7.9004245 15.906366,7.773238 l -0.20429,0.1230127 -0.204289,0.1230121 -0.26702,0.059413 -0.267022,0.059413 -0.205761,-0.021508 -0.205766,-0.021508 -0.23495,-0.08844 -0.234953,-0.08844 -0.118429,-0.090334 -0.118428,-0.090333 h -0.03944 -0.03944 L 13.711268,7.8540732 13.655958,7.9706205 13.497227,8.1520709 13.338499,8.3335203 13.168394,8.4419112 12.998289,8.550301 12.777045,8.624223 12.5558,8.698155 H 12.275611 11.995429 L 11.799973,8.6309015 11.604513,8.5636472 11.491311,8.5051061 11.37811,8.446565 11.138172,8.2254579 10.898231,8.0043497 l -0.09565,-0.084618 -0.09565,-0.084613 -0.218822,0.198024 -0.218822,0.1980231 -0.165392,0.078387 -0.1653925,0.078387 -0.177894,0.047948 -0.177892,0.047948 L 9.3635263,8.4842631 9.144328,8.4846889 8.9195029,8.4147138 8.6946778,8.3447386 8.5931214,8.4414036 8.491565,8.5380686 8.3707618,8.7019598 8.2499597,8.8658478 8.0802403,8.9290726 7.9105231,8.9922974 7.7952769,9.0780061 7.6800299,9.1637148 7.5706169,9.2778257 7.4612038,9.3919481 7.1059768,9.9205267 6.7507497,10.449105 l -0.2159851,0.449834 -0.2159839,0.449834 -0.2216572,0.462522 -0.2216559,0.462523 -0.1459343,0.337996 -0.1459342,0.337998 -0.055483,0.220042 -0.055483,0.220041 -0.015885,0.206903 -0.015872,0.206901 0.034307,0.242939 0.034307,0.24294 0.096281,0.196632 0.096281,0.196634 0.143607,0.125222 0.1436071,0.125222 0.1873143,0.08737 0.1873141,0.08737 0.2752084,0.002 0.2752084,0.002 0.2312297,-0.09773 0.231231,-0.09772 0.1067615,-0.07603 0.1067614,-0.07603 0.3679062,-0.29377 0.3679065,-0.293771 0.026804,0.01656 0.026804,0.01656 0.023626,0.466819 0.023626,0.466815 0.088326,0.513195 0.088326,0.513193 0.08897,0.364413 0.08897,0.364411 0.1315362,0.302418 0.1315352,0.302418 0.1051964,0.160105 0.1051954,0.160103 0.1104741,0.11877 0.1104731,0.118769 0.2846284,0.205644 0.2846305,0.205642 0.144448,0.07312 0.144448,0.07312 0.214787,0.05566 0.214787,0.05566 0.245601,0.03075 0.245602,0.03075 0.204577,-0.0125 0.204578,-0.0125 z m 0.686342,-3.497495 -0.11281,-0.06077 -0.106155,-0.134033 -0.106155,-0.134031 -0.04406,-0.18371 -0.04406,-0.183707 0.02417,-0.553937 0.02417,-0.553936 0.03513,-0.426945 0.03513,-0.426942 0.07225,-0.373576 0.07225,-0.373575 0.05417,-0.211338 0.05417,-0.211339 0.0674,-0.132112 0.0674,-0.132112 0.132437,-0.10916 0.132437,-0.109161 0.187436,-0.04195 0.187438,-0.04195 0.170366,0.06469 0.170364,0.06469 0.114312,0.124073 0.114313,0.124086 0.04139,0.18495 0.04139,0.184951 -0.111218,0.459845 -0.111219,0.459844 -0.03383,0.26584 -0.03382,0.265841 -0.03986,0.818307 -0.03986,0.818309 -0.0378,0.15162 -0.03779,0.151621 -0.11089,0.110562 -0.110891,0.110561 -0.114489,0.04913 -0.114489,0.04913 -0.187932,-0.0016 -0.187929,-0.0016 z m -2.8087655,-0.358124 -0.146445,-0.06848 -0.088025,-0.119502 -0.088024,-0.119502 -0.038581,-0.106736 -0.038581,-0.106736 -0.02237,-0.134956 -0.02239,-0.134957 -0.031955,-0.46988 -0.031955,-0.469881 0.036203,-0.444733 0.036203,-0.444731 0.048862,-0.215257 0.048862,-0.215255 0.076082,-0.203349 0.076081,-0.203348 0.0936,-0.111244 0.0936,-0.111245 0.143787,-0.06531 0.1437865,-0.06532 h 0.142315 0.142314 l 0.142314,0.06588 0.142316,0.06588 0.093,0.102325 0.093,0.102325 0.04042,0.120942 0.04042,0.120942 v 0.152479 0.152477 l -0.03347,0.08804 -0.03347,0.08805 -0.05693,0.275653 -0.05693,0.275651 2.11e-4,0.430246 2.12e-4,0.430243 0.04294,0.392646 0.04295,0.392647 -0.09189,0.200702 -0.09189,0.200702 -0.148688,0.0984 -0.148687,0.0984 -0.20136,0.01212 -0.2013595,0.01212 z" 53 - id="path4" /> 54 - </g> 55 - </svg> 2 + <svg 3 + version="1.1" 4 + id="svg1" 5 + class="{{ .Classes }}" 6 + width="25" 7 + height="25" 8 + viewBox="0 0 25 25" 9 + sodipodi:docname="tangled_dolly_face_only_black_on_trans.svg" 10 + inkscape:export-filename="tangled_logotype_black_on_trans.svg" 11 + inkscape:export-xdpi="96" 12 + inkscape:export-ydpi="96" 13 + inkscape:version="1.4 (e7c3feb100, 2024-10-09)" 14 + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" 15 + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" 16 + xmlns="http://www.w3.org/2000/svg" 17 + xmlns:svg="http://www.w3.org/2000/svg" 18 + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" 19 + xmlns:cc="http://creativecommons.org/ns#"> 20 + <style> 21 + .dolly { 22 + color: #000000; 23 + } 24 + 25 + @media (prefers-color-scheme: dark) { 26 + .dolly { 27 + color: #ffffff; 28 + } 29 + } 30 + </style> 31 + <sodipodi:namedview 32 + id="namedview1" 33 + pagecolor="#ffffff" 34 + bordercolor="#000000" 35 + borderopacity="0.25" 36 + inkscape:showpageshadow="2" 37 + inkscape:pageopacity="0.0" 38 + inkscape:pagecheckerboard="true" 39 + inkscape:deskcolor="#d5d5d5" 40 + inkscape:zoom="45.254834" 41 + inkscape:cx="3.1377863" 42 + inkscape:cy="8.9382717" 43 + inkscape:window-width="3840" 44 + inkscape:window-height="2160" 45 + inkscape:window-x="0" 46 + inkscape:window-y="0" 47 + inkscape:window-maximized="0" 48 + inkscape:current-layer="g1" 49 + borderlayer="true"> 50 + <inkscape:page 51 + x="0" 52 + y="0" 53 + width="25" 54 + height="25" 55 + id="page2" 56 + margin="0" 57 + bleed="0" /> 58 + </sodipodi:namedview> 59 + <g 60 + inkscape:groupmode="layer" 61 + inkscape:label="Image" 62 + id="g1" 63 + transform="translate(-0.42924038,-0.87777209)"> 64 + <path 65 + class="dolly" 66 + fill="{{ or .FillColor "currentColor" }}" 67 + style="stroke-width:0.111183;" 68 + d="m 16.775491,24.987061 c -0.78517,-0.0064 -1.384202,-0.234614 -2.033994,-0.631295 -0.931792,-0.490188 -1.643475,-1.31368 -2.152014,-2.221647 C 11.781409,23.136647 10.701392,23.744942 9.4922931,24.0886 8.9774725,24.238111 8.0757679,24.389777 6.5811304,23.84827 4.4270703,23.124679 2.8580086,20.883331 3.0363279,18.599583 3.0037061,17.652919 3.3488675,16.723769 3.8381157,15.925061 2.5329485,15.224503 1.4686756,14.048584 1.0611184,12.606459 0.81344502,11.816973 0.82385989,10.966486 0.91519098,10.154906 1.2422711,8.2387903 2.6795811,6.5725716 4.5299585,5.9732484 5.2685364,4.290122 6.8802592,3.0349975 8.706276,2.7794663 c 1.2124148,-0.1688264 2.46744,0.084987 3.52811,0.7011837 1.545426,-1.7139736 4.237779,-2.2205077 6.293579,-1.1676231 1.568222,0.7488935 2.689625,2.3113526 2.961888,4.0151464 1.492195,0.5977882 2.749007,1.8168898 3.242225,3.3644951 0.329805,0.9581836 0.340709,2.0135956 0.127128,2.9974286 -0.381606,1.535184 -1.465322,2.842146 -2.868035,3.556463 0.0034,0.273204 0.901506,2.243045 0.751284,3.729647 -0.03281,1.858525 -1.211631,3.619894 -2.846433,4.475452 -0.953967,0.556812 -2.084452,0.546309 -3.120531,0.535398 z m -4.470079,-5.349839 c 1.322246,-0.147248 2.189053,-1.300106 2.862307,-2.338363 0.318287,-0.472954 0.561404,-1.002348 0.803,-1.505815 0.313265,0.287151 0.578698,0.828085 1.074141,0.956909 0.521892,0.162542 1.133743,0.03052 1.45325,-0.443554 0.611414,-1.140449 0.31004,-2.516537 -0.04602,-3.698347 C 18.232844,11.92927 17.945151,11.232927 17.397785,10.751793 17.514522,9.9283111 17.026575,9.0919791 16.332883,8.6609491 15.741721,9.1323278 14.842258,9.1294949 14.271975,8.6252369 13.178927,9.7400102 12.177239,9.7029996 11.209704,8.8195135 10.992255,8.6209543 10.577326,10.031484 9.1211947,9.2324497 8.2846288,9.9333947 7.6359672,10.607693 7.0611981,11.578553 6.5026891,12.62523 5.9177873,13.554793 5.867393,14.69141 c -0.024234,0.66432 0.4948601,1.360337 1.1982269,1.306329 0.702996,0.06277 1.1815208,-0.629091 1.7138087,-0.916491 0.079382,0.927141 0.1688108,1.923227 0.4821259,2.828358 0.3596254,1.171275 1.6262605,1.915695 2.8251855,1.745211 0.08481,-0.0066 0.218672,-0.01769 0.218672,-0.0176 z m 0.686342,-3.497495 c -0.643126,-0.394168 -0.33365,-1.249599 -0.359402,-1.870938 0.064,-0.749774 0.115321,-1.538054 0.452402,-2.221125 0.356724,-0.487008 1.226721,-0.299139 1.265134,0.325689 -0.02558,0.628509 -0.314101,1.25416 -0.279646,1.9057 -0.07482,0.544043 0.05418,1.155133 -0.186476,1.652391 -0.197455,0.275121 -0.599638,0.355105 -0.892012,0.208283 z m -2.808766,-0.358124 c -0.605767,-0.328664 -0.4133176,-1.155655 -0.5083256,-1.73063 0.078762,-0.66567 0.013203,-1.510085 0.5705316,-1.976886 0.545037,-0.380109 1.286917,0.270803 1.029164,0.868384 -0.274913,0.755214 -0.09475,1.580345 -0.08893,2.34609 -0.104009,0.451702 -0.587146,0.691508 -1.002445,0.493042 z" 69 + id="path4" 70 + sodipodi:nodetypes="sccccccccccccccccccsscccccccccsccccccccccccccccccccccc" /> 71 + </g> 72 + <metadata 73 + id="metadata1"> 74 + <rdf:RDF> 75 + <cc:Work 76 + rdf:about=""> 77 + <cc:license 78 + rdf:resource="http://creativecommons.org/licenses/by/4.0/" /> 79 + </cc:Work> 80 + <cc:License 81 + rdf:about="http://creativecommons.org/licenses/by/4.0/"> 82 + <cc:permits 83 + rdf:resource="http://creativecommons.org/ns#Reproduction" /> 84 + <cc:permits 85 + rdf:resource="http://creativecommons.org/ns#Distribution" /> 86 + <cc:requires 87 + rdf:resource="http://creativecommons.org/ns#Notice" /> 88 + <cc:requires 89 + rdf:resource="http://creativecommons.org/ns#Attribution" /> 90 + <cc:permits 91 + rdf:resource="http://creativecommons.org/ns#DerivativeWorks" /> 92 + </cc:License> 93 + </rdf:RDF> 94 + </metadata> 95 + </svg> 56 96 {{ end }}
-57
appview/pages/templates/fragments/dolly/silhouette.html
··· 1 - {{ define "fragments/dolly/silhouette" }} 2 - <svg 3 - version="1.1" 4 - id="svg1" 5 - width="32" 6 - height="32" 7 - viewBox="0 0 25 25" 8 - sodipodi:docname="tangled_dolly_silhouette.png" 9 - xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" 10 - xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" 11 - xmlns="http://www.w3.org/2000/svg" 12 - xmlns:svg="http://www.w3.org/2000/svg"> 13 - <style> 14 - .dolly { 15 - color: #000000; 16 - } 17 - 18 - @media (prefers-color-scheme: dark) { 19 - .dolly { 20 - color: #ffffff; 21 - } 22 - } 23 - </style> 24 - <title>Dolly</title> 25 - <defs 26 - id="defs1" /> 27 - <sodipodi:namedview 28 - id="namedview1" 29 - pagecolor="#ffffff" 30 - bordercolor="#000000" 31 - borderopacity="0.25" 32 - inkscape:showpageshadow="2" 33 - inkscape:pageopacity="0.0" 34 - inkscape:pagecheckerboard="true" 35 - inkscape:deskcolor="#d1d1d1"> 36 - <inkscape:page 37 - x="0" 38 - y="0" 39 - width="25" 40 - height="25" 41 - id="page2" 42 - margin="0" 43 - bleed="0" /> 44 - </sodipodi:namedview> 45 - <g 46 - inkscape:groupmode="layer" 47 - inkscape:label="Image" 48 - id="g1"> 49 - <path 50 - class="dolly" 51 - fill="currentColor" 52 - style="stroke-width:1.12248" 53 - d="m 16.208435,23.914069 c -0.06147,-0.02273 -0.147027,-0.03034 -0.190158,-0.01691 -0.197279,0.06145 -1.31068,-0.230493 -1.388819,-0.364153 -0.01956,-0.03344 -0.163274,-0.134049 -0.319377,-0.223561 -0.550395,-0.315603 -1.010951,-0.696643 -1.428383,-1.181771 -0.264598,-0.307509 -0.597257,-0.785384 -0.597257,-0.857979 0,-0.0216 -0.02841,-0.06243 -0.06313,-0.0907 -0.04977,-0.04053 -0.160873,0.0436 -0.52488,0.397463 -0.479803,0.466432 -0.78924,0.689475 -1.355603,0.977118 -0.183693,0.0933 -0.323426,0.179989 -0.310516,0.192658 0.02801,0.02748 -0.7656391,0.270031 -1.209129,0.369517 -0.5378332,0.120647 -1.6341809,0.08626 -1.9721503,-0.06186 C 6.7977157,23.031391 6.56735,22.957551 6.3371134,22.889782 4.9717169,22.487902 3.7511914,21.481518 3.1172396,20.234838 2.6890391,19.392772 2.5582276,18.827446 2.5610489,17.831154 2.5639589,16.802192 2.7366641,16.125844 3.2142117,15.273187 3.3040457,15.112788 3.3713143,14.976533 3.3636956,14.9704 3.3560756,14.9643 3.2459634,14.90305 3.1189994,14.834381 1.7582586,14.098312 0.77760984,12.777439 0.44909837,11.23818 0.33531456,10.705039 0.33670119,9.7067968 0.45195381,9.1778795 0.72259241,7.9359287 1.3827188,6.8888436 2.4297498,6.0407205 2.6856126,5.8334648 3.2975489,5.4910878 3.6885849,5.3364049 L 4.0584319,5.190106 4.2333984,4.860432 C 4.8393906,3.7186139 5.8908314,2.7968028 7.1056396,2.3423025 7.7690673,2.0940921 8.2290216,2.0150935 9.01853,2.0137575 c 0.9625627,-0.00163 1.629181,0.1532762 2.485864,0.5776514 l 0.271744,0.1346134 0.42911,-0.3607688 c 1.082666,-0.9102346 2.185531,-1.3136811 3.578383,-1.3090327 0.916696,0.00306 1.573918,0.1517893 2.356121,0.5331927 1.465948,0.7148 2.54506,2.0625628 2.865177,3.57848 l 0.07653,0.362429 0.515095,0.2556611 c 1.022872,0.5076874 1.756122,1.1690944 2.288361,2.0641468 0.401896,0.6758594 0.537303,1.0442682 0.675505,1.8378683 0.288575,1.6570823 -0.266229,3.3548023 -1.490464,4.5608743 -0.371074,0.36557 -0.840205,0.718265 -1.203442,0.904754 -0.144112,0.07398 -0.271303,0.15826 -0.282647,0.187269 -0.01134,0.02901 0.02121,0.142764 0.07234,0.25279 0.184248,0.396467 0.451371,1.331823 0.619371,2.168779 0.463493,2.30908 -0.754646,4.693707 -2.92278,5.721632 -0.479538,0.227352 -0.717629,0.309322 -1.144194,0.39393 -0.321869,0.06383 -1.850573,0.09139 -2.000174,0.03604 z M 12.25443,18.636956 c 0.739923,-0.24652 1.382521,-0.718922 1.874623,-1.37812 0.0752,-0.100718 0.213883,-0.275851 0.308198,-0.389167 0.09432,-0.113318 0.210136,-0.271056 0.257381,-0.350531 0.416347,-0.700389 0.680936,-1.176102 0.766454,-1.378041 0.05594,-0.132087 0.114653,-0.239607 0.130477,-0.238929 0.01583,6.79e-4 0.08126,0.08531 0.145412,0.188069 0.178029,0.285173 0.614305,0.658998 0.868158,0.743878 0.259802,0.08686 0.656158,0.09598 0.911369,0.02095 0.213812,-0.06285 0.507296,-0.298016 0.645179,-0.516947 0.155165,-0.246374 0.327989,-0.989595 0.327989,-1.410501 0,-1.26718 -0.610975,-3.143405 -1.237774,-3.801045 -0.198483,-0.2082486 -0.208557,-0.2319396 -0.208557,-0.4904655 0,-0.2517771 -0.08774,-0.5704927 -0.258476,-0.938956 C 16.694963,8.50313 16.375697,8.1377479 16.135846,7.9543702 L 15.932296,7.7987471 15.683004,7.9356529 C 15.131767,8.2383821 14.435638,8.1945733 13.943459,7.8261812 L 13.782862,7.7059758 13.686773,7.8908012 C 13.338849,8.5600578 12.487087,8.8811064 11.743178,8.6233891 11.487199,8.5347109 11.358897,8.4505994 11.063189,8.1776138 L 10.69871,7.8411436 10.453484,8.0579255 C 10.318608,8.1771557 10.113778,8.3156283 9.9983037,8.3656417 9.7041488,8.4930449 9.1808299,8.5227884 8.8979004,8.4281886 8.7754792,8.3872574 8.6687415,8.3537661 8.6607053,8.3537661 c -0.03426,0 -0.3092864,0.3066098 -0.3791974,0.42275 -0.041935,0.069664 -0.1040482,0.1266636 -0.1380294,0.1266636 -0.1316419,0 -0.4197402,0.1843928 -0.6257041,0.4004735 -0.1923125,0.2017571 -0.6853701,0.9036038 -0.8926582,1.2706578 -0.042662,0.07554 -0.1803555,0.353687 -0.3059848,0.618091 -0.1256293,0.264406 -0.3270073,0.686768 -0.4475067,0.938581 -0.1204992,0.251816 -0.2469926,0.519654 -0.2810961,0.595199 -0.2592829,0.574347 -0.285919,1.391094 -0.057822,1.77304 0.1690683,0.283105 0.4224039,0.480895 0.7285507,0.568809 0.487122,0.139885 0.9109638,-0.004 1.6013422,-0.543768 l 0.4560939,-0.356568 0.0036,0.172041 c 0.01635,0.781837 0.1831084,1.813183 0.4016641,2.484154 0.1160449,0.356262 0.3781448,0.83968 0.5614081,1.035462 0.2171883,0.232025 0.7140951,0.577268 1.0100284,0.701749 0.121485,0.0511 0.351032,0.110795 0.510105,0.132647 0.396966,0.05452 1.2105,0.02265 1.448934,-0.05679 z" 54 - id="path1" /> 55 - </g> 56 - </svg> 57 - {{ end }}
-44
appview/pages/templates/fragments/dolly/silhouette.svg
··· 1 - <svg 2 - version="1.1" 3 - id="svg1" 4 - width="32" 5 - height="32" 6 - viewBox="0 0 25 25" 7 - sodipodi:docname="tangled_dolly_silhouette.png" 8 - xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" 9 - xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" 10 - xmlns="http://www.w3.org/2000/svg" 11 - xmlns:svg="http://www.w3.org/2000/svg"> 12 - <title>Dolly</title> 13 - <defs 14 - id="defs1" /> 15 - <sodipodi:namedview 16 - id="namedview1" 17 - pagecolor="#ffffff" 18 - bordercolor="#000000" 19 - borderopacity="0.25" 20 - inkscape:showpageshadow="2" 21 - inkscape:pageopacity="0.0" 22 - inkscape:pagecheckerboard="true" 23 - inkscape:deskcolor="#d1d1d1"> 24 - <inkscape:page 25 - x="0" 26 - y="0" 27 - width="25" 28 - height="25" 29 - id="page2" 30 - margin="0" 31 - bleed="0" /> 32 - </sodipodi:namedview> 33 - <g 34 - inkscape:groupmode="layer" 35 - inkscape:label="Image" 36 - id="g1"> 37 - <path 38 - class="dolly" 39 - fill="currentColor" 40 - style="stroke-width:1.12248" 41 - d="m 16.208435,23.914069 c -0.06147,-0.02273 -0.147027,-0.03034 -0.190158,-0.01691 -0.197279,0.06145 -1.31068,-0.230493 -1.388819,-0.364153 -0.01956,-0.03344 -0.163274,-0.134049 -0.319377,-0.223561 -0.550395,-0.315603 -1.010951,-0.696643 -1.428383,-1.181771 -0.264598,-0.307509 -0.597257,-0.785384 -0.597257,-0.857979 0,-0.0216 -0.02841,-0.06243 -0.06313,-0.0907 -0.04977,-0.04053 -0.160873,0.0436 -0.52488,0.397463 -0.479803,0.466432 -0.78924,0.689475 -1.355603,0.977118 -0.183693,0.0933 -0.323426,0.179989 -0.310516,0.192658 0.02801,0.02748 -0.7656391,0.270031 -1.209129,0.369517 -0.5378332,0.120647 -1.6341809,0.08626 -1.9721503,-0.06186 C 6.7977157,23.031391 6.56735,22.957551 6.3371134,22.889782 4.9717169,22.487902 3.7511914,21.481518 3.1172396,20.234838 2.6890391,19.392772 2.5582276,18.827446 2.5610489,17.831154 2.5639589,16.802192 2.7366641,16.125844 3.2142117,15.273187 3.3040457,15.112788 3.3713143,14.976533 3.3636956,14.9704 3.3560756,14.9643 3.2459634,14.90305 3.1189994,14.834381 1.7582586,14.098312 0.77760984,12.777439 0.44909837,11.23818 0.33531456,10.705039 0.33670119,9.7067968 0.45195381,9.1778795 0.72259241,7.9359287 1.3827188,6.8888436 2.4297498,6.0407205 2.6856126,5.8334648 3.2975489,5.4910878 3.6885849,5.3364049 L 4.0584319,5.190106 4.2333984,4.860432 C 4.8393906,3.7186139 5.8908314,2.7968028 7.1056396,2.3423025 7.7690673,2.0940921 8.2290216,2.0150935 9.01853,2.0137575 c 0.9625627,-0.00163 1.629181,0.1532762 2.485864,0.5776514 l 0.271744,0.1346134 0.42911,-0.3607688 c 1.082666,-0.9102346 2.185531,-1.3136811 3.578383,-1.3090327 0.916696,0.00306 1.573918,0.1517893 2.356121,0.5331927 1.465948,0.7148 2.54506,2.0625628 2.865177,3.57848 l 0.07653,0.362429 0.515095,0.2556611 c 1.022872,0.5076874 1.756122,1.1690944 2.288361,2.0641468 0.401896,0.6758594 0.537303,1.0442682 0.675505,1.8378683 0.288575,1.6570823 -0.266229,3.3548023 -1.490464,4.5608743 -0.371074,0.36557 -0.840205,0.718265 -1.203442,0.904754 -0.144112,0.07398 -0.271303,0.15826 -0.282647,0.187269 -0.01134,0.02901 0.02121,0.142764 0.07234,0.25279 0.184248,0.396467 0.451371,1.331823 0.619371,2.168779 0.463493,2.30908 -0.754646,4.693707 -2.92278,5.721632 -0.479538,0.227352 -0.717629,0.309322 -1.144194,0.39393 -0.321869,0.06383 -1.850573,0.09139 -2.000174,0.03604 z M 12.25443,18.636956 c 0.739923,-0.24652 1.382521,-0.718922 1.874623,-1.37812 0.0752,-0.100718 0.213883,-0.275851 0.308198,-0.389167 0.09432,-0.113318 0.210136,-0.271056 0.257381,-0.350531 0.416347,-0.700389 0.680936,-1.176102 0.766454,-1.378041 0.05594,-0.132087 0.114653,-0.239607 0.130477,-0.238929 0.01583,6.79e-4 0.08126,0.08531 0.145412,0.188069 0.178029,0.285173 0.614305,0.658998 0.868158,0.743878 0.259802,0.08686 0.656158,0.09598 0.911369,0.02095 0.213812,-0.06285 0.507296,-0.298016 0.645179,-0.516947 0.155165,-0.246374 0.327989,-0.989595 0.327989,-1.410501 0,-1.26718 -0.610975,-3.143405 -1.237774,-3.801045 -0.198483,-0.2082486 -0.208557,-0.2319396 -0.208557,-0.4904655 0,-0.2517771 -0.08774,-0.5704927 -0.258476,-0.938956 C 16.694963,8.50313 16.375697,8.1377479 16.135846,7.9543702 L 15.932296,7.7987471 15.683004,7.9356529 C 15.131767,8.2383821 14.435638,8.1945733 13.943459,7.8261812 L 13.782862,7.7059758 13.686773,7.8908012 C 13.338849,8.5600578 12.487087,8.8811064 11.743178,8.6233891 11.487199,8.5347109 11.358897,8.4505994 11.063189,8.1776138 L 10.69871,7.8411436 10.453484,8.0579255 C 10.318608,8.1771557 10.113778,8.3156283 9.9983037,8.3656417 9.7041488,8.4930449 9.1808299,8.5227884 8.8979004,8.4281886 8.7754792,8.3872574 8.6687415,8.3537661 8.6607053,8.3537661 c -0.03426,0 -0.3092864,0.3066098 -0.3791974,0.42275 -0.041935,0.069664 -0.1040482,0.1266636 -0.1380294,0.1266636 -0.1316419,0 -0.4197402,0.1843928 -0.6257041,0.4004735 -0.1923125,0.2017571 -0.6853701,0.9036038 -0.8926582,1.2706578 -0.042662,0.07554 -0.1803555,0.353687 -0.3059848,0.618091 -0.1256293,0.264406 -0.3270073,0.686768 -0.4475067,0.938581 -0.1204992,0.251816 -0.2469926,0.519654 -0.2810961,0.595199 -0.2592829,0.574347 -0.285919,1.391094 -0.057822,1.77304 0.1690683,0.283105 0.4224039,0.480895 0.7285507,0.568809 0.487122,0.139885 0.9109638,-0.004 1.6013422,-0.543768 l 0.4560939,-0.356568 0.0036,0.172041 c 0.01635,0.781837 0.1831084,1.813183 0.4016641,2.484154 0.1160449,0.356262 0.3781448,0.83968 0.5614081,1.035462 0.2171883,0.232025 0.7140951,0.577268 1.0100284,0.701749 0.121485,0.0511 0.351032,0.110795 0.510105,0.132647 0.396966,0.05452 1.2105,0.02265 1.448934,-0.05679 z" 42 - id="path1" /> 43 - </g> 44 - </svg>
+1 -1
appview/pages/templates/fragments/logotype.html
··· 1 1 {{ define "fragments/logotype" }} 2 2 <span class="flex items-center gap-2"> 3 - {{ template "fragments/dolly/logo" "size-16 text-black dark:text-white" }} 3 + {{ template "fragments/dolly/logo" (dict "Classes" "size-16 text-black dark:text-white") }} 4 4 <span class="font-bold text-4xl not-italic">tangled</span> 5 5 <span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1"> 6 6 alpha
+1 -1
appview/pages/templates/fragments/logotypeSmall.html
··· 1 1 {{ define "fragments/logotypeSmall" }} 2 2 <span class="flex items-center gap-2"> 3 - {{ template "fragments/dolly/logo" "size-8 text-black dark:text-white" }} 3 + {{ template "fragments/dolly/logo" (dict "Classes" "size-8 text-black dark:text-white")}} 4 4 <span class="font-bold text-xl not-italic">tangled</span> 5 5 <span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1"> 6 6 alpha
+5
appview/pages/templates/fragments/starBtn-oob.html
··· 1 + {{ define "fragments/starBtn-oob" }} 2 + <div hx-swap-oob='outerHTML:#starBtn[data-star-subject-at="{{ .SubjectAt }}"]'> 3 + {{ template "fragments/starBtn" . }} 4 + </div> 5 + {{ end }}
+26
appview/pages/templates/fragments/starBtn.html
··· 1 + {{ define "fragments/starBtn" }} 2 + {{/* NOTE: this fragment is always replaced with hx-swap-oob */}} 3 + <button 4 + id="starBtn" 5 + class="btn disabled:opacity-50 disabled:cursor-not-allowed flex gap-2 items-center group" 6 + data-star-subject-at="{{ .SubjectAt }}" 7 + {{ if .IsStarred }} 8 + hx-delete="/star?subject={{ .SubjectAt }}&countHint={{ .StarCount }}" 9 + {{ else }} 10 + hx-post="/star?subject={{ .SubjectAt }}&countHint={{ .StarCount }}" 11 + {{ end }} 12 + 13 + hx-trigger="click" 14 + hx-disabled-elt="#starBtn" 15 + > 16 + {{ if .IsStarred }} 17 + {{ i "star" "w-4 h-4 fill-current" }} 18 + {{ else }} 19 + {{ i "star" "w-4 h-4" }} 20 + {{ end }} 21 + <span class="text-sm"> 22 + {{ .StarCount }} 23 + </span> 24 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 25 + </button> 26 + {{ end }}
+33
appview/pages/templates/fragments/tabSelector.html
··· 1 + {{ define "fragments/tabSelector" }} 2 + {{ $name := .Name }} 3 + {{ $all := .Values }} 4 + {{ $active := .Active }} 5 + {{ $include := .Include }} 6 + <div class="flex justify-between divide-x divide-gray-200 dark:divide-gray-700 rounded border border-gray-200 dark:border-gray-700 overflow-hidden"> 7 + {{ $activeTab := "bg-white dark:bg-gray-700 shadow-sm" }} 8 + {{ $inactiveTab := "bg-gray-100 dark:bg-gray-800 shadow-inner" }} 9 + {{ range $index, $value := $all }} 10 + {{ $isActive := eq $value.Key $active }} 11 + <a href="?{{ $name }}={{ $value.Key }}" 12 + {{ if $include }} 13 + hx-get="?{{ $name }}={{ $value.Key }}" 14 + hx-include="{{ $include }}" 15 + hx-push-url="true" 16 + hx-target="body" 17 + hx-on:htmx:config-request="if(!event.detail.parameters.q) delete event.detail.parameters.q" 18 + {{ end }} 19 + class="p-2 whitespace-nowrap flex justify-center items-center gap-2 text-sm w-full block hover:no-underline text-center {{ if $isActive }} {{$activeTab }} {{ else }} {{ $inactiveTab }} {{ end }}"> 20 + {{ if $value.Icon }} 21 + {{ i $value.Icon "size-4" }} 22 + {{ end }} 23 + 24 + {{ with $value.Meta }} 25 + {{ . }} 26 + {{ end }} 27 + 28 + {{ $value.Value }} 29 + </a> 30 + {{ end }} 31 + </div> 32 + {{ end }} 33 +
+22
appview/pages/templates/fragments/tinyAvatarList.html
··· 1 + {{ define "fragments/tinyAvatarList" }} 2 + {{ $all := .all }} 3 + {{ $classes := .classes }} 4 + {{ $ps := take $all 5 }} 5 + <div class="inline-flex items-center -space-x-3"> 6 + {{ $c := "z-50 z-40 z-30 z-20 z-10" }} 7 + {{ range $i, $p := $ps }} 8 + <img 9 + src="{{ tinyAvatar . }}" 10 + alt="" 11 + class="rounded-full size-8 mr-1 border-2 border-gray-100 dark:border-gray-900 z-{{sub 5 $i}}0 {{ $classes }}" 12 + /> 13 + {{ end }} 14 + 15 + {{ if gt (len $all) 5 }} 16 + <span class="pl-4 text-gray-500 dark:text-gray-400 text-sm"> 17 + +{{ sub (len $all) 5 }} 18 + </span> 19 + {{ end }} 20 + </div> 21 + {{ end }} 22 +
+36
appview/pages/templates/fragments/workflow-timers.html
··· 1 + {{ define "fragments/workflow-timers" }} 2 + <script> 3 + function formatElapsed(seconds) { 4 + if (seconds < 1) return '0s'; 5 + if (seconds < 60) return `${seconds}s`; 6 + const minutes = Math.floor(seconds / 60); 7 + const secs = seconds % 60; 8 + if (seconds < 3600) return `${minutes}m ${secs}s`; 9 + const hours = Math.floor(seconds / 3600); 10 + const mins = Math.floor((seconds % 3600) / 60); 11 + return `${hours}h ${mins}m`; 12 + } 13 + 14 + function updateTimers() { 15 + const now = Math.floor(Date.now() / 1000); 16 + 17 + document.querySelectorAll('[data-timer]').forEach(el => { 18 + const startTime = parseInt(el.dataset.start); 19 + const endTime = el.dataset.end ? parseInt(el.dataset.end) : null; 20 + 21 + if (endTime) { 22 + // Step is complete, show final time 23 + const elapsed = endTime - startTime; 24 + el.textContent = formatElapsed(elapsed); 25 + } else { 26 + // Step is running, update live 27 + const elapsed = now - startTime; 28 + el.textContent = formatElapsed(elapsed); 29 + } 30 + }); 31 + } 32 + 33 + setInterval(updateTimers, 1000); 34 + updateTimers(); 35 + </script> 36 + {{ end }}
+23 -7
appview/pages/templates/knots/dashboard.html
··· 1 - {{ define "title" }}{{ .Registration.Domain }} &middot; knots{{ end }} 1 + {{ define "title" }}{{ .Registration.Domain }} &middot; {{ .Tab }} settings{{ end }} 2 2 3 3 {{ define "content" }} 4 - <div class="px-6 py-4"> 4 + <div class="p-6"> 5 + <p class="text-xl font-bold dark:text-white">Settings</p> 6 + </div> 7 + <div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 8 + <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6"> 9 + <div class="col-span-1"> 10 + {{ template "user/settings/fragments/sidebar" . }} 11 + </div> 12 + <div class="col-span-1 md:col-span-3 flex flex-col gap-6"> 13 + {{ template "knotDash" . }} 14 + </div> 15 + </section> 16 + </div> 17 + {{ end }} 18 + 19 + {{ define "knotDash" }} 20 + <div> 5 21 <div class="flex justify-between items-center"> 6 - <h1 class="text-xl font-bold dark:text-white">{{ .Registration.Domain }}</h1> 22 + <h2 class="text-sm pb-2 uppercase font-bold">{{ .Tab }} &middot; {{ .Registration.Domain }}</h2> 7 23 <div id="right-side" class="flex gap-2"> 8 24 {{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2" }} 9 25 {{ $isOwner := and .LoggedInUser (eq .LoggedInUser.Did .Registration.ByDid) }} ··· 35 51 </div> 36 52 37 53 {{ if .Members }} 38 - <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 54 + <section class="bg-white dark:bg-gray-800 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 39 55 <div class="flex flex-col gap-2"> 40 56 {{ block "member" . }} {{ end }} 41 57 </div> ··· 79 95 <button 80 96 class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 81 97 title="Delete knot" 82 - hx-delete="/knots/{{ .Domain }}" 98 + hx-delete="/settings/knots/{{ .Domain }}" 83 99 hx-swap="outerHTML" 84 100 hx-confirm="Are you sure you want to delete the knot '{{ .Domain }}'?" 85 101 hx-headers='{"shouldRedirect": "true"}' ··· 95 111 <button 96 112 class="btn gap-2 group" 97 113 title="Retry knot verification" 98 - hx-post="/knots/{{ .Domain }}/retry" 114 + hx-post="/settings/knots/{{ .Domain }}/retry" 99 115 hx-swap="none" 100 116 hx-headers='{"shouldRefresh": "true"}' 101 117 > ··· 113 129 <button 114 130 class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 115 131 title="Remove member" 116 - hx-post="/knots/{{ $root.Registration.Domain }}/remove" 132 + hx-post="/settings/knots/{{ $root.Registration.Domain }}/remove" 117 133 hx-swap="none" 118 134 hx-vals='{"member": "{{$member}}" }' 119 135 hx-confirm="Are you sure you want to remove {{ $memberHandle }} from this knot?"
+18 -10
appview/pages/templates/knots/fragments/addMemberModal.html
··· 13 13 <div 14 14 id="add-member-{{ .Id }}" 15 15 popover 16 - class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 16 + class=" 17 + 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 18 + w-full md:w-96 p-4 rounded drop-shadow overflow-visible"> 17 19 {{ block "addKnotMemberPopover" . }} {{ end }} 18 20 </div> 19 21 {{ end }} 20 22 21 23 {{ define "addKnotMemberPopover" }} 22 24 <form 23 - hx-post="/knots/{{ .Domain }}/add" 25 + hx-post="/settings/knots/{{ .Domain }}/add" 24 26 hx-indicator="#spinner" 25 27 hx-swap="none" 26 28 class="flex flex-col gap-2" ··· 29 31 ADD MEMBER 30 32 </label> 31 33 <p class="text-sm text-gray-500 dark:text-gray-400">Members can create repositories and run workflows on this knot.</p> 32 - <input 33 - type="text" 34 - id="member-did-{{ .Id }}" 35 - name="member" 36 - required 37 - placeholder="@foo.bsky.social" 38 - /> 34 + <actor-typeahead> 35 + <input 36 + autocapitalize="none" 37 + autocorrect="off" 38 + autocomplete="off" 39 + type="text" 40 + id="member-did-{{ .Id }}" 41 + name="member" 42 + required 43 + placeholder="user.tngl.sh" 44 + class="w-full" 45 + /> 46 + </actor-typeahead> 39 47 <div class="flex gap-2 pt-2"> 40 48 <button 41 49 type="button" ··· 54 62 </div> 55 63 <div id="add-member-error-{{ .Id }}" class="text-red-500 dark:text-red-400"></div> 56 64 </form> 57 - {{ end }} 65 + {{ end }}
+3 -3
appview/pages/templates/knots/fragments/knotListing.html
··· 7 7 8 8 {{ define "knotLeftSide" }} 9 9 {{ if .Registered }} 10 - <a href="/knots/{{ .Domain }}" class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]"> 10 + <a href="/settings/knots/{{ .Domain }}" class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]"> 11 11 {{ i "hard-drive" "w-4 h-4" }} 12 12 <span class="hover:underline"> 13 13 {{ .Domain }} ··· 56 56 <button 57 57 class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 58 58 title="Delete knot" 59 - hx-delete="/knots/{{ .Domain }}" 59 + hx-delete="/settings/knots/{{ .Domain }}" 60 60 hx-swap="outerHTML" 61 61 hx-target="#knot-{{.Id}}" 62 62 hx-confirm="Are you sure you want to delete the knot '{{ .Domain }}'?" ··· 72 72 <button 73 73 class="btn gap-2 group" 74 74 title="Retry knot verification" 75 - hx-post="/knots/{{ .Domain }}/retry" 75 + hx-post="/settings/knots/{{ .Domain }}/retry" 76 76 hx-swap="none" 77 77 hx-target="#knot-{{.Id}}" 78 78 >
+42 -11
appview/pages/templates/knots/index.html
··· 1 - {{ define "title" }}knots{{ end }} 1 + {{ define "title" }}{{ .Tab }} settings{{ end }} 2 2 3 3 {{ define "content" }} 4 - <div class="px-6 py-4 flex items-center justify-between gap-4 align-bottom"> 5 - <h1 class="text-xl font-bold dark:text-white">Knots</h1> 6 - <span class="flex items-center gap-1"> 7 - {{ i "book" "w-3 h-3" }} 8 - <a href="https://tangled.org/@tangled.org/core/blob/master/docs/knot-hosting.md">docs</a> 9 - </span> 10 - </div> 4 + <div class="p-6"> 5 + <p class="text-xl font-bold dark:text-white">Settings</p> 6 + </div> 7 + <div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 8 + <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6"> 9 + <div class="col-span-1"> 10 + {{ template "user/settings/fragments/sidebar" . }} 11 + </div> 12 + <div class="col-span-1 md:col-span-3 flex flex-col gap-6"> 13 + {{ template "knotsList" . }} 14 + </div> 15 + </section> 16 + </div> 17 + {{ end }} 18 + 19 + {{ define "knotsList" }} 20 + <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 21 + <div class="col-span-1 md:col-span-2"> 22 + <h2 class="text-sm pb-2 uppercase font-bold">Knots</h2> 23 + {{ block "about" . }} {{ end }} 24 + </div> 25 + <div class="col-span-1 md:col-span-1 md:justify-self-end"> 26 + {{ template "docsButton" . }} 27 + </div> 28 + </div> 11 29 12 - <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 30 + <section> 13 31 <div class="flex flex-col gap-6"> 14 - {{ block "about" . }} {{ end }} 15 32 {{ block "list" . }} {{ end }} 16 33 {{ block "register" . }} {{ end }} 17 34 </div> ··· 50 67 <h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">register a knot</h2> 51 68 <p class="mb-2 dark:text-gray-300">Enter the hostname of your knot to get started.</p> 52 69 <form 53 - hx-post="/knots/register" 70 + hx-post="/settings/knots/register" 54 71 class="max-w-2xl mb-2 space-y-4" 55 72 hx-indicator="#register-button" 56 73 hx-swap="none" ··· 84 101 85 102 </section> 86 103 {{ end }} 104 + 105 + {{ define "docsButton" }} 106 + <a 107 + class="btn flex items-center gap-2" 108 + href="https://docs.tangled.org/knot-self-hosting-guide.html#knot-self-hosting-guide"> 109 + {{ i "book" "size-4" }} 110 + docs 111 + </a> 112 + <div 113 + id="add-email-modal" 114 + popover 115 + 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"> 116 + </div> 117 + {{ end }}
+5
appview/pages/templates/layouts/base.html
··· 9 9 10 10 <script defer src="/static/htmx.min.js"></script> 11 11 <script defer src="/static/htmx-ext-ws.min.js"></script> 12 + <script defer src="/static/actor-typeahead.js" type="module"></script> 13 + 14 + <link rel="icon" href="/static/logos/dolly.ico" sizes="48x48"/> 15 + <link rel="icon" href="/static/logos/dolly.svg" sizes="any" type="image/svg+xml"/> 16 + <link rel="apple-touch-icon" href="/static/logos/dolly.png"/> 12 17 13 18 <!-- preconnect to image cdn --> 14 19 <link rel="preconnect" href="https://avatar.tangled.sh" />
+2 -2
appview/pages/templates/layouts/fragments/footer.html
··· 26 26 <div class="flex flex-col gap-1"> 27 27 <div class="{{ $headerStyle }}">resources</div> 28 28 <a href="https://blog.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a> 29 - <a href="https://tangled.org/@tangled.org/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 29 + <a href="https://docs.tangled.org" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 30 30 <a href="https://tangled.org/@tangled.org/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a> 31 31 <a href="https://tangled.org/brand" class="{{ $linkStyle }}">{{ i "paintbrush" $iconStyle }} brand</a> 32 32 </div> ··· 73 73 <div class="flex flex-col gap-1"> 74 74 <div class="{{ $headerStyle }}">resources</div> 75 75 <a href="https://blog.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a> 76 - <a href="https://tangled.org/@tangled.org/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 76 + <a href="https://docs.tangled.org" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 77 77 <a href="https://tangled.org/@tangled.org/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a> 78 78 <a href="https://tangled.org/brand" class="{{ $linkStyle }}">{{ i "paintbrush" $iconStyle }} brand</a> 79 79 </div>
+6 -16
appview/pages/templates/layouts/fragments/topbar.html
··· 3 3 <div class="flex justify-between p-0 items-center"> 4 4 <div id="left-items"> 5 5 <a href="/" hx-boost="true" class="text-2xl no-underline hover:no-underline flex items-center gap-2"> 6 - {{ template "fragments/dolly/logo" "size-8 text-black dark:text-white" }} 7 - <span class="font-bold text-xl not-italic hidden md:inline">tangled</span> 8 - <span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1 hidden md:inline"> 9 - alpha 10 - </span> 6 + {{ template "fragments/logotypeSmall" }} 11 7 </a> 12 8 </div> 13 9 ··· 15 11 {{ with .LoggedInUser }} 16 12 {{ block "newButton" . }} {{ end }} 17 13 {{ template "notifications/fragments/bell" }} 18 - {{ block "dropDown" . }} {{ end }} 14 + {{ block "profileDropdown" . }} {{ end }} 19 15 {{ else }} 20 16 <a href="/login">login</a> 21 17 <span class="text-gray-500 dark:text-gray-400">or</span> ··· 33 29 <summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2"> 34 30 {{ i "plus" "w-4 h-4" }} <span class="hidden md:inline">new</span> 35 31 </summary> 36 - <div class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700"> 32 + <div class="absolute flex flex-col right-0 mt-3 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700"> 37 33 <a href="/repo/new" class="flex items-center gap-2"> 38 34 {{ i "book-plus" "w-4 h-4" }} 39 35 new repository ··· 46 42 </details> 47 43 {{ end }} 48 44 49 - {{ define "dropDown" }} 45 + {{ define "profileDropdown" }} 50 46 <details class="relative inline-block text-left nav-dropdown"> 51 - <summary 52 - class="cursor-pointer list-none flex items-center gap-1" 53 - > 47 + <summary class="cursor-pointer list-none flex items-center gap-1"> 54 48 {{ $user := .Did }} 55 49 <img 56 50 src="{{ tinyAvatar $user }}" ··· 59 53 /> 60 54 <span class="hidden md:inline">{{ $user | resolve | truncateAt30 }}</span> 61 55 </summary> 62 - <div 63 - class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700" 64 - > 56 + <div class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700"> 65 57 <a href="/{{ $user }}">profile</a> 66 58 <a href="/{{ $user }}?tab=repos">repositories</a> 67 59 <a href="/{{ $user }}?tab=strings">strings</a> 68 - <a href="/knots">knots</a> 69 - <a href="/spindles">spindles</a> 70 60 <a href="/settings">settings</a> 71 61 <a href="#" 72 62 hx-post="/logout"
+8 -7
appview/pages/templates/layouts/profilebase.html
··· 1 - {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }}{{ end }} 1 + {{ define "title" }}{{ resolve .Card.UserDid }}{{ end }} 2 2 3 3 {{ define "extrameta" }} 4 - {{ $avatarUrl := fullAvatar .Card.UserHandle }} 5 - <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" /> 4 + {{ $handle := resolve .Card.UserDid }} 5 + {{ $avatarUrl := fullAvatar $handle }} 6 + <meta property="og:title" content="{{ $handle }}" /> 6 7 <meta property="og:type" content="profile" /> 7 - <meta property="og:url" content="https://tangled.org/{{ or .Card.UserHandle .Card.UserDid }}?tab={{ .Active }}" /> 8 - <meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 8 + <meta property="og:url" content="https://tangled.org/{{ $handle }}?tab={{ .Active }}" /> 9 + <meta property="og:description" content="{{ or .Card.Profile.Description $handle }}" /> 9 10 <meta property="og:image" content="{{ $avatarUrl }}" /> 10 11 <meta property="og:image:width" content="512" /> 11 12 <meta property="og:image:height" content="512" /> 12 13 13 14 <meta name="twitter:card" content="summary" /> 14 - <meta name="twitter:title" content="{{ or .Card.UserHandle .Card.UserDid }}" /> 15 - <meta name="twitter:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 15 + <meta name="twitter:title" content="{{ $handle }}" /> 16 + <meta name="twitter:description" content="{{ or .Card.Profile.Description $handle }}" /> 16 17 <meta name="twitter:image" content="{{ $avatarUrl }}" /> 17 18 {{ end }} 18 19
+57 -26
appview/pages/templates/layouts/repobase.html
··· 1 1 {{ define "title" }}{{ .RepoInfo.FullName }}{{ end }} 2 2 3 3 {{ define "content" }} 4 - <section id="repo-header" class="mb-4 py-2 px-6 dark:text-white"> 5 - {{ if .RepoInfo.Source }} 6 - <p class="text-sm"> 7 - <div class="flex items-center"> 8 - {{ i "git-fork" "w-3 h-3 mr-1 shrink-0" }} 9 - forked from 10 - {{ $sourceOwner := didOrHandle .RepoInfo.Source.Did .RepoInfo.SourceHandle }} 11 - <a class="ml-1 underline" href="/{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}">{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}</a> 12 - </div> 13 - </p> 14 - {{ end }} 15 - <div class="text-lg flex items-center justify-between"> 16 - <div> 17 - <a href="/{{ .RepoInfo.OwnerWithAt }}">{{ .RepoInfo.OwnerWithAt }}</a> 18 - <span class="select-none">/</span> 19 - <a href="/{{ .RepoInfo.FullName }}" class="font-bold">{{ .RepoInfo.Name }}</a> 4 + <section id="repo-header" class="mb-4 p-2 dark:text-white"> 5 + <div class="text-lg flex flex-col sm:flex-row items-start gap-4 justify-between"> 6 + <!-- left items --> 7 + <div class="flex flex-col gap-2"> 8 + <!-- repo owner / repo name --> 9 + <div class="flex items-center gap-2 flex-wrap"> 10 + {{ template "user/fragments/picHandleLink" .RepoInfo.OwnerDid }} 11 + <span class="select-none">/</span> 12 + <a href="/{{ .RepoInfo.FullName }}" class="font-bold">{{ .RepoInfo.Name }}</a> 13 + </div> 14 + 15 + {{ if .RepoInfo.Source }} 16 + {{ $sourceOwner := resolve .RepoInfo.Source.Did }} 17 + <div class="flex items-center gap-1 text-sm flex-wrap"> 18 + {{ i "git-fork" "w-3 h-3 shrink-0" }} 19 + <span>forked from</span> 20 + <a class="underline" href="/{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}"> 21 + {{ $sourceOwner }}/{{ .RepoInfo.Source.Name }} 22 + </a> 23 + </div> 24 + {{ end }} 25 + 26 + <span class="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-gray-600 dark:text-gray-300"> 27 + {{ if .RepoInfo.Description }} 28 + {{ .RepoInfo.Description | description }} 29 + {{ else }} 30 + <span class="italic">this repo has no description</span> 31 + {{ end }} 32 + 33 + {{ with .RepoInfo.Website }} 34 + <span class="flex items-center gap-1"> 35 + <span class="flex-shrink-0">{{ i "globe" "size-4" }}</span> 36 + <a href="{{ . }}">{{ . | trimUriScheme }}</a> 37 + </span> 38 + {{ end }} 39 + 40 + {{ if .RepoInfo.Topics }} 41 + <div class="flex items-center gap-1 text-sm text-gray-600 dark:text-gray-300"> 42 + {{ range .RepoInfo.Topics }} 43 + <span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-sm">{{ . }}</span> 44 + {{ end }} 45 + </div> 46 + {{ end }} 47 + 48 + </span> 20 49 </div> 21 50 22 - <div class="flex items-center gap-2 z-auto"> 23 - <a 24 - class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group" 25 - href="/{{ .RepoInfo.FullName }}/feed.atom" 26 - > 27 - {{ i "rss" "size-4" }} 28 - </a> 29 - {{ template "repo/fragments/repoStar" .RepoInfo }} 51 + <div class="w-full sm:w-fit grid grid-cols-3 gap-2 z-auto"> 52 + {{ template "fragments/starBtn" 53 + (dict "SubjectAt" .RepoInfo.RepoAt 54 + "IsStarred" .RepoInfo.IsStarred 55 + "StarCount" .RepoInfo.Stats.StarCount) }} 30 56 <a 31 57 class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group" 32 58 hx-boost="true" ··· 36 62 fork 37 63 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 38 64 </a> 65 + <a 66 + class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group" 67 + href="/{{ .RepoInfo.FullName }}/feed.atom"> 68 + {{ i "rss" "size-4" }} 69 + <span class="md:hidden">atom</span> 70 + </a> 39 71 </div> 40 72 </div> 41 - {{ template "repo/fragments/repoDescription" . }} 42 73 </section> 43 74 44 75 <section class="w-full flex flex-col" > ··· 79 110 </div> 80 111 </nav> 81 112 {{ block "repoContentLayout" . }} 82 - <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white"> 113 + <section class="bg-white dark:bg-gray-800 px-6 py-4 rounded relative w-full mx-auto dark:text-white"> 83 114 {{ block "repoContent" . }}{{ end }} 84 115 </section> 85 116 {{ block "repoAfter" . }}{{ end }}
+6
appview/pages/templates/notifications/fragments/item.html
··· 40 40 commented on an issue 41 41 {{ else if eq .Type "issue_closed" }} 42 42 closed an issue 43 + {{ else if eq .Type "issue_reopen" }} 44 + reopened an issue 43 45 {{ else if eq .Type "pull_created" }} 44 46 created a pull request 45 47 {{ else if eq .Type "pull_commented" }} ··· 48 50 merged a pull request 49 51 {{ else if eq .Type "pull_closed" }} 50 52 closed a pull request 53 + {{ else if eq .Type "pull_reopen" }} 54 + reopened a pull request 51 55 {{ else if eq .Type "followed" }} 52 56 followed you 57 + {{ else if eq .Type "user_mentioned" }} 58 + mentioned you 53 59 {{ else }} 54 60 {{ end }} 55 61 {{ end }}
+64 -39
appview/pages/templates/repo/blob.html
··· 11 11 {{ end }} 12 12 13 13 {{ define "repoContent" }} 14 - {{ $lines := split .Contents }} 15 - {{ $tot_lines := len $lines }} 16 - {{ $tot_chars := len (printf "%d" $tot_lines) }} 17 - {{ $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" }} 18 14 {{ $linkstyle := "no-underline hover:underline" }} 19 15 <div class="pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700"> 20 16 <div class="flex flex-col md:flex-row md:justify-between gap-2"> ··· 36 32 </div> 37 33 <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"> 38 34 <span>at <a href="/{{ .RepoInfo.FullName }}/tree/{{ .Ref }}">{{ .Ref }}</a></span> 39 - <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 40 - <span>{{ .Lines }} lines</span> 41 - <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 42 - <span>{{ byteFmt .SizeHint }}</span> 43 - <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 44 - <a href="/{{ .RepoInfo.FullName }}/raw/{{ .Ref }}/{{ .Path }}">view raw</a> 45 - {{ if .RenderToggle }} 46 - <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 47 - <a 48 - href="/{{ .RepoInfo.FullName }}/blob/{{ .Ref }}/{{ .Path }}?code={{ .ShowRendered }}" 49 - hx-boost="true" 50 - >view {{ if .ShowRendered }}code{{ else }}rendered{{ end }}</a> 35 + 36 + {{ if .BlobView.ShowingText }} 37 + <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 38 + <span>{{ .Lines }} lines</span> 39 + {{ end }} 40 + 41 + {{ if .BlobView.SizeHint }} 42 + <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 43 + <span>{{ byteFmt .BlobView.SizeHint }}</span> 44 + {{ end }} 45 + 46 + {{ if .BlobView.HasRawView }} 47 + <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 48 + <a href="/{{ .RepoInfo.FullName }}/raw/{{ .Ref }}/{{ .Path }}">view raw</a> 49 + {{ end }} 50 + 51 + {{ if .BlobView.ShowToggle }} 52 + <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 53 + <a href="/{{ .RepoInfo.FullName }}/blob/{{ .Ref }}/{{ .Path }}?code={{ .BlobView.ShowingRendered }}" hx-boost="true"> 54 + view {{ if .BlobView.ShowingRendered }}code{{ else }}rendered{{ end }} 55 + </a> 51 56 {{ end }} 52 57 </div> 53 58 </div> 54 59 </div> 55 - {{ if and .IsBinary .Unsupported }} 56 - <p class="text-center text-gray-400 dark:text-gray-500"> 57 - Previews are not supported for this file type. 58 - </p> 59 - {{ else if .IsBinary }} 60 - <div class="text-center"> 61 - {{ if .IsImage }} 62 - <img src="{{ .ContentSrc }}" 63 - alt="{{ .Path }}" 64 - class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded" /> 65 - {{ else if .IsVideo }} 66 - <video controls class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded"> 67 - <source src="{{ .ContentSrc }}"> 68 - Your browser does not support the video tag. 69 - </video> 70 - {{ end }} 71 - </div> 72 - {{ else }} 73 - <div class="overflow-auto relative"> 74 - {{ if .ShowRendered }} 75 - <div id="blob-contents" class="prose dark:prose-invert">{{ .RenderedContents }}</div> 60 + {{ if .BlobView.IsUnsupported }} 61 + <p class="text-center text-gray-400 dark:text-gray-500"> 62 + Previews are not supported for this file type. 63 + </p> 64 + {{ else if .BlobView.ContentType.IsSubmodule }} 65 + <p class="text-center text-gray-400 dark:text-gray-500"> 66 + This directory is a git submodule of <a href="{{ .BlobView.ContentSrc }}">{{ .BlobView.ContentSrc }}</a>. 67 + </p> 68 + {{ else if .BlobView.ContentType.IsImage }} 69 + <div class="text-center"> 70 + <img src="{{ .BlobView.ContentSrc }}" 71 + alt="{{ .Path }}" 72 + class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded" /> 73 + </div> 74 + {{ else if .BlobView.ContentType.IsVideo }} 75 + <div class="text-center"> 76 + <video controls class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded"> 77 + <source src="{{ .BlobView.ContentSrc }}"> 78 + Your browser does not support the video tag. 79 + </video> 80 + </div> 81 + {{ else if .BlobView.ContentType.IsSvg }} 82 + <div class="overflow-auto relative"> 83 + {{ if .BlobView.ShowingRendered }} 84 + <div class="text-center"> 85 + <img src="{{ .BlobView.ContentSrc }}" 86 + alt="{{ .Path }}" 87 + class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded" /> 88 + </div> 89 + {{ else }} 90 + <div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ code .BlobView.Contents .Path | escapeHtml }}</div> 91 + {{ end }} 92 + </div> 93 + {{ else if .BlobView.ContentType.IsMarkup }} 94 + <div class="overflow-auto relative"> 95 + {{ if .BlobView.ShowingRendered }} 96 + <div id="blob-contents" class="prose dark:prose-invert">{{ .BlobView.Contents | readme }}</div> 76 97 {{ else }} 77 - <div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ $.Contents | escapeHtml }}</div> 98 + <div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ code .BlobView.Contents .Path | escapeHtml }}</div> 78 99 {{ end }} 79 - </div> 100 + </div> 101 + {{ else if .BlobView.ContentType.IsCode }} 102 + <div class="overflow-auto relative"> 103 + <div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ code .BlobView.Contents .Path | escapeHtml }}</div> 104 + </div> 80 105 {{ end }} 81 106 {{ template "fragments/multiline-select" }} 82 107 {{ end }}
+41 -16
appview/pages/templates/repo/commit.html
··· 24 24 </div> 25 25 </div> 26 26 27 - <div class="flex items-center space-x-2"> 28 - <p class="text-sm text-gray-500 dark:text-gray-300"> 29 - {{ $didOrHandle := index $.EmailToDidOrHandle $commit.Author.Email }} 27 + <div class="flex flex-wrap items-center space-x-2"> 28 + <p class="flex flex-wrap items-center gap-1 text-sm text-gray-500 dark:text-gray-300"> 29 + {{ template "attribution" . }} 30 30 31 - {{ if $didOrHandle }} 32 - <a href="/{{ $didOrHandle }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ $didOrHandle }}</a> 33 - {{ else }} 34 - <a href="mailto:{{ $commit.Author.Email }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ $commit.Author.Name }}</a> 35 - {{ end }} 36 31 <span class="px-1 select-none before:content-['\00B7']"></span> 37 - {{ template "repo/fragments/time" $commit.Author.When }} 32 + {{ template "repo/fragments/time" $commit.Committer.When }} 38 33 <span class="px-1 select-none before:content-['\00B7']"></span> 39 - </p> 40 34 41 - <p class="flex items-center text-sm text-gray-500 dark:text-gray-300"> 42 35 <a href="/{{ $repo }}/commit/{{ $commit.This }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ slice $commit.This 0 8 }}</a> 36 + 43 37 {{ if $commit.Parent }} 44 - {{ i "arrow-left" "w-3 h-3 mx-1" }} 45 - <a href="/{{ $repo }}/commit/{{ $commit.Parent }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ slice $commit.Parent 0 8 }}</a> 38 + {{ i "arrow-left" "w-3 h-3 mx-1" }} 39 + <a href="/{{ $repo }}/commit/{{ $commit.Parent }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ slice $commit.Parent 0 8 }}</a> 46 40 {{ end }} 47 41 </p> 48 42 ··· 58 52 <div class="mb-1">This commit was signed with the committer's <span class="text-green-600 font-semibold">known signature</span>.</div> 59 53 <div class="flex items-center gap-2 my-2"> 60 54 {{ i "user" "w-4 h-4" }} 61 - {{ $committerDidOrHandle := index $.EmailToDidOrHandle $commit.Committer.Email }} 62 - <a href="/{{ $committerDidOrHandle }}">{{ template "user/fragments/picHandleLink" $committerDidOrHandle }}</a> 55 + {{ $committerDid := index $.EmailToDid $commit.Committer.Email }} 56 + {{ template "user/fragments/picHandleLink" $committerDid }} 63 57 </div> 64 58 <div class="my-1 pt-2 text-xs border-t border-gray-200 dark:border-gray-700"> 65 59 <div class="text-gray-600 dark:text-gray-300">SSH Key Fingerprint:</div> ··· 79 73 </section> 80 74 {{end}} 81 75 76 + {{ define "attribution" }} 77 + {{ $commit := .Diff.Commit }} 78 + {{ $showCommitter := true }} 79 + {{ if eq $commit.Author.Email $commit.Committer.Email }} 80 + {{ $showCommitter = false }} 81 + {{ end }} 82 + 83 + {{ if $showCommitter }} 84 + authored by {{ template "attributedUser" (list $commit.Author.Email $commit.Author.Name $.EmailToDid) }} 85 + {{ range $commit.CoAuthors }} 86 + {{ template "attributedUser" (list .Email .Name $.EmailToDid) }} 87 + {{ end }} 88 + and committed by {{ template "attributedUser" (list $commit.Committer.Email $commit.Committer.Name $.EmailToDid) }} 89 + {{ else }} 90 + {{ template "attributedUser" (list $commit.Author.Email $commit.Author.Name $.EmailToDid )}} 91 + {{ end }} 92 + {{ end }} 93 + 94 + {{ define "attributedUser" }} 95 + {{ $email := index . 0 }} 96 + {{ $name := index . 1 }} 97 + {{ $map := index . 2 }} 98 + {{ $did := index $map $email }} 99 + 100 + {{ if $did }} 101 + {{ template "user/fragments/picHandleLink" $did }} 102 + {{ else }} 103 + <a href="mailto:{{ $email }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ $name }}</a> 104 + {{ end }} 105 + {{ end }} 106 + 82 107 {{ define "topbarLayout" }} 83 108 <header class="col-span-full" style="z-index: 20;"> 84 109 {{ template "layouts/fragments/topbar" . }} ··· 111 136 {{ end }} 112 137 113 138 {{ define "contentAfter" }} 114 - {{ template "repo/fragments/diff" (list .RepoInfo.FullName .Diff .DiffOpts) }} 139 + {{ template "repo/fragments/diff" (list .Diff .DiffOpts) }} 115 140 {{end}} 116 141 117 142 {{ define "contentAfterLeft" }}
+2 -2
appview/pages/templates/repo/compare/compare.html
··· 17 17 {{ end }} 18 18 19 19 {{ define "mainLayout" }} 20 - <div class="px-1 col-span-full flex flex-col gap-4"> 20 + <div class="px-1 flex-grow col-span-full flex flex-col gap-4"> 21 21 {{ block "contentLayout" . }} 22 22 {{ block "content" . }}{{ end }} 23 23 {{ end }} ··· 42 42 {{ end }} 43 43 44 44 {{ define "contentAfter" }} 45 - {{ template "repo/fragments/diff" (list .RepoInfo.FullName .Diff .DiffOpts) }} 45 + {{ template "repo/fragments/diff" (list .Diff .DiffOpts) }} 46 46 {{end}} 47 47 48 48 {{ define "contentAfterLeft" }}
+2 -2
appview/pages/templates/repo/empty.html
··· 26 26 {{ else if (and .LoggedInUser (eq .LoggedInUser.Did .RepoInfo.OwnerDid)) }} 27 27 {{ $knot := .RepoInfo.Knot }} 28 28 {{ if eq $knot "knot1.tangled.sh" }} 29 - {{ $knot = "tangled.sh" }} 29 + {{ $knot = "tangled.org" }} 30 30 {{ end }} 31 31 <div class="w-full flex place-content-center"> 32 32 <div class="py-6 w-fit flex flex-col gap-4"> ··· 35 35 36 36 <p><span class="{{$bullet}}">1</span>First, generate a new <a href="https://git-scm.com/book/en/v2/Git-on-the-Server-Generating-Your-SSH-Public-Key" class="underline">SSH key pair</a>.</p> 37 37 <p><span class="{{$bullet}}">2</span>Then add the public key to your account from the <a href="/settings" class="underline">settings</a> page.</p> 38 - <p><span class="{{$bullet}}">3</span>Configure your remote to <code>git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code></p> 38 + <p><span class="{{$bullet}}">3</span>Configure your remote to <code>git@{{ $knot | stripPort }}:{{ resolve .RepoInfo.OwnerDid }}/{{ .RepoInfo.Name }}</code></p> 39 39 <p><span class="{{$bullet}}">4</span>Push!</p> 40 40 </div> 41 41 </div>
+2 -1
appview/pages/templates/repo/fork.html
··· 25 25 value="{{ . }}" 26 26 class="mr-2" 27 27 id="domain-{{ . }}" 28 + {{if eq (len $.Knots) 1}}checked{{end}} 28 29 /> 29 30 <label for="domain-{{ . }}" class="dark:text-white">{{ . }}</label> 30 31 </div> ··· 33 34 {{ end }} 34 35 </div> 35 36 </div> 36 - <p class="text-sm text-gray-500 dark:text-gray-400">A knot hosts repository data. <a href="/knots" class="underline">Learn how to register your own knot.</a></p> 37 + <p class="text-sm text-gray-500 dark:text-gray-400">A knot hosts repository data. <a href="/settings/knots" class="underline">Learn how to register your own knot.</a></p> 37 38 </fieldset> 38 39 39 40 <div class="space-y-2">
+49
appview/pages/templates/repo/fragments/backlinks.html
··· 1 + {{ define "repo/fragments/backlinks" }} 2 + {{ if .Backlinks }} 3 + <div id="at-uri-panel" class="px-2 md:px-0"> 4 + <div> 5 + <span class="text-sm py-1 font-bold text-gray-500 dark:text-gray-400">Referenced by</span> 6 + </div> 7 + <ul> 8 + {{ range .Backlinks }} 9 + <li> 10 + {{ $repoOwner := resolve .Handle }} 11 + {{ $repoName := .Repo }} 12 + {{ $repoUrl := printf "%s/%s" $repoOwner $repoName }} 13 + <div class="flex flex-col"> 14 + <div class="flex gap-2 items-center"> 15 + {{ if .State.IsClosed }} 16 + <span class="text-gray-500 dark:text-gray-400"> 17 + {{ i "ban" "size-3" }} 18 + </span> 19 + {{ else if eq .Kind.String "issues" }} 20 + <span class="text-green-600 dark:text-green-500"> 21 + {{ i "circle-dot" "size-3" }} 22 + </span> 23 + {{ else if .State.IsOpen }} 24 + <span class="text-green-600 dark:text-green-500"> 25 + {{ i "git-pull-request" "size-3" }} 26 + </span> 27 + {{ else if .State.IsMerged }} 28 + <span class="text-purple-600 dark:text-purple-500"> 29 + {{ i "git-merge" "size-3" }} 30 + </span> 31 + {{ else }} 32 + <span class="text-gray-600 dark:text-gray-300"> 33 + {{ i "git-pull-request-closed" "size-3" }} 34 + </span> 35 + {{ end }} 36 + <a href="{{ . }}" class="line-clamp-1 text-sm"><span class="text-gray-500 dark:text-gray-400">#{{ .SubjectId }}</span> {{ .Title }}</a> 37 + </div> 38 + {{ if not (eq $.RepoInfo.FullName $repoUrl) }} 39 + <div> 40 + <span>on <a href="/{{ $repoUrl }}">{{ $repoUrl }}</a></span> 41 + </div> 42 + {{ end }} 43 + </div> 44 + </li> 45 + {{ end }} 46 + </ul> 47 + </div> 48 + {{ end }} 49 + {{ end }}
+5 -4
appview/pages/templates/repo/fragments/cloneDropdown.html
··· 29 29 <code 30 30 class="flex-1 px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-l select-all cursor-pointer whitespace-nowrap overflow-x-auto" 31 31 onclick="window.getSelection().selectAllChildren(this)" 32 - data-url="https://tangled.org/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}" 33 - >https://tangled.org/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}</code> 32 + data-url="https://tangled.org/{{ resolve .RepoInfo.OwnerDid }}/{{ .RepoInfo.Name }}" 33 + >https://tangled.org/{{ resolve .RepoInfo.OwnerDid }}/{{ .RepoInfo.Name }}</code> 34 34 <button 35 35 onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))" 36 36 class="px-3 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 border-l border-gray-300 dark:border-gray-600" ··· 43 43 44 44 <!-- SSH Clone --> 45 45 <div class="mb-3"> 46 + {{ $repoOwnerHandle := resolve .RepoInfo.OwnerDid }} 46 47 <label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">SSH</label> 47 48 <div class="flex items-center border border-gray-300 dark:border-gray-600 rounded"> 48 49 <code 49 50 class="flex-1 px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-l select-all cursor-pointer whitespace-nowrap overflow-x-auto" 50 51 onclick="window.getSelection().selectAllChildren(this)" 51 - data-url="git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}" 52 - >git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code> 52 + data-url="git@{{ $knot | stripPort }}:{{ $repoOwnerHandle }}/{{ .RepoInfo.Name }}" 53 + >git@{{ $knot | stripPort }}:{{ $repoOwnerHandle }}/{{ .RepoInfo.Name }}</code> 53 54 <button 54 55 onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))" 55 56 class="px-3 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 border-l border-gray-300 dark:border-gray-600"
+3 -4
appview/pages/templates/repo/fragments/diff.html
··· 1 1 {{ define "repo/fragments/diff" }} 2 - {{ $repo := index . 0 }} 3 - {{ $diff := index . 1 }} 4 - {{ $opts := index . 2 }} 2 + {{ $diff := index . 0 }} 3 + {{ $opts := index . 1 }} 5 4 6 5 {{ $commit := $diff.Commit }} 7 6 {{ $diff := $diff.Diff }} ··· 18 17 {{ else }} 19 18 {{ range $idx, $hunk := $diff }} 20 19 {{ with $hunk }} 21 - <details open id="file-{{ .Name.New }}" class="group border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm" tabindex="{{ add $idx 1 }}"> 20 + <details open id="file-{{ .Id }}" class="group border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm" tabindex="{{ add $idx 1 }}"> 22 21 <summary class="list-none cursor-pointer sticky top-0"> 23 22 <div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between"> 24 23 <div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto">
+20 -18
appview/pages/templates/repo/fragments/diffOpts.html
··· 5 5 {{ if .Split }} 6 6 {{ $active = "split" }} 7 7 {{ end }} 8 - {{ $values := list "unified" "split" }} 9 - {{ block "tabSelector" (dict "Name" "diff" "Values" $values "Active" $active) }} {{ end }} 8 + 9 + {{ $unified := 10 + (dict 11 + "Key" "unified" 12 + "Value" "unified" 13 + "Icon" "square-split-vertical" 14 + "Meta" "") }} 15 + {{ $split := 16 + (dict 17 + "Key" "split" 18 + "Value" "split" 19 + "Icon" "square-split-horizontal" 20 + "Meta" "") }} 21 + {{ $values := list $unified $split }} 22 + 23 + {{ template "fragments/tabSelector" 24 + (dict 25 + "Name" "diff" 26 + "Values" $values 27 + "Active" $active) }} 10 28 </section> 11 29 {{ end }} 12 30 13 - {{ define "tabSelector" }} 14 - {{ $name := .Name }} 15 - {{ $all := .Values }} 16 - {{ $active := .Active }} 17 - <div class="flex justify-between divide-x divide-gray-200 dark:divide-gray-700 rounded border border-gray-200 dark:border-gray-700 overflow-hidden"> 18 - {{ $activeTab := "bg-white dark:bg-gray-700 shadow-sm" }} 19 - {{ $inactiveTab := "bg-gray-100 dark:bg-gray-800 shadow-inner" }} 20 - {{ range $index, $value := $all }} 21 - {{ $isActive := eq $value $active }} 22 - <a href="?{{ $name }}={{ $value }}" 23 - class="py-2 text-sm w-full block hover:no-underline text-center {{ if $isActive }} {{$activeTab }} {{ else }} {{ $inactiveTab }} {{ end }}"> 24 - {{ $value }} 25 - </a> 26 - {{ end }} 27 - </div> 28 - {{ end }}
+15 -1
appview/pages/templates/repo/fragments/editLabelPanel.html
··· 170 170 {{ $fieldName := $def.AtUri }} 171 171 {{ $valueType := $def.ValueType }} 172 172 {{ $value := .value }} 173 + 173 174 {{ if $valueType.IsDidFormat }} 174 175 {{ $value = trimPrefix (resolve .value) "@" }} 176 + <actor-typeahead> 177 + <input 178 + autocapitalize="none" 179 + autocorrect="off" 180 + autocomplete="off" 181 + placeholder="user.tngl.sh" 182 + value="{{$value}}" 183 + name="{{$fieldName}}" 184 + type="text" 185 + class="p-1 w-full text-sm" 186 + /> 187 + </actor-typeahead> 188 + {{ else }} 189 + <input class="p-1 w-full" type="text" name="{{$fieldName}}" value="{{$value}}"> 175 190 {{ end }} 176 - <input class="p-1 w-full" type="text" name="{{$fieldName}}" value="{{$value}}"> 177 191 {{ end }} 178 192 179 193 {{ define "nullTypeInput" }}
-11
appview/pages/templates/repo/fragments/editRepoDescription.html
··· 1 - {{ define "repo/fragments/editRepoDescription" }} 2 - <form hx-put="/{{ .RepoInfo.FullName }}/description" hx-target="this" hx-swap="outerHTML" class="flex flex-wrap gap-2"> 3 - <input type="text" class="p-1" name="description" value="{{ .RepoInfo.Description }}"> 4 - <button type="submit" class="btn p-1 flex items-center gap-2 no-underline text-sm"> 5 - {{ i "check" "w-3 h-3" }} save 6 - </button> 7 - <button type="button" class="btn p-1 flex items-center gap-2 no-underline text-sm" hx-get="/{{ .RepoInfo.FullName }}/description" > 8 - {{ i "x" "w-3 h-3" }} cancel 9 - </button> 10 - </form> 11 - {{ end }}
+48
appview/pages/templates/repo/fragments/externalLinkPanel.html
··· 1 + {{ define "repo/fragments/externalLinkPanel" }} 2 + <div id="at-uri-panel" class="px-2 md:px-0"> 3 + <div class="flex justify-between items-center gap-2"> 4 + <span class="text-sm py-1 font-bold text-gray-500 dark:text-gray-400 capitalize">AT URI</span> 5 + <div class="flex items-center gap-2"> 6 + <button 7 + onclick="copyToClipboard(this)" 8 + class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200" 9 + title="Copy to clipboard"> 10 + {{ i "copy" "w-4 h-4" }} 11 + </button> 12 + <a 13 + href="https://pdsls.dev/{{.}}" 14 + class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200" 15 + title="View in PDSls"> 16 + {{ i "arrow-up-right" "w-4 h-4" }} 17 + </a> 18 + </div> 19 + </div> 20 + <span 21 + class="font-mono text-sm select-all cursor-pointer block max-w-full overflow-x-auto whitespace-nowrap scrollbar-thin scrollbar-thumb-gray-300 dark:scrollbar-thumb-gray-600" 22 + onclick="window.getSelection().selectAllChildren(this)" 23 + title="{{.}}" 24 + data-aturi="{{ . | string | safeUrl }}" 25 + >{{.}}</span> 26 + 27 + 28 + </div> 29 + 30 + <script> 31 + function copyToClipboard(button) { 32 + const container = document.getElementById("at-uri-panel"); 33 + const urlSpan = container?.querySelector('[data-aturi]'); 34 + const text = urlSpan?.getAttribute('data-aturi'); 35 + console.log("copying to clipboard", text) 36 + if (!text) return; 37 + 38 + navigator.clipboard.writeText(text).then(() => { 39 + const originalContent = button.innerHTML; 40 + button.innerHTML = `{{ i "check" "w-4 h-4" }}`; 41 + setTimeout(() => { 42 + button.innerHTML = originalContent; 43 + }, 2000); 44 + }); 45 + } 46 + </script> 47 + {{ end }} 48 +
+1 -16
appview/pages/templates/repo/fragments/participants.html
··· 6 6 <span class="font-bold text-gray-500 dark:text-gray-400 capitalize">Participants</span> 7 7 <span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 ml-1">{{ len $all }}</span> 8 8 </div> 9 - <div class="flex items-center -space-x-3 mt-2"> 10 - {{ $c := "z-50 z-40 z-30 z-20 z-10" }} 11 - {{ range $i, $p := $ps }} 12 - <img 13 - src="{{ tinyAvatar . }}" 14 - alt="" 15 - class="rounded-full h-8 w-8 mr-1 border-2 border-gray-100 dark:border-gray-900 z-{{sub 5 $i}}0" 16 - /> 17 - {{ end }} 18 - 19 - {{ if gt (len $all) 5 }} 20 - <span class="pl-4 text-gray-500 dark:text-gray-400 text-sm"> 21 - +{{ sub (len $all) 5 }} 22 - </span> 23 - {{ end }} 24 - </div> 9 + {{ template "fragments/tinyAvatarList" (dict "all" $all "classes" "w-8 h-8") }} 25 10 </div> 26 11 {{ end }}
-15
appview/pages/templates/repo/fragments/repoDescription.html
··· 1 - {{ define "repo/fragments/repoDescription" }} 2 - <span id="repo-description" class="flex flex-wrap items-center gap-2 text-sm" hx-target="this" hx-swap="outerHTML"> 3 - {{ if .RepoInfo.Description }} 4 - {{ .RepoInfo.Description | description }} 5 - {{ else }} 6 - <span class="italic">this repo has no description</span> 7 - {{ end }} 8 - 9 - {{ if .RepoInfo.Roles.IsOwner }} 10 - <button class="flex items-center gap-2 no-underline text-sm" hx-get="/{{ .RepoInfo.FullName }}/description/edit"> 11 - {{ i "pencil" "w-3 h-3" }} 12 - </button> 13 - {{ end }} 14 - </span> 15 - {{ end }}
-26
appview/pages/templates/repo/fragments/repoStar.html
··· 1 - {{ define "repo/fragments/repoStar" }} 2 - <button 3 - id="starBtn" 4 - class="btn disabled:opacity-50 disabled:cursor-not-allowed flex gap-2 items-center group" 5 - {{ if .IsStarred }} 6 - hx-delete="/star?subject={{ .RepoAt }}&countHint={{ .Stats.StarCount }}" 7 - {{ else }} 8 - hx-post="/star?subject={{ .RepoAt }}&countHint={{ .Stats.StarCount }}" 9 - {{ end }} 10 - 11 - hx-trigger="click" 12 - hx-target="this" 13 - hx-swap="outerHTML" 14 - hx-disabled-elt="#starBtn" 15 - > 16 - {{ if .IsStarred }} 17 - {{ i "star" "w-4 h-4 fill-current" }} 18 - {{ else }} 19 - {{ i "star" "w-4 h-4" }} 20 - {{ end }} 21 - <span class="text-sm"> 22 - {{ .Stats.StarCount }} 23 - </span> 24 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 25 - </button> 26 - {{ end }}
+35 -35
appview/pages/templates/repo/fragments/splitDiff.html
··· 3 3 {{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800" -}} 4 4 {{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}} 5 5 {{- $lineNrSepStyle := "pr-2 border-r border-gray-200 dark:border-gray-700" -}} 6 - {{- $containerStyle := "flex min-w-full items-center target:border target:rounded-sm target:border-yellow-200 target:dark:border-yellow-700 scroll-mt-20" -}} 6 + {{- $containerStyle := "inline-flex w-full items-center target:border target:rounded-sm target:border-yellow-200 target:dark:border-yellow-700 scroll-mt-20" -}} 7 7 {{- $emptyStyle := "bg-gray-200/30 dark:bg-gray-700/30" -}} 8 8 {{- $addStyle := "bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400" -}} 9 9 {{- $delStyle := "bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 " -}} 10 10 {{- $ctxStyle := "bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400" -}} 11 11 {{- $opStyle := "w-5 flex-shrink-0 select-none text-center" -}} 12 12 <div class="grid grid-cols-2 divide-x divide-gray-200 dark:divide-gray-700"> 13 - <pre class="overflow-x-auto col-span-1"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}<div class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">&middot;&middot;&middot;</div> 13 + <div class="overflow-x-auto col-span-1 font-mono leading-normal"><div class="overflow-x-auto"><div class="inline-flex flex-col min-w-full">{{- range .TextFragments -}}<span class="block bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">&middot;&middot;&middot;</span> 14 14 {{- range .LeftLines -}} 15 15 {{- if .IsEmpty -}} 16 - <div class="{{ $emptyStyle }} {{ $containerStyle }}"> 17 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><span aria-hidden="true" class="invisible">{{.LineNumber}}</span></div> 18 - <div class="{{ $opStyle }}"><span aria-hidden="true" class="invisible">{{ .Op.String }}</span></div> 19 - <div class="px-2 invisible" aria-hidden="true">{{ .Content }}</div> 20 - </div> 16 + <span class="{{ $emptyStyle }} {{ $containerStyle }}"> 17 + <span class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><span aria-hidden="true" class="invisible">{{.LineNumber}}</span></span> 18 + <span class="{{ $opStyle }}"><span aria-hidden="true" class="invisible">{{ .Op.String }}</span></span> 19 + <span class="px-2 invisible" aria-hidden="true">{{ .Content }}</span> 20 + </span> 21 21 {{- else if eq .Op.String "-" -}} 22 - <div class="{{ $delStyle }} {{ $containerStyle }}" id="{{$name}}-O{{.LineNumber}}"> 23 - <div class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{.LineNumber}}">{{ .LineNumber }}</a></div> 24 - <div class="{{ $opStyle }}">{{ .Op.String }}</div> 25 - <div class="px-2">{{ .Content }}</div> 26 - </div> 22 + <span class="{{ $delStyle }} {{ $containerStyle }}" id="{{$name}}-O{{.LineNumber}}"> 23 + <span class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{.LineNumber}}">{{ .LineNumber }}</a></span> 24 + <span class="{{ $opStyle }}">{{ .Op.String }}</span> 25 + <span class="px-2 whitespace-pre">{{ .Content }}</span> 26 + </span> 27 27 {{- else if eq .Op.String " " -}} 28 - <div class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-O{{.LineNumber}}"> 29 - <div class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{.LineNumber}}">{{ .LineNumber }}</a></div> 30 - <div class="{{ $opStyle }}">{{ .Op.String }}</div> 31 - <div class="px-2">{{ .Content }}</div> 32 - </div> 28 + <span class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-O{{.LineNumber}}"> 29 + <span class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{.LineNumber}}">{{ .LineNumber }}</a></span> 30 + <span class="{{ $opStyle }}">{{ .Op.String }}</span> 31 + <span class="px-2 whitespace-pre">{{ .Content }}</span> 32 + </span> 33 33 {{- end -}} 34 34 {{- end -}} 35 - {{- end -}}</div></div></pre> 35 + {{- end -}}</div></div></div> 36 36 37 - <pre class="overflow-x-auto col-span-1"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}<div class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">&middot;&middot;&middot;</div> 37 + <div class="overflow-x-auto col-span-1 font-mono leading-normal"><div class="overflow-x-auto"><div class="inline-flex flex-col min-w-full">{{- range .TextFragments -}}<span class="block bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">&middot;&middot;&middot;</span> 38 38 {{- range .RightLines -}} 39 39 {{- if .IsEmpty -}} 40 - <div class="{{ $emptyStyle }} {{ $containerStyle }}"> 41 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><span aria-hidden="true" class="invisible">{{.LineNumber}}</span></div> 42 - <div class="{{ $opStyle }}"><span aria-hidden="true" class="invisible">{{ .Op.String }}</span></div> 43 - <div class="px-2 invisible" aria-hidden="true">{{ .Content }}</div> 44 - </div> 40 + <span class="{{ $emptyStyle }} {{ $containerStyle }}"> 41 + <span class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><span aria-hidden="true" class="invisible">{{.LineNumber}}</span></span> 42 + <span class="{{ $opStyle }}"><span aria-hidden="true" class="invisible">{{ .Op.String }}</span></span> 43 + <span class="px-2 invisible" aria-hidden="true">{{ .Content }}</span> 44 + </span> 45 45 {{- else if eq .Op.String "+" -}} 46 - <div class="{{ $addStyle }} {{ $containerStyle }}" id="{{$name}}-N{{.LineNumber}}"> 47 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{.LineNumber}}">{{ .LineNumber }}</a></div> 48 - <div class="{{ $opStyle }}">{{ .Op.String }}</div> 49 - <div class="px-2" >{{ .Content }}</div> 50 - </div> 46 + <span class="{{ $addStyle }} {{ $containerStyle }}" id="{{$name}}-N{{.LineNumber}}"> 47 + <span class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{.LineNumber}}">{{ .LineNumber }}</a></span> 48 + <span class="{{ $opStyle }}">{{ .Op.String }}</span> 49 + <span class="px-2 whitespace-pre">{{ .Content }}</span> 50 + </span> 51 51 {{- else if eq .Op.String " " -}} 52 - <div class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-N{{.LineNumber}}"> 53 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{.LineNumber}}">{{ .LineNumber }}</a></div> 54 - <div class="{{ $opStyle }}">{{ .Op.String }}</div> 55 - <div class="px-2">{{ .Content }}</div> 56 - </div> 52 + <span class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-N{{.LineNumber}}"> 53 + <span class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{.LineNumber}}">{{ .LineNumber }}</a> </span> 54 + <span class="{{ $opStyle }}">{{ .Op.String }}</span> 55 + <span class="px-2 whitespace-pre">{{ .Content }}</span> 56 + </span> 57 57 {{- end -}} 58 58 {{- end -}} 59 - {{- end -}}</div></div></pre> 59 + {{- end -}}</div></div></div> 60 60 </div> 61 61 {{ end }}
+21 -22
appview/pages/templates/repo/fragments/unifiedDiff.html
··· 1 1 {{ define "repo/fragments/unifiedDiff" }} 2 2 {{ $name := .Id }} 3 - <pre class="overflow-x-auto"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}<div class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">&middot;&middot;&middot;</div> 3 + <div class="overflow-x-auto font-mono leading-normal"><div class="overflow-x-auto"><div class="inline-flex flex-col min-w-full">{{- range .TextFragments -}}<span class="block bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">&middot;&middot;&middot;</span> 4 4 {{- $oldStart := .OldPosition -}} 5 5 {{- $newStart := .NewPosition -}} 6 6 {{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800 target:bg-yellow-200 target:dark:bg-yellow-600" -}} 7 7 {{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}} 8 8 {{- $lineNrSepStyle1 := "" -}} 9 9 {{- $lineNrSepStyle2 := "pr-2 border-r border-gray-200 dark:border-gray-700" -}} 10 - {{- $containerStyle := "flex min-w-full items-center target:border target:rounded-sm target:border-yellow-200 target:dark:border-yellow-700 scroll-mt-20" -}} 10 + {{- $containerStyle := "inline-flex w-full items-center target:border target:rounded-sm target:border-yellow-200 target:dark:border-yellow-700 scroll-mt-20" -}} 11 11 {{- $addStyle := "bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400 " -}} 12 12 {{- $delStyle := "bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 " -}} 13 13 {{- $ctxStyle := "bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400" -}} 14 14 {{- $opStyle := "w-5 flex-shrink-0 select-none text-center" -}} 15 15 {{- range .Lines -}} 16 16 {{- if eq .Op.String "+" -}} 17 - <div class="{{ $addStyle }} {{ $containerStyle }}" id="{{$name}}-N{{$newStart}}"> 18 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><span aria-hidden="true" class="invisible">{{$newStart}}</span></div> 19 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></div> 20 - <div class="{{ $opStyle }}">{{ .Op.String }}</div> 21 - <div class="px-2">{{ .Line }}</div> 22 - </div> 17 + <span class="{{ $addStyle }} {{ $containerStyle }}" id="{{$name}}-N{{$newStart}}"> 18 + <span class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><span aria-hidden="true" class="invisible">{{$newStart}}</span></span> 19 + <span class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></span> 20 + <span class="{{ $opStyle }}">{{ .Op.String }}</span> 21 + <span class="px-2 whitespace-pre">{{ .Line }}</span> 22 + </span> 23 23 {{- $newStart = add64 $newStart 1 -}} 24 24 {{- end -}} 25 25 {{- if eq .Op.String "-" -}} 26 - <div class="{{ $delStyle }} {{ $containerStyle }}" id="{{$name}}-O{{$oldStart}}"> 27 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></div> 28 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><span aria-hidden="true" class="invisible">{{$oldStart}}</span></div> 29 - <div class="{{ $opStyle }}">{{ .Op.String }}</div> 30 - <div class="px-2">{{ .Line }}</div> 31 - </div> 26 + <span class="{{ $delStyle }} {{ $containerStyle }}" id="{{$name}}-O{{$oldStart}}"> 27 + <span class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></span> 28 + <span class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><span aria-hidden="true" class="invisible">{{$oldStart}}</span></span> 29 + <span class="{{ $opStyle }}">{{ .Op.String }}</span> 30 + <span class="px-2 whitespace-pre">{{ .Line }}</span> 31 + </span> 32 32 {{- $oldStart = add64 $oldStart 1 -}} 33 33 {{- end -}} 34 34 {{- if eq .Op.String " " -}} 35 - <div class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-O{{$oldStart}}-N{{$newStart}}"> 36 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}-N{{$newStart}}">{{ $oldStart }}</a></div> 37 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}-N{{$newStart}}">{{ $newStart }}</a></div> 38 - <div class="{{ $opStyle }}">{{ .Op.String }}</div> 39 - <div class="px-2">{{ .Line }}</div> 40 - </div> 35 + <span class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-O{{$oldStart}}-N{{$newStart}}"> 36 + <span class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}-N{{$newStart}}">{{ $oldStart }}</a></span> 37 + <span class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}-N{{$newStart}}">{{ $newStart }}</a></span> 38 + <span class="{{ $opStyle }}">{{ .Op.String }}</span> 39 + <span class="px-2 whitespace-pre">{{ .Line }}</span> 40 + </span> 41 41 {{- $newStart = add64 $newStart 1 -}} 42 42 {{- $oldStart = add64 $oldStart 1 -}} 43 43 {{- end -}} 44 44 {{- end -}} 45 - {{- end -}}</div></div></pre> 45 + {{- end -}}</div></div></div> 46 46 {{ end }} 47 -
+39 -20
appview/pages/templates/repo/index.html
··· 14 14 {{ end }} 15 15 <div class="flex items-center justify-between pb-5"> 16 16 {{ block "branchSelector" . }}{{ end }} 17 - <div class="flex md:hidden items-center gap-2"> 17 + <div class="flex md:hidden items-center gap-3"> 18 18 <a href="/{{ .RepoInfo.FullName }}/commits/{{ .Ref | urlquery }}" class="inline-flex items-center text-sm gap-1 font-bold"> 19 19 {{ i "git-commit-horizontal" "w-4" "h-4" }} {{ .TotalCommits }} 20 20 </a> ··· 35 35 {{ end }} 36 36 37 37 {{ define "repoLanguages" }} 38 - <details class="group -m-6 mb-4"> 38 + <details class="group -my-4 -m-6 mb-4"> 39 39 <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"> 40 40 {{ range $value := .Languages }} 41 41 <div ··· 47 47 <div class="px-4 py-2 border-b border-gray-200 dark:border-gray-600 flex items-center gap-4 flex-wrap"> 48 48 {{ range $value := .Languages }} 49 49 <div 50 - class="flex flex-grow items-center gap-2 text-xs align-items-center justify-center" 50 + class="flex items-center gap-2 text-xs align-items-center justify-center" 51 51 > 52 52 {{ template "repo/fragments/colorBall" (dict "color" (langColor $value.Name)) }} 53 53 <div>{{ or $value.Name "Other" }} ··· 66 66 67 67 {{ define "branchSelector" }} 68 68 <div class="flex gap-2 items-center justify-between w-full"> 69 - <div class="flex gap-2 items-center"> 69 + <div class="flex gap-2 items-stretch"> 70 70 <select 71 71 onchange="window.location.href = '/{{ .RepoInfo.FullName }}/tree/' + encodeURIComponent(this.value)" 72 72 class="p-1 border max-w-32 border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700" ··· 129 129 {{ $icon := "folder" }} 130 130 {{ $iconStyle := "size-4 fill-current" }} 131 131 132 + {{ if .IsSubmodule }} 133 + {{ $link = printf "/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) .Name }} 134 + {{ $icon = "folder-input" }} 135 + {{ $iconStyle = "size-4" }} 136 + {{ end }} 137 + 132 138 {{ if .IsFile }} 133 139 {{ $link = printf "/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) .Name }} 134 140 {{ $icon = "file" }} 135 141 {{ $iconStyle = "size-4" }} 136 142 {{ end }} 143 + 137 144 <a href="{{ $link }}" class="{{ $linkstyle }}"> 138 145 <div class="flex items-center gap-2"> 139 146 {{ i $icon $iconStyle "flex-shrink-0" }} ··· 221 228 <span 222 229 class="mx-1 before:content-['ยท'] before:select-none" 223 230 ></span> 224 - <span> 225 - {{ $didOrHandle := index $.EmailToDidOrHandle .Author.Email }} 226 - <a 227 - href="{{ if $didOrHandle }} 228 - /{{ $didOrHandle }} 229 - {{ else }} 230 - mailto:{{ .Author.Email }} 231 - {{ end }}" 232 - class="text-gray-500 dark:text-gray-400 no-underline hover:underline" 233 - >{{ if $didOrHandle }} 234 - {{ template "user/fragments/picHandleLink" $didOrHandle }} 235 - {{ else }} 236 - {{ .Author.Name }} 237 - {{ end }}</a 238 - > 239 - </span> 231 + {{ template "attribution" (list . $.EmailToDid) }} 240 232 <div class="inline-block px-1 select-none after:content-['ยท']"></div> 241 233 {{ template "repo/fragments/time" .Committer.When }} 242 234 ··· 262 254 {{ end }} 263 255 </div> 264 256 </div> 257 + {{ end }} 258 + 259 + {{ define "attribution" }} 260 + {{ $commit := index . 0 }} 261 + {{ $map := index . 1 }} 262 + <span class="flex items-center"> 263 + {{ $author := index $map $commit.Author.Email }} 264 + {{ $coauthors := $commit.CoAuthors }} 265 + {{ $all := list }} 266 + 267 + {{ if $author }} 268 + {{ $all = append $all $author }} 269 + {{ end }} 270 + {{ range $coauthors }} 271 + {{ $co := index $map .Email }} 272 + {{ if $co }} 273 + {{ $all = append $all $co }} 274 + {{ end }} 275 + {{ end }} 276 + 277 + {{ template "fragments/tinyAvatarList" (dict "all" $all "classes" "size-6") }} 278 + <a href="{{ if $author }}/{{ $author }}{{ else }}mailto:{{ $commit.Author.Email }}{{ end }}" 279 + class="no-underline hover:underline"> 280 + {{ if $author }}{{ resolve $author }}{{ else }}{{ $commit.Author.Name }}{{ end }} 281 + {{ if $coauthors }} +{{ length $coauthors }}{{ end }} 282 + </a> 283 + </span> 265 284 {{ end }} 266 285 267 286 {{ define "branchList" }}
+2 -2
appview/pages/templates/repo/issues/fragments/issueCommentHeader.html
··· 19 19 {{ end }} 20 20 21 21 {{ define "timestamp" }} 22 - <a href="#{{ .Comment.Id }}" 22 + <a href="#comment-{{ .Comment.Id }}" 23 23 class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-400 hover:underline no-underline" 24 - id="{{ .Comment.Id }}"> 24 + id="comment-{{ .Comment.Id }}"> 25 25 {{ if .Comment.Deleted }} 26 26 {{ template "repo/fragments/shortTimeAgo" .Comment.Deleted }} 27 27 {{ else if .Comment.Edited }}
+1 -1
appview/pages/templates/repo/issues/fragments/issueListing.html
··· 8 8 class="no-underline hover:underline" 9 9 > 10 10 {{ .Title | description }} 11 - <span class="text-gray-500">#{{ .IssueId }}</span> 11 + <span class="text-gray-500 dark:text-gray-400">#{{ .IssueId }}</span> 12 12 </a> 13 13 </div> 14 14 <div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1">
+4
appview/pages/templates/repo/issues/issue.html
··· 20 20 "Subject" $.Issue.AtUri 21 21 "State" $.Issue.Labels) }} 22 22 {{ template "repo/fragments/participants" $.Issue.Participants }} 23 + {{ template "repo/fragments/backlinks" 24 + (dict "RepoInfo" $.RepoInfo 25 + "Backlinks" $.Backlinks) }} 26 + {{ template "repo/fragments/externalLinkPanel" $.Issue.AtUri }} 23 27 </div> 24 28 </div> 25 29 {{ end }}
+146 -53
appview/pages/templates/repo/issues/issues.html
··· 8 8 {{ end }} 9 9 10 10 {{ define "repoContent" }} 11 - <div class="flex justify-between items-center gap-4"> 12 - <div class="flex gap-4"> 13 - <a 14 - href="?state=open" 15 - class="flex items-center gap-2 {{ if .FilteringByOpen }}font-bold {{ else }}text-gray-500 dark:text-gray-400{{ end }}" 11 + {{ $active := "closed" }} 12 + {{ if .FilteringByOpen }} 13 + {{ $active = "open" }} 14 + {{ end }} 15 + 16 + {{ $open := 17 + (dict 18 + "Key" "open" 19 + "Value" "open" 20 + "Icon" "circle-dot" 21 + "Meta" (string .RepoInfo.Stats.IssueCount.Open)) }} 22 + {{ $closed := 23 + (dict 24 + "Key" "closed" 25 + "Value" "closed" 26 + "Icon" "ban" 27 + "Meta" (string .RepoInfo.Stats.IssueCount.Closed)) }} 28 + {{ $values := list $open $closed }} 29 + 30 + <div class="grid gap-2 grid-cols-[auto_1fr_auto] grid-row-2"> 31 + <form class="flex relative col-span-3 sm:col-span-1 sm:col-start-2" method="GET"> 32 + <input type="hidden" name="state" value="{{ if .FilteringByOpen }}open{{ else }}closed{{ end }}"> 33 + <div class="flex-1 flex relative"> 34 + <input 35 + id="search-q" 36 + class="flex-1 py-1 pl-2 pr-10 mr-[-1px] rounded-r-none focus:border-0 focus:outline-none focus:ring focus:ring-blue-400 ring-inset peer" 37 + type="text" 38 + name="q" 39 + value="{{ .FilterQuery }}" 40 + placeholder=" " 16 41 > 17 - {{ i "circle-dot" "w-4 h-4" }} 18 - <span>{{ .RepoInfo.Stats.IssueCount.Open }} open</span> 19 - </a> 20 - <a 21 - href="?state=closed" 22 - class="flex items-center gap-2 {{ if not .FilteringByOpen }}font-bold {{ else }}text-gray-500 dark:text-gray-400{{ end }}" 42 + <a 43 + href="?state={{ if .FilteringByOpen }}open{{ else }}closed{{ end }}" 44 + 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" 23 45 > 24 - {{ i "ban" "w-4 h-4" }} 25 - <span>{{ .RepoInfo.Stats.IssueCount.Closed }} closed</span> 26 - </a> 27 - <form class="flex gap-4" method="GET"> 28 - <input type="hidden" name="state" value="{{ if .FilteringByOpen }}open{{ else }}closed{{ end }}"> 29 - <input class="" type="text" name="q" value="{{ .FilterQuery }}"> 30 - <button class="btn" type="submit"> 31 - search 32 - </button> 46 + {{ i "x" "w-4 h-4" }} 47 + </a> 48 + </div> 49 + <button 50 + type="submit" 51 + class="p-2 text-gray-400 border rounded-r border-gray-400 dark:border-gray-600" 52 + > 53 + {{ i "search" "w-4 h-4" }} 54 + </button> 33 55 </form> 34 - </div> 35 - <a 56 + <div class="sm:row-start-1"> 57 + {{ template "fragments/tabSelector" (dict "Name" "state" "Values" $values "Active" $active "Include" "#search-q") }} 58 + </div> 59 + <a 36 60 href="/{{ .RepoInfo.FullName }}/issues/new" 37 - class="btn-create text-sm flex items-center justify-center gap-2 no-underline hover:no-underline hover:text-white" 38 - > 61 + class="col-start-3 btn-create text-sm flex items-center justify-center gap-2 no-underline hover:no-underline hover:text-white" 62 + > 39 63 {{ i "circle-plus" "w-4 h-4" }} 40 64 <span>new</span> 41 - </a> 42 - </div> 43 - <div class="error" id="issues"></div> 65 + </a> 66 + </div> 67 + <div class="error" id="issues"></div> 44 68 {{ end }} 45 69 46 70 {{ define "repoAfter" }} 47 71 <div class="mt-2"> 48 72 {{ template "repo/issues/fragments/issueListing" (dict "Issues" .Issues "RepoPrefix" .RepoInfo.FullName "LabelDefs" .LabelDefs) }} 49 73 </div> 50 - {{ block "pagination" . }} {{ end }} 74 + {{if gt .IssueCount .Page.Limit }} 75 + {{ block "pagination" . }} {{ end }} 76 + {{ end }} 51 77 {{ end }} 52 78 53 79 {{ define "pagination" }} 54 - <div class="flex justify-end mt-4 gap-2"> 55 - {{ $currentState := "closed" }} 56 - {{ if .FilteringByOpen }} 57 - {{ $currentState = "open" }} 58 - {{ end }} 80 + <div class="flex justify-center items-center mt-4 gap-2"> 81 + {{ $currentState := "closed" }} 82 + {{ if .FilteringByOpen }} 83 + {{ $currentState = "open" }} 84 + {{ end }} 85 + 86 + {{ $prev := .Page.Previous.Offset }} 87 + {{ $next := .Page.Next.Offset }} 88 + {{ $lastPage := sub .IssueCount (mod .IssueCount .Page.Limit) }} 59 89 90 + <a 91 + class=" 92 + btn flex items-center gap-2 no-underline hover:no-underline 93 + dark:text-white dark:hover:bg-gray-700 94 + {{ if le .Page.Offset 0 }} 95 + cursor-not-allowed opacity-50 96 + {{ end }} 97 + " 60 98 {{ if gt .Page.Offset 0 }} 61 - {{ $prev := .Page.Previous }} 62 - <a 63 - class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 64 - hx-boost="true" 65 - href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&q={{ .FilterQuery }}&offset={{ $prev.Offset }}&limit={{ $prev.Limit }}" 66 - > 67 - {{ i "chevron-left" "w-4 h-4" }} 68 - previous 69 - </a> 70 - {{ else }} 71 - <div></div> 99 + hx-boost="true" 100 + href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&q={{ .FilterQuery }}&offset={{ $prev }}&limit={{ .Page.Limit }}" 72 101 {{ end }} 102 + > 103 + {{ i "chevron-left" "w-4 h-4" }} 104 + previous 105 + </a> 73 106 107 + <!-- dont show first page if current page is first page --> 108 + {{ if gt .Page.Offset 0 }} 109 + <a 110 + hx-boost="true" 111 + href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&q={{ .FilterQuery }}&offset=0&limit={{ .Page.Limit }}" 112 + > 113 + 1 114 + </a> 115 + {{ end }} 116 + 117 + <!-- if previous page is not first or second page (prev > limit) --> 118 + {{ if gt $prev .Page.Limit }} 119 + <span>...</span> 120 + {{ end }} 121 + 122 + <!-- if previous page is not the first page --> 123 + {{ if gt $prev 0 }} 124 + <a 125 + hx-boost="true" 126 + href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&q={{ .FilterQuery }}&offset={{ $prev }}&limit={{ .Page.Limit }}" 127 + > 128 + {{ add (div $prev .Page.Limit) 1 }} 129 + </a> 130 + {{ end }} 131 + 132 + <!-- current page. this is always visible --> 133 + <span class="font-bold"> 134 + {{ add (div .Page.Offset .Page.Limit) 1 }} 135 + </span> 136 + 137 + <!-- if next page is not last page --> 138 + {{ if lt $next $lastPage }} 139 + <a 140 + hx-boost="true" 141 + href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&q={{ .FilterQuery }}&offset={{ $next }}&limit={{ .Page.Limit }}" 142 + > 143 + {{ add (div $next .Page.Limit) 1 }} 144 + </a> 145 + {{ end }} 146 + 147 + <!-- if next page is not second last or last page (next < issues - 2 * limit) --> 148 + {{ if lt ($next) (sub .IssueCount (mul (2) .Page.Limit)) }} 149 + <span>...</span> 150 + {{ end }} 151 + 152 + <!-- if its not the last page --> 153 + {{ if lt .Page.Offset $lastPage }} 154 + <a 155 + hx-boost="true" 156 + href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&q={{ .FilterQuery }}&offset={{ $lastPage }}&limit={{ .Page.Limit }}" 157 + > 158 + {{ add (div $lastPage .Page.Limit) 1 }} 159 + </a> 160 + {{ end }} 161 + 162 + <a 163 + class=" 164 + btn flex items-center gap-2 no-underline hover:no-underline 165 + dark:text-white dark:hover:bg-gray-700 166 + {{ if ne (len .Issues) .Page.Limit }} 167 + cursor-not-allowed opacity-50 168 + {{ end }} 169 + " 74 170 {{ if eq (len .Issues) .Page.Limit }} 75 - {{ $next := .Page.Next }} 76 - <a 77 - class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 78 - hx-boost="true" 79 - href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&q={{ .FilterQuery }}&offset={{ $next.Offset }}&limit={{ $next.Limit }}" 80 - > 81 - next 82 - {{ i "chevron-right" "w-4 h-4" }} 83 - </a> 171 + hx-boost="true" 172 + href="/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&q={{ .FilterQuery }}&offset={{ $next }}&limit={{ .Page.Limit }}" 84 173 {{ end }} 174 + > 175 + next 176 + {{ i "chevron-right" "w-4 h-4" }} 177 + </a> 85 178 </div> 86 179 {{ end }}
+40 -23
appview/pages/templates/repo/log.html
··· 17 17 <div class="hidden md:flex md:flex-col divide-y divide-gray-200 dark:divide-gray-700"> 18 18 {{ $grid := "grid grid-cols-14 gap-4" }} 19 19 <div class="{{ $grid }}"> 20 - <div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-2">Author</div> 20 + <div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-3">Author</div> 21 21 <div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-3">Commit</div> 22 22 <div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-6">Message</div> 23 - <div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-1"></div> 24 23 <div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-2 justify-self-end">Date</div> 25 24 </div> 26 25 {{ range $index, $commit := .Commits }} 27 26 {{ $messageParts := splitN $commit.Message "\n\n" 2 }} 28 27 <div class="{{ $grid }} py-3"> 29 - <div class="align-top truncate col-span-2"> 30 - {{ $didOrHandle := index $.EmailToDidOrHandle $commit.Author.Email }} 31 - {{ if $didOrHandle }} 32 - {{ template "user/fragments/picHandleLink" $didOrHandle }} 33 - {{ else }} 34 - <a href="mailto:{{ $commit.Author.Email }}" class="text-gray-700 dark:text-gray-300 no-underline hover:underline">{{ $commit.Author.Name }}</a> 35 - {{ end }} 28 + <div class="align-top col-span-3"> 29 + {{ template "attribution" (list $commit $.EmailToDid) }} 36 30 </div> 37 31 <div class="align-top font-mono flex items-start col-span-3"> 38 32 {{ $verified := $.VerifiedCommits.IsVerified $commit.Hash.String }} ··· 61 55 <div class="align-top col-span-6"> 62 56 <div> 63 57 <a href="/{{ $.RepoInfo.FullName }}/commit/{{ $commit.Hash.String }}" class="dark:text-white no-underline hover:underline">{{ index $messageParts 0 }}</a> 58 + 64 59 {{ if gt (len $messageParts) 1 }} 65 60 <button class="py-1/2 px-1 bg-gray-200 hover:bg-gray-400 dark:bg-gray-700 dark:hover:bg-gray-600 rounded" hx-on:click="this.parentElement.nextElementSibling.classList.toggle('hidden')">{{ i "ellipsis" "w-3 h-3" }}</button> 66 61 {{ end }} ··· 72 67 </span> 73 68 {{ end }} 74 69 {{ end }} 70 + 71 + <!-- ci status --> 72 + <span class="text-xs"> 73 + {{ $pipeline := index $.Pipelines .Hash.String }} 74 + {{ if and $pipeline (gt (len $pipeline.Statuses) 0) }} 75 + {{ template "repo/pipelines/fragments/pipelineSymbolLong" (dict "Pipeline" $pipeline "RepoInfo" $.RepoInfo) }} 76 + {{ end }} 77 + </span> 75 78 </div> 76 79 77 80 {{ if gt (len $messageParts) 1 }} 78 81 <p class="hidden mt-1 text-sm text-gray-600 dark:text-gray-400">{{ nl2br (index $messageParts 1) }}</p> 79 82 {{ end }} 80 - </div> 81 - <div class="align-top col-span-1"> 82 - <!-- ci status --> 83 - {{ $pipeline := index $.Pipelines .Hash.String }} 84 - {{ if and $pipeline (gt (len $pipeline.Statuses) 0) }} 85 - {{ template "repo/pipelines/fragments/pipelineSymbolLong" (dict "Pipeline" $pipeline "RepoInfo" $.RepoInfo) }} 86 - {{ end }} 87 83 </div> 88 84 <div class="align-top justify-self-end text-gray-500 dark:text-gray-400 col-span-2">{{ template "repo/fragments/shortTimeAgo" $commit.Committer.When }}</div> 89 85 </div> ··· 152 148 </a> 153 149 </span> 154 150 <span class="mx-2 before:content-['ยท'] before:select-none"></span> 155 - <span> 156 - {{ $didOrHandle := index $.EmailToDidOrHandle $commit.Author.Email }} 157 - <a href="{{ if $didOrHandle }}/{{ $didOrHandle }}{{ else }}mailto:{{ $commit.Author.Email }}{{ end }}" 158 - class="text-gray-500 dark:text-gray-400 no-underline hover:underline"> 159 - {{ if $didOrHandle }}{{ template "user/fragments/picHandleLink" $didOrHandle }}{{ else }}{{ $commit.Author.Name }}{{ end }} 160 - </a> 161 - </span> 151 + {{ template "attribution" (list $commit $.EmailToDid) }} 162 152 <div class="inline-block px-1 select-none after:content-['ยท']"></div> 163 153 <span>{{ template "repo/fragments/shortTime" $commit.Committer.When }}</span> 164 154 ··· 176 166 </div> 177 167 </section> 178 168 169 + {{ end }} 170 + 171 + {{ define "attribution" }} 172 + {{ $commit := index . 0 }} 173 + {{ $map := index . 1 }} 174 + <span class="flex items-center gap-1"> 175 + {{ $author := index $map $commit.Author.Email }} 176 + {{ $coauthors := $commit.CoAuthors }} 177 + {{ $all := list }} 178 + 179 + {{ if $author }} 180 + {{ $all = append $all $author }} 181 + {{ end }} 182 + {{ range $coauthors }} 183 + {{ $co := index $map .Email }} 184 + {{ if $co }} 185 + {{ $all = append $all $co }} 186 + {{ end }} 187 + {{ end }} 188 + 189 + {{ template "fragments/tinyAvatarList" (dict "all" $all "classes" "size-6") }} 190 + <a href="{{ if $author }}/{{ $author }}{{ else }}mailto:{{ $commit.Author.Email }}{{ end }}" 191 + class="no-underline hover:underline"> 192 + {{ if $author }}{{ resolve $author }}{{ else }}{{ $commit.Author.Name }}{{ end }} 193 + {{ if $coauthors }} +{{ length $coauthors }}{{ end }} 194 + </a> 195 + </span> 179 196 {{ end }} 180 197 181 198 {{ define "repoAfter" }}
+2 -1
appview/pages/templates/repo/new.html
··· 155 155 class="mr-2" 156 156 id="domain-{{ . }}" 157 157 required 158 + {{if eq (len $.Knots) 1}}checked{{end}} 158 159 /> 159 160 <label for="domain-{{ . }}" class="dark:text-white lowercase">{{ . }}</label> 160 161 </div> ··· 164 165 </div> 165 166 <p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> 166 167 A knot hosts repository data and handles Git operations. 167 - You can also <a href="/knots" class="underline">register your own knot</a>. 168 + You can also <a href="/settings/knots" class="underline">register your own knot</a>. 168 169 </p> 169 170 </div> 170 171 {{ end }}
+7 -6
appview/pages/templates/repo/pipelines/fragments/logBlock.html
··· 2 2 <div id="lines" hx-swap-oob="beforeend"> 3 3 <details id="step-{{ .Id }}" {{if not .Collapsed}}open{{end}} class="group pb-2 rounded-sm border border-gray-200 dark:border-gray-700"> 4 4 <summary class="sticky top-0 pt-2 px-2 group-open:pb-2 group-open:mb-2 list-none cursor-pointer group-open:border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:text-gray-500 hover:dark:text-gray-400"> 5 - <div class="group-open:hidden flex items-center gap-1"> 6 - {{ i "chevron-right" "w-4 h-4" }} {{ .Name }} 7 - </div> 8 - <div class="hidden group-open:flex items-center gap-1"> 9 - {{ i "chevron-down" "w-4 h-4" }} {{ .Name }} 10 - </div> 5 + <div class="group-open:hidden flex items-center gap-1">{{ i "chevron-right" "w-4 h-4" }} {{ template "stepHeader" . }}</div> 6 + <div class="hidden group-open:flex items-center gap-1">{{ i "chevron-down" "w-4 h-4" }} {{ template "stepHeader" . }}</div> 11 7 </summary> 12 8 <div class="font-mono whitespace-pre overflow-x-auto px-2"><div class="text-blue-600 dark:text-blue-300">{{ .Command }}</div><div id="step-body-{{ .Id }}"></div></div> 13 9 </details> 14 10 </div> 15 11 {{ end }} 12 + 13 + {{ define "stepHeader" }} 14 + {{ .Name }} 15 + <span class="ml-auto text-sm text-gray-500 tabular-nums" data-timer="{{ .Id }}" data-start="{{ .StartTime.Unix }}"></span> 16 + {{ end }}
+9
appview/pages/templates/repo/pipelines/fragments/logBlockEnd.html
··· 1 + {{ define "repo/pipelines/fragments/logBlockEnd" }} 2 + <span 3 + class="ml-auto text-sm text-gray-500 tabular-nums" 4 + data-timer="{{ .Id }}" 5 + data-start="{{ .StartTime.Unix }}" 6 + data-end="{{ .EndTime.Unix }}" 7 + hx-swap-oob="outerHTML:[data-timer='{{ .Id }}']"></span> 8 + {{ end }} 9 +
+15 -3
appview/pages/templates/repo/pipelines/pipelines.html
··· 12 12 {{ range .Pipelines }} 13 13 {{ block "pipeline" (list $ .) }} {{ end }} 14 14 {{ else }} 15 - <p class="text-center pt-5 text-gray-400 dark:text-gray-500"> 16 - No pipelines run for this repository. 17 - </p> 15 + <div class="py-6 w-fit flex flex-col gap-4 mx-auto"> 16 + <p> 17 + No pipelines have been run for this repository yet. To get started: 18 + </p> 19 + {{ $bullet := "mx-2 text-xs bg-gray-200 dark:bg-gray-600 rounded-full size-5 flex items-center justify-center font-mono inline-flex align-middle" }} 20 + <p> 21 + <span class="{{ $bullet }}">1</span>First, choose a spindle in your 22 + <a href="/{{ .RepoInfo.FullName }}/settings?tab=pipelines" class="underline">repository settings</a>. 23 + </p> 24 + <p> 25 + <span class="{{ $bullet }}">2</span>Configure your CI/CD 26 + <a href="https://docs.tangled.org/spindles.html#pipelines" class="underline">pipeline</a>. 27 + </p> 28 + <p><span class="{{ $bullet }}">3</span>Trigger a workflow with a push or a pull-request!</p> 29 + </div> 18 30 {{ end }} 19 31 </div> 20 32 </div>
+6
appview/pages/templates/repo/pipelines/workflow.html
··· 15 15 {{ block "logs" . }} {{ end }} 16 16 </div> 17 17 </section> 18 + {{ template "fragments/workflow-timers" }} 18 19 {{ end }} 19 20 20 21 {{ define "sidebar" }} ··· 58 59 hx-ext="ws" 59 60 ws-connect="/{{ $.RepoInfo.FullName }}/pipelines/{{ .Pipeline.Id }}/workflow/{{ .Workflow }}/logs"> 60 61 <div id="lines" class="flex flex-col gap-2"> 62 + <div class="text-base text-gray-500 flex items-center justify-center italic p-12 only:flex hidden border border-gray-200 dark:border-gray-700 rounded"> 63 + <span class="flex items-center gap-2"> 64 + {{ i "triangle-alert" "size-4" }} No logs for this workflow 65 + </span> 66 + </div> 61 67 </div> 62 68 </div> 63 69 {{ end }}
+81 -83
appview/pages/templates/repo/pulls/fragments/pullActions.html
··· 22 22 {{ $isLastRound := eq $roundNumber $lastIdx }} 23 23 {{ $isSameRepoBranch := .Pull.IsBranchBased }} 24 24 {{ $isUpToDate := .ResubmitCheck.No }} 25 - <div class="relative w-fit"> 26 - <div id="actions-{{$roundNumber}}" class="flex flex-wrap gap-2"> 27 - <button 28 - hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ $roundNumber }}/comment" 29 - hx-target="#actions-{{$roundNumber}}" 30 - hx-swap="outerHtml" 31 - class="btn p-2 flex items-center gap-2 no-underline hover:no-underline group"> 32 - {{ i "message-square-plus" "w-4 h-4" }} 33 - <span>comment</span> 34 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 35 - </button> 36 - {{ if .BranchDeleteStatus }} 37 - <button 38 - hx-delete="/{{ .BranchDeleteStatus.Repo.Did }}/{{ .BranchDeleteStatus.Repo.Name }}/branches" 39 - hx-vals='{"branch": "{{ .BranchDeleteStatus.Branch }}" }' 40 - hx-swap="none" 41 - class="btn p-2 flex items-center gap-2 no-underline hover:no-underline group text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"> 42 - {{ i "git-branch" "w-4 h-4" }} 43 - <span>delete branch</span> 44 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 45 - </button> 46 - {{ end }} 47 - {{ if and $isPushAllowed $isOpen $isLastRound }} 48 - {{ $disabled := "" }} 49 - {{ if $isConflicted }} 50 - {{ $disabled = "disabled" }} 51 - {{ end }} 52 - <button 53 - hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/merge" 54 - hx-swap="none" 55 - hx-confirm="Are you sure you want to merge pull #{{ .Pull.PullId }} into the `{{ .Pull.TargetBranch }}` branch?" 56 - class="btn p-2 flex items-center gap-2 group" {{ $disabled }}> 57 - {{ i "git-merge" "w-4 h-4" }} 58 - <span>merge{{if $stackCount}} {{$stackCount}}{{end}}</span> 59 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 60 - </button> 61 - {{ end }} 25 + <div id="actions-{{$roundNumber}}" class="flex flex-wrap gap-2 relative"> 26 + <button 27 + hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ $roundNumber }}/comment" 28 + hx-target="#actions-{{$roundNumber}}" 29 + hx-swap="outerHtml" 30 + class="btn p-2 flex items-center gap-2 no-underline hover:no-underline group"> 31 + {{ i "message-square-plus" "w-4 h-4" }} 32 + <span>comment</span> 33 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 34 + </button> 35 + {{ if .BranchDeleteStatus }} 36 + <button 37 + hx-delete="/{{ .BranchDeleteStatus.Repo.Did }}/{{ .BranchDeleteStatus.Repo.Name }}/branches" 38 + hx-vals='{"branch": "{{ .BranchDeleteStatus.Branch }}" }' 39 + hx-swap="none" 40 + 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"> 41 + {{ i "git-branch" "w-4 h-4" }} 42 + <span>delete branch</span> 43 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 44 + </button> 45 + {{ end }} 46 + {{ if and $isPushAllowed $isOpen $isLastRound }} 47 + {{ $disabled := "" }} 48 + {{ if $isConflicted }} 49 + {{ $disabled = "disabled" }} 50 + {{ end }} 51 + <button 52 + hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/merge" 53 + hx-swap="none" 54 + hx-confirm="Are you sure you want to merge pull #{{ .Pull.PullId }} into the `{{ .Pull.TargetBranch }}` branch?" 55 + class="btn p-2 flex items-center gap-2 group" {{ $disabled }}> 56 + {{ i "git-merge" "w-4 h-4" }} 57 + <span>merge{{if $stackCount}} {{$stackCount}}{{end}}</span> 58 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 59 + </button> 60 + {{ end }} 62 61 63 - {{ if and $isPullAuthor $isOpen $isLastRound }} 64 - {{ $disabled := "" }} 65 - {{ if $isUpToDate }} 66 - {{ $disabled = "disabled" }} 62 + {{ if and $isPullAuthor $isOpen $isLastRound }} 63 + {{ $disabled := "" }} 64 + {{ if $isUpToDate }} 65 + {{ $disabled = "disabled" }} 66 + {{ end }} 67 + <button id="resubmitBtn" 68 + {{ if not .Pull.IsPatchBased }} 69 + hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit" 70 + {{ else }} 71 + hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit" 72 + hx-target="#actions-{{$roundNumber}}" 73 + hx-swap="outerHtml" 67 74 {{ end }} 68 - <button id="resubmitBtn" 69 - {{ if not .Pull.IsPatchBased }} 70 - hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit" 71 - {{ else }} 72 - hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit" 73 - hx-target="#actions-{{$roundNumber}}" 74 - hx-swap="outerHtml" 75 - {{ end }} 76 75 77 - hx-disabled-elt="#resubmitBtn" 78 - class="btn p-2 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed group" {{ $disabled }} 76 + hx-disabled-elt="#resubmitBtn" 77 + class="btn p-2 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed group" {{ $disabled }} 79 78 80 - {{ if $disabled }} 81 - title="Update this branch to resubmit this pull request" 82 - {{ else }} 83 - title="Resubmit this pull request" 84 - {{ end }} 85 - > 86 - {{ i "rotate-ccw" "w-4 h-4" }} 87 - <span>resubmit</span> 88 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 89 - </button> 90 - {{ end }} 79 + {{ if $disabled }} 80 + title="Update this branch to resubmit this pull request" 81 + {{ else }} 82 + title="Resubmit this pull request" 83 + {{ end }} 84 + > 85 + {{ i "rotate-ccw" "w-4 h-4" }} 86 + <span>resubmit</span> 87 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 88 + </button> 89 + {{ end }} 91 90 92 - {{ if and (or $isPullAuthor $isPushAllowed) $isOpen $isLastRound }} 93 - <button 94 - hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/close" 95 - hx-swap="none" 96 - class="btn p-2 flex items-center gap-2 group"> 97 - {{ i "ban" "w-4 h-4" }} 98 - <span>close</span> 99 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 100 - </button> 101 - {{ end }} 91 + {{ if and (or $isPullAuthor $isPushAllowed) $isOpen $isLastRound }} 92 + <button 93 + hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/close" 94 + hx-swap="none" 95 + class="btn p-2 flex items-center gap-2 group"> 96 + {{ i "ban" "w-4 h-4" }} 97 + <span>close</span> 98 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 99 + </button> 100 + {{ end }} 102 101 103 - {{ if and (or $isPullAuthor $isPushAllowed) $isClosed $isLastRound }} 104 - <button 105 - hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/reopen" 106 - hx-swap="none" 107 - class="btn p-2 flex items-center gap-2 group"> 108 - {{ i "refresh-ccw-dot" "w-4 h-4" }} 109 - <span>reopen</span> 110 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 111 - </button> 112 - {{ end }} 113 - </div> 102 + {{ if and (or $isPullAuthor $isPushAllowed) $isClosed $isLastRound }} 103 + <button 104 + hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/reopen" 105 + hx-swap="none" 106 + class="btn p-2 flex items-center gap-2 group"> 107 + {{ i "refresh-ccw-dot" "w-4 h-4" }} 108 + <span>reopen</span> 109 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 110 + </button> 111 + {{ end }} 114 112 </div> 115 113 {{ end }} 116 114
+1 -1
appview/pages/templates/repo/pulls/fragments/pullHeader.html
··· 75 75 "Kind" $kind 76 76 "Count" $reactionData.Count 77 77 "IsReacted" (index $.UserReacted $kind) 78 - "ThreadAt" $.Pull.PullAt 78 + "ThreadAt" $.Pull.AtUri 79 79 "Users" $reactionData.Users) 80 80 }} 81 81 {{ end }}
+1 -1
appview/pages/templates/repo/pulls/patch.html
··· 54 54 {{ end }} 55 55 56 56 {{ define "contentAfter" }} 57 - {{ template "repo/fragments/diff" (list .RepoInfo.FullName .Diff .DiffOpts) }} 57 + {{ template "repo/fragments/diff" (list .Diff .DiffOpts) }} 58 58 {{end}} 59 59 60 60 {{ define "contentAfterLeft" }}
+5 -1
appview/pages/templates/repo/pulls/pull.html
··· 18 18 {{ template "repo/fragments/labelPanel" 19 19 (dict "RepoInfo" $.RepoInfo 20 20 "Defs" $.LabelDefs 21 - "Subject" $.Pull.PullAt 21 + "Subject" $.Pull.AtUri 22 22 "State" $.Pull.Labels) }} 23 23 {{ template "repo/fragments/participants" $.Pull.Participants }} 24 + {{ template "repo/fragments/backlinks" 25 + (dict "RepoInfo" $.RepoInfo 26 + "Backlinks" $.Backlinks) }} 27 + {{ template "repo/fragments/externalLinkPanel" $.Pull.AtUri }} 24 28 </div> 25 29 </div> 26 30 {{ end }}
+61 -31
appview/pages/templates/repo/pulls/pulls.html
··· 8 8 {{ end }} 9 9 10 10 {{ define "repoContent" }} 11 - <div class="flex justify-between items-center"> 12 - <div class="flex gap-4"> 13 - <a 14 - href="?state=open" 15 - class="flex items-center gap-2 {{ if .FilteringBy.IsOpen }}font-bold {{ else }}text-gray-500 dark:text-gray-400{{ end }}" 16 - > 17 - {{ i "git-pull-request" "w-4 h-4" }} 18 - <span>{{ .RepoInfo.Stats.PullCount.Open }} open</span> 19 - </a> 20 - <a 21 - href="?state=merged" 22 - class="flex items-center gap-2 {{ if .FilteringBy.IsMerged }}font-bold {{ else }}text-gray-500 dark:text-gray-400{{ end }}" 23 - > 24 - {{ i "git-merge" "w-4 h-4" }} 25 - <span>{{ .RepoInfo.Stats.PullCount.Merged }} merged</span> 26 - </a> 27 - <a 28 - href="?state=closed" 29 - class="flex items-center gap-2 {{ if .FilteringBy.IsClosed }}font-bold {{ else }}text-gray-500 dark:text-gray-400{{ end }}" 30 - > 31 - {{ i "ban" "w-4 h-4" }} 32 - <span>{{ .RepoInfo.Stats.PullCount.Closed }} closed</span> 33 - </a> 34 - </div> 11 + {{ $active := "closed" }} 12 + {{ if .FilteringBy.IsOpen }} 13 + {{ $active = "open" }} 14 + {{ else if .FilteringBy.IsMerged }} 15 + {{ $active = "merged" }} 16 + {{ end }} 17 + {{ $open := 18 + (dict 19 + "Key" "open" 20 + "Value" "open" 21 + "Icon" "git-pull-request" 22 + "Meta" (string .RepoInfo.Stats.PullCount.Open)) }} 23 + {{ $merged := 24 + (dict 25 + "Key" "merged" 26 + "Value" "merged" 27 + "Icon" "git-merge" 28 + "Meta" (string .RepoInfo.Stats.PullCount.Merged)) }} 29 + {{ $closed := 30 + (dict 31 + "Key" "closed" 32 + "Value" "closed" 33 + "Icon" "ban" 34 + "Meta" (string .RepoInfo.Stats.PullCount.Closed)) }} 35 + {{ $values := list $open $merged $closed }} 36 + <div class="grid gap-2 grid-cols-[auto_1fr_auto] grid-row-2"> 37 + <form class="flex relative col-span-3 sm:col-span-1 sm:col-start-2" method="GET"> 38 + <input type="hidden" name="state" value="{{ .FilteringBy.String }}"> 39 + <div class="flex-1 flex relative"> 40 + <input 41 + id="search-q" 42 + class="flex-1 py-1 pl-2 pr-10 mr-[-1px] rounded-r-none focus:border-0 focus:outline-none focus:ring focus:ring-blue-400 ring-inset peer" 43 + type="text" 44 + name="q" 45 + value="{{ .FilterQuery }}" 46 + placeholder=" " 47 + > 35 48 <a 36 - href="/{{ .RepoInfo.FullName }}/pulls/new" 37 - class="btn-create text-sm flex items-center gap-2 no-underline hover:no-underline hover:text-white" 49 + href="?state={{ .FilteringBy.String }}" 50 + 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" 38 51 > 39 - {{ i "git-pull-request-create" "w-4 h-4" }} 40 - <span>new</span> 52 + {{ i "x" "w-4 h-4" }} 41 53 </a> 54 + </div> 55 + <button 56 + type="submit" 57 + class="p-2 text-gray-400 border rounded-r border-gray-400 dark:border-gray-600" 58 + > 59 + {{ i "search" "w-4 h-4" }} 60 + </button> 61 + </form> 62 + <div class="sm:row-start-1"> 63 + {{ template "fragments/tabSelector" (dict "Name" "state" "Values" $values "Active" $active "Include" "#search-q") }} 42 64 </div> 43 - <div class="error" id="pulls"></div> 65 + <a 66 + href="/{{ .RepoInfo.FullName }}/pulls/new" 67 + class="col-start-3 btn-create text-sm flex items-center gap-2 no-underline hover:no-underline hover:text-white" 68 + > 69 + {{ i "git-pull-request-create" "w-4 h-4" }} 70 + <span>new</span> 71 + </a> 72 + </div> 73 + <div class="error" id="pulls"></div> 44 74 {{ end }} 45 75 46 76 {{ define "repoAfter" }} ··· 133 163 {{ i "chevrons-down-up" "w-4 h-4" }} hide {{ len $otherPulls }} pull{{$s}} in this stack 134 164 </div> 135 165 </summary> 136 - {{ block "pullList" (list $otherPulls $) }} {{ end }} 166 + {{ block "stackedPullList" (list $otherPulls $) }} {{ end }} 137 167 </details> 138 168 {{ end }} 139 169 {{ end }} ··· 142 172 </div> 143 173 {{ end }} 144 174 145 - {{ define "pullList" }} 175 + {{ define "stackedPullList" }} 146 176 {{ $list := index . 0 }} 147 177 {{ $root := index . 1 }} 148 178 <div class="grid grid-cols-1 rounded-b border-b border-t border-gray-200 dark:border-gray-900 divide-y divide-gray-200 dark:divide-gray-900">
+22 -14
appview/pages/templates/repo/settings/access.html
··· 29 29 {{ template "addCollaboratorButton" . }} 30 30 {{ end }} 31 31 {{ range .Collaborators }} 32 + {{ $handle := resolve .Did }} 32 33 <div class="border border-gray-200 dark:border-gray-700 rounded p-4"> 33 34 <div class="flex items-center gap-3"> 34 35 <img 35 - src="{{ fullAvatar .Handle }}" 36 - alt="{{ .Handle }}" 36 + src="{{ fullAvatar $handle }}" 37 + alt="{{ $handle }}" 37 38 class="rounded-full h-10 w-10 border border-gray-300 dark:border-gray-600 flex-shrink-0"/> 38 39 39 40 <div class="flex-1 min-w-0"> 40 - <a href="/{{ .Handle }}" class="block truncate"> 41 - {{ didOrHandle .Did .Handle }} 41 + <a href="/{{ $handle }}" class="block truncate"> 42 + {{ $handle }} 42 43 </a> 43 44 <p class="text-sm text-gray-500 dark:text-gray-400">{{ .Role }}</p> 44 45 </div> ··· 66 67 <div 67 68 id="add-collaborator-modal" 68 69 popover 69 - 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"> 70 + class=" 71 + bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 72 + dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50 73 + w-full md:w-96 p-4 rounded drop-shadow overflow-visible"> 70 74 {{ template "addCollaboratorModal" . }} 71 75 </div> 72 76 {{ end }} ··· 82 86 ADD COLLABORATOR 83 87 </label> 84 88 <p class="text-sm text-gray-500 dark:text-gray-400">Collaborators can push to this repository.</p> 85 - <input 86 - autocapitalize="none" 87 - autocorrect="off" 88 - type="text" 89 - id="add-collaborator" 90 - name="collaborator" 91 - required 92 - placeholder="@foo.bsky.social" 93 - /> 89 + <actor-typeahead> 90 + <input 91 + autocapitalize="none" 92 + autocorrect="off" 93 + autocomplete="off" 94 + type="text" 95 + id="add-collaborator" 96 + name="collaborator" 97 + required 98 + placeholder="user.tngl.sh" 99 + class="w-full" 100 + /> 101 + </actor-typeahead> 94 102 <div class="flex gap-2 pt-2"> 95 103 <button 96 104 type="button"
+47
appview/pages/templates/repo/settings/general.html
··· 6 6 {{ template "repo/settings/fragments/sidebar" . }} 7 7 </div> 8 8 <div class="col-span-1 md:col-span-3 flex flex-col gap-6 p-2"> 9 + {{ template "baseSettings" . }} 9 10 {{ template "branchSettings" . }} 10 11 {{ template "defaultLabelSettings" . }} 11 12 {{ template "customLabelSettings" . }} ··· 13 14 <div id="operation-error" class="text-red-500 dark:text-red-400"></div> 14 15 </div> 15 16 </section> 17 + {{ end }} 18 + 19 + {{ define "baseSettings" }} 20 + <form hx-put="/{{ $.RepoInfo.FullName }}/settings/base" hx-swap="none"> 21 + <fieldset 22 + class="" 23 + {{ if not .RepoInfo.Roles.IsOwner }}disabled{{ end }} 24 + > 25 + <h2 class="text-sm pb-2 uppercase font-bold">Description</h2> 26 + <textarea 27 + rows="3" 28 + class="w-full mb-2" 29 + id="base-form-description" 30 + name="description" 31 + >{{ .RepoInfo.Description }}</textarea> 32 + <h2 class="text-sm pb-2 uppercase font-bold">Website URL</h2> 33 + <input 34 + type="text" 35 + class="w-full mb-2" 36 + id="base-form-website" 37 + name="website" 38 + value="{{ .RepoInfo.Website }}" 39 + > 40 + <h2 class="text-sm pb-2 uppercase font-bold">Topics</h2> 41 + <p class="text-gray-500 dark:text-gray-400"> 42 + List of topics separated by spaces. 43 + </p> 44 + <textarea 45 + rows="2" 46 + class="w-full my-2" 47 + id="base-form-topics" 48 + name="topics" 49 + >{{ range $topic := .RepoInfo.Topics }}{{ $topic }} {{ end }}</textarea> 50 + <div id="repo-base-settings-error" class="text-red-500 dark:text-red-400"></div> 51 + <div class="flex justify-end pt-2"> 52 + <button 53 + type="submit" 54 + class="btn-create flex items-center gap-2 group" 55 + > 56 + {{ i "save" "w-4 h-4" }} 57 + save 58 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 59 + </button> 60 + </div> 61 + </fieldset> 62 + </form> 16 63 {{ end }} 17 64 18 65 {{ define "branchSettings" }}
+1 -1
appview/pages/templates/repo/settings/pipelines.html
··· 22 22 <p class="text-gray-500 dark:text-gray-400"> 23 23 Choose a spindle to execute your workflows on. Only repository owners 24 24 can configure spindles. Spindles can be selfhosted, 25 - <a class="text-gray-500 dark:text-gray-400 underline" href="https://tangled.org/@tangled.org/core/blob/master/docs/spindle/hosting.md"> 25 + <a class="text-gray-500 dark:text-gray-400 underline" href="https://docs.tangled.org/spindles.html#self-hosting-guide"> 26 26 click to learn more. 27 27 </a> 28 28 </p>
+8
appview/pages/templates/repo/tree.html
··· 59 59 {{ $icon := "folder" }} 60 60 {{ $iconStyle := "size-4 fill-current" }} 61 61 62 + {{ if .IsSubmodule }} 63 + {{ $link = printf "/%s/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) $.TreePath .Name }} 64 + {{ $icon = "folder-input" }} 65 + {{ $iconStyle = "size-4" }} 66 + {{ end }} 67 + 62 68 {{ if .IsFile }} 69 + {{ $link = printf "/%s/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) $.TreePath .Name }} 63 70 {{ $icon = "file" }} 64 71 {{ $iconStyle = "size-4" }} 65 72 {{ end }} 73 + 66 74 <a href="{{ $link }}" class="{{ $linkstyle }}"> 67 75 <div class="flex items-center gap-2"> 68 76 {{ i $icon $iconStyle "flex-shrink-0" }}
+22 -6
appview/pages/templates/spindles/dashboard.html
··· 1 - {{ define "title" }}{{.Spindle.Instance}} &middot; spindles{{ end }} 1 + {{ define "title" }}{{.Spindle.Instance}} &middot; {{ .Tab }} settings{{ end }} 2 2 3 3 {{ define "content" }} 4 - <div class="px-6 py-4"> 4 + <div class="p-6"> 5 + <p class="text-xl font-bold dark:text-white">Settings</p> 6 + </div> 7 + <div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 8 + <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6"> 9 + <div class="col-span-1"> 10 + {{ template "user/settings/fragments/sidebar" . }} 11 + </div> 12 + <div class="col-span-1 md:col-span-3 flex flex-col gap-6"> 13 + {{ template "spindleDash" . }} 14 + </div> 15 + </section> 16 + </div> 17 + {{ end }} 18 + 19 + {{ define "spindleDash" }} 20 + <div> 5 21 <div class="flex justify-between items-center"> 6 - <h1 class="text-xl font-bold dark:text-white">{{ .Spindle.Instance }}</h1> 22 + <h2 class="text-sm pb-2 uppercase font-bold">{{ .Tab }} &middot; {{ .Spindle.Instance }}</h2> 7 23 <div id="right-side" class="flex gap-2"> 8 24 {{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2" }} 9 25 {{ $isOwner := and .LoggedInUser (eq .LoggedInUser.Did .Spindle.Owner) }} ··· 71 87 <button 72 88 class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 73 89 title="Delete spindle" 74 - hx-delete="/spindles/{{ .Instance }}" 90 + hx-delete="/settings/spindles/{{ .Instance }}" 75 91 hx-swap="outerHTML" 76 92 hx-confirm="Are you sure you want to delete the spindle '{{ .Instance }}'?" 77 93 hx-headers='{"shouldRedirect": "true"}' ··· 87 103 <button 88 104 class="btn gap-2 group" 89 105 title="Retry spindle verification" 90 - hx-post="/spindles/{{ .Instance }}/retry" 106 + hx-post="/settings/spindles/{{ .Instance }}/retry" 91 107 hx-swap="none" 92 108 hx-headers='{"shouldRefresh": "true"}' 93 109 > ··· 104 120 <button 105 121 class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 106 122 title="Remove member" 107 - hx-post="/spindles/{{ $root.Spindle.Instance }}/remove" 123 + hx-post="/settings/spindles/{{ $root.Spindle.Instance }}/remove" 108 124 hx-swap="none" 109 125 hx-vals='{"member": "{{$member}}" }' 110 126 hx-confirm="Are you sure you want to remove {{ resolve $member }} from this instance?"
+17 -11
appview/pages/templates/spindles/fragments/addMemberModal.html
··· 13 13 <div 14 14 id="add-member-{{ .Instance }}" 15 15 popover 16 - class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 16 + class=" 17 + 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 18 + w-full md:w-96 p-4 rounded drop-shadow overflow-visible"> 17 19 {{ block "addSpindleMemberPopover" . }} {{ end }} 18 20 </div> 19 21 {{ end }} 20 22 21 23 {{ define "addSpindleMemberPopover" }} 22 24 <form 23 - hx-post="/spindles/{{ .Instance }}/add" 25 + hx-post="/settings/spindles/{{ .Instance }}/add" 24 26 hx-indicator="#spinner" 25 27 hx-swap="none" 26 28 class="flex flex-col gap-2" ··· 29 31 ADD MEMBER 30 32 </label> 31 33 <p class="text-sm text-gray-500 dark:text-gray-400">Members can register repositories and run workflows on this spindle.</p> 32 - <input 33 - autocapitalize="none" 34 - autocorrect="off" 35 - type="text" 36 - id="member-did-{{ .Id }}" 37 - name="member" 38 - required 39 - placeholder="@foo.bsky.social" 40 - /> 34 + <actor-typeahead> 35 + <input 36 + autocapitalize="none" 37 + autocorrect="off" 38 + autocomplete="off" 39 + type="text" 40 + id="member-did-{{ .Id }}" 41 + name="member" 42 + required 43 + placeholder="user.tngl.sh" 44 + class="w-full" 45 + /> 46 + </actor-typeahead> 41 47 <div class="flex gap-2 pt-2"> 42 48 <button 43 49 type="button"
+3 -3
appview/pages/templates/spindles/fragments/spindleListing.html
··· 7 7 8 8 {{ define "spindleLeftSide" }} 9 9 {{ if .Verified }} 10 - <a href="/spindles/{{ .Instance }}" class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]"> 10 + <a href="/settings/spindles/{{ .Instance }}" class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]"> 11 11 {{ i "hard-drive" "w-4 h-4" }} 12 12 <span class="hover:underline"> 13 13 {{ .Instance }} ··· 50 50 <button 51 51 class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 52 52 title="Delete spindle" 53 - hx-delete="/spindles/{{ .Instance }}" 53 + hx-delete="/settings/spindles/{{ .Instance }}" 54 54 hx-swap="outerHTML" 55 55 hx-target="#spindle-{{.Id}}" 56 56 hx-confirm="Are you sure you want to delete the spindle '{{ .Instance }}'?" ··· 66 66 <button 67 67 class="btn gap-2 group" 68 68 title="Retry spindle verification" 69 - hx-post="/spindles/{{ .Instance }}/retry" 69 + hx-post="/settings/spindles/{{ .Instance }}/retry" 70 70 hx-swap="none" 71 71 hx-target="#spindle-{{.Id}}" 72 72 >
+90 -59
appview/pages/templates/spindles/index.html
··· 1 - {{ define "title" }}spindles{{ end }} 1 + {{ define "title" }}{{ .Tab }} settings{{ end }} 2 2 3 3 {{ define "content" }} 4 - <div class="px-6 py-4 flex items-center justify-between gap-4 align-bottom"> 5 - <h1 class="text-xl font-bold dark:text-white">Spindles</h1> 6 - <span class="flex items-center gap-1"> 7 - {{ i "book" "w-3 h-3" }} 8 - <a href="https://tangled.org/@tangled.org/core/blob/master/docs/spindle/hosting.md">docs</a> 9 - </span> 4 + <div class="p-6"> 5 + <p class="text-xl font-bold dark:text-white">Settings</p> 6 + </div> 7 + <div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 8 + <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6"> 9 + <div class="col-span-1"> 10 + {{ template "user/settings/fragments/sidebar" . }} 11 + </div> 12 + <div class="col-span-1 md:col-span-3 flex flex-col gap-6"> 13 + {{ template "spindleList" . }} 14 + </div> 15 + </section> 16 + </div> 17 + {{ end }} 18 + 19 + {{ define "spindleList" }} 20 + <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 21 + <div class="col-span-1 md:col-span-2"> 22 + <h2 class="text-sm pb-2 uppercase font-bold">Spindle</h2> 23 + {{ block "about" . }} {{ end }} 24 + </div> 25 + <div class="col-span-1 md:col-span-1 md:justify-self-end"> 26 + {{ template "docsButton" . }} 27 + </div> 10 28 </div> 11 29 12 - <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 30 + <section> 13 31 <div class="flex flex-col gap-6"> 14 - {{ block "about" . }} {{ end }} 15 32 {{ block "list" . }} {{ end }} 16 33 {{ block "register" . }} {{ end }} 17 34 </div> ··· 20 37 21 38 {{ define "about" }} 22 39 <section class="rounded flex items-center gap-2"> 23 - <p class="text-gray-500 dark:text-gray-400"> 24 - Spindles are small CI runners. 25 - </p> 40 + <p class="text-gray-500 dark:text-gray-400"> 41 + Spindles are small CI runners. 42 + </p> 26 43 </section> 27 44 {{ end }} 28 45 29 46 {{ define "list" }} 30 - <section class="rounded w-full flex flex-col gap-2"> 31 - <h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">your spindles</h2> 32 - <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 w-full"> 33 - {{ range $spindle := .Spindles }} 34 - {{ template "spindles/fragments/spindleListing" . }} 35 - {{ else }} 36 - <div class="flex items-center justify-center p-2 border-b border-gray-200 dark:border-gray-700 text-gray-500"> 37 - no spindles registered yet 38 - </div> 39 - {{ end }} 47 + <section class="rounded w-full flex flex-col gap-2"> 48 + <h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">your spindles</h2> 49 + <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 w-full"> 50 + {{ range $spindle := .Spindles }} 51 + {{ template "spindles/fragments/spindleListing" . }} 52 + {{ else }} 53 + <div class="flex items-center justify-center p-2 border-b border-gray-200 dark:border-gray-700 text-gray-500"> 54 + no spindles registered yet 40 55 </div> 41 - <div id="operation-error" class="text-red-500 dark:text-red-400"></div> 42 - </section> 56 + {{ end }} 57 + </div> 58 + <div id="operation-error" class="text-red-500 dark:text-red-400"></div> 59 + </section> 43 60 {{ end }} 44 61 45 62 {{ define "register" }} 46 - <section class="rounded w-full lg:w-fit flex flex-col gap-2"> 47 - <h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">register a spindle</h2> 48 - <p class="mb-2 dark:text-gray-300">Enter the hostname of your spindle to get started.</p> 49 - <form 50 - hx-post="/spindles/register" 51 - class="max-w-2xl mb-2 space-y-4" 52 - hx-indicator="#register-button" 53 - hx-swap="none" 54 - > 55 - <div class="flex gap-2"> 56 - <input 57 - type="text" 58 - id="instance" 59 - name="instance" 60 - placeholder="spindle.example.com" 61 - required 62 - class="flex-1 w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 px-3 py-2 border rounded" 63 - > 64 - <button 65 - type="submit" 66 - id="register-button" 67 - class="btn rounded flex items-center py-2 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 group" 68 - > 69 - <span class="inline-flex items-center gap-2"> 70 - {{ i "plus" "w-4 h-4" }} 71 - register 72 - </span> 73 - <span class="pl-2 hidden group-[.htmx-request]:inline"> 74 - {{ i "loader-circle" "w-4 h-4 animate-spin" }} 75 - </span> 76 - </button> 77 - </div> 63 + <section class="rounded w-full lg:w-fit flex flex-col gap-2"> 64 + <h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">register a spindle</h2> 65 + <p class="mb-2 dark:text-gray-300">Enter the hostname of your spindle to get started.</p> 66 + <form 67 + hx-post="/settings/spindles/register" 68 + class="max-w-2xl mb-2 space-y-4" 69 + hx-indicator="#register-button" 70 + hx-swap="none" 71 + > 72 + <div class="flex gap-2"> 73 + <input 74 + type="text" 75 + id="instance" 76 + name="instance" 77 + placeholder="spindle.example.com" 78 + required 79 + class="flex-1 w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 px-3 py-2 border rounded" 80 + > 81 + <button 82 + type="submit" 83 + id="register-button" 84 + class="btn rounded flex items-center py-2 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 group" 85 + > 86 + <span class="inline-flex items-center gap-2"> 87 + {{ i "plus" "w-4 h-4" }} 88 + register 89 + </span> 90 + <span class="pl-2 hidden group-[.htmx-request]:inline"> 91 + {{ i "loader-circle" "w-4 h-4 animate-spin" }} 92 + </span> 93 + </button> 94 + </div> 78 95 79 - <div id="register-error" class="dark:text-red-400"></div> 80 - </form> 96 + <div id="register-error" class="dark:text-red-400"></div> 97 + </form> 98 + 99 + </section> 100 + {{ end }} 81 101 82 - </section> 102 + {{ define "docsButton" }} 103 + <a 104 + class="btn flex items-center gap-2" 105 + href="https://docs.tangled.org/spindles.html#self-hosting-guide"> 106 + {{ i "book" "size-4" }} 107 + docs 108 + </a> 109 + <div 110 + id="add-email-modal" 111 + popover 112 + 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"> 113 + </div> 83 114 {{ end }}
+6 -5
appview/pages/templates/strings/dashboard.html
··· 1 - {{ define "title" }}strings by {{ or .Card.UserHandle .Card.UserDid }}{{ end }} 1 + {{ define "title" }}strings by {{ resolve .Card.UserDid }}{{ end }} 2 2 3 3 {{ define "extrameta" }} 4 - <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" /> 4 + {{ $handle := resolve .Card.UserDid }} 5 + <meta property="og:title" content="{{ $handle }}" /> 5 6 <meta property="og:type" content="profile" /> 6 - <meta property="og:url" content="https://tangled.org/{{ or .Card.UserHandle .Card.UserDid }}" /> 7 - <meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 7 + <meta property="og:url" content="https://tangled.org/{{ $handle }}" /> 8 + <meta property="og:description" content="{{ or .Card.Profile.Description $handle }}" /> 8 9 {{ end }} 9 10 10 11 ··· 35 36 {{ $s := index . 1 }} 36 37 <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800"> 37 38 <div class="font-medium dark:text-white flex gap-2 items-center"> 38 - <a href="/strings/{{ or $root.Card.UserHandle $root.Card.UserDid }}/{{ $s.Rkey }}">{{ $s.Filename }}</a> 39 + <a href="/strings/{{ resolve $root.Card.UserDid }}/{{ $s.Rkey }}">{{ $s.Filename }}</a> 39 40 </div> 40 41 {{ with $s.Description }} 41 42 <div class="text-gray-600 dark:text-gray-300 text-sm">
+13 -9
appview/pages/templates/strings/string.html
··· 1 - {{ define "title" }}{{ .String.Filename }} ยท by {{ didOrHandle .Owner.DID.String .Owner.Handle.String }}{{ end }} 1 + {{ define "title" }}{{ .String.Filename }} ยท by {{ resolve .Owner.DID.String }}{{ end }} 2 2 3 3 {{ define "extrameta" }} 4 - {{ $ownerId := didOrHandle .Owner.DID.String .Owner.Handle.String }} 4 + {{ $ownerId := resolve .Owner.DID.String }} 5 5 <meta property="og:title" content="{{ .String.Filename }} ยท by {{ $ownerId }}" /> 6 6 <meta property="og:type" content="object" /> 7 7 <meta property="og:url" content="https://tangled.org/strings/{{ $ownerId }}/{{ .String.Rkey }}" /> ··· 9 9 {{ end }} 10 10 11 11 {{ define "content" }} 12 - {{ $ownerId := didOrHandle .Owner.DID.String .Owner.Handle.String }} 12 + {{ $ownerId := resolve .Owner.DID.String }} 13 13 <section id="string-header" class="mb-4 py-2 px-6 dark:text-white"> 14 14 <div class="text-lg flex items-center justify-between"> 15 15 <div> ··· 17 17 <span class="select-none">/</span> 18 18 <a href="/strings/{{ $ownerId }}/{{ .String.Rkey }}" class="font-bold">{{ .String.Filename }}</a> 19 19 </div> 20 - {{ if and .LoggedInUser (eq .LoggedInUser.Did .String.Did) }} 21 - <div class="flex gap-2 text-base"> 20 + <div class="flex gap-2 items-stretch text-base"> 21 + {{ if and .LoggedInUser (eq .LoggedInUser.Did .String.Did) }} 22 22 <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group" 23 23 hx-boost="true" 24 24 href="/strings/{{ .String.Did }}/{{ .String.Rkey }}/edit"> ··· 37 37 <span class="hidden md:inline">delete</span> 38 38 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 39 39 </button> 40 - </div> 41 - {{ end }} 40 + {{ end }} 41 + {{ template "fragments/starBtn" 42 + (dict "SubjectAt" .String.AtUri 43 + "IsStarred" .IsStarred 44 + "StarCount" .StarCount) }} 45 + </div> 42 46 </div> 43 47 <span> 44 48 {{ with .String.Description }} ··· 75 79 </div> 76 80 <div class="overflow-x-auto overflow-y-hidden relative"> 77 81 {{ if .ShowRendered }} 78 - <div id="blob-contents" class="prose dark:prose-invert">{{ .RenderedContents }}</div> 82 + <div id="blob-contents" class="prose dark:prose-invert">{{ .String.Contents | readme }}</div> 79 83 {{ else }} 80 - <div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ .String.Contents | escapeHtml }}</div> 84 + <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> 81 85 {{ end }} 82 86 </div> 83 87 {{ template "fragments/multiline-select" }}
+1 -2
appview/pages/templates/timeline/fragments/goodfirstissues.html
··· 3 3 <a href="/goodfirstissues" class="no-underline hover:no-underline"> 4 4 <div class="flex items-center justify-between gap-2 bg-purple-200 dark:bg-purple-900 border border-purple-400 dark:border-purple-500 rounded mb-4 py-4 px-6 "> 5 5 <div class="flex-1 flex flex-col gap-2"> 6 - <div class="text-purple-500 dark:text-purple-400">Oct 2025</div> 7 6 <p> 8 - Make your first contribution to an open-source project this October. 7 + Make your first contribution to an open-source project. 9 8 <em>good-first-issue</em> helps new contributors find easy ways to 10 9 start contributing to open-source projects. 11 10 </p>
+2 -2
appview/pages/templates/timeline/fragments/hero.html
··· 4 4 <h1 class="font-bold text-4xl">tightly-knit<br>social coding.</h1> 5 5 6 6 <p class="text-lg"> 7 - tangled is new social-enabled git collaboration platform built on <a class="underline" href="https://atproto.com/">atproto</a>. 7 + Tangled is a decentralized Git hosting and collaboration platform. 8 8 </p> 9 9 <p class="text-lg"> 10 - we envision a place where developers have complete ownership of their 10 + We envision a place where developers have complete ownership of their 11 11 code, open source communities can freely self-govern and most 12 12 importantly, coding can be social and fun again. 13 13 </p>
+5 -5
appview/pages/templates/timeline/fragments/timeline.html
··· 14 14 <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm"> 15 15 {{ if .Repo }} 16 16 {{ template "timeline/fragments/repoEvent" (list $ .) }} 17 - {{ else if .Star }} 17 + {{ else if .RepoStar }} 18 18 {{ template "timeline/fragments/starEvent" (list $ .) }} 19 19 {{ else if .Follow }} 20 20 {{ template "timeline/fragments/followEvent" (list $ .) }} ··· 52 52 <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $repo.Created }}</span> 53 53 </div> 54 54 {{ with $repo }} 55 - {{ template "user/fragments/repoCard" (list $root . true true (dict "IsStarred" $event.IsStarred "RepoAt" .RepoAt "Stats" (dict "StarCount" $event.StarCount))) }} 55 + {{ template "user/fragments/repoCard" (list $root . true true (dict "IsStarred" $event.IsStarred "SubjectAt" .RepoAt "StarCount" $event.StarCount)) }} 56 56 {{ end }} 57 57 {{ end }} 58 58 59 59 {{ define "timeline/fragments/starEvent" }} 60 60 {{ $root := index . 0 }} 61 61 {{ $event := index . 1 }} 62 - {{ $star := $event.Star }} 62 + {{ $star := $event.RepoStar }} 63 63 {{ with $star }} 64 - {{ $starrerHandle := resolve .StarredByDid }} 64 + {{ $starrerHandle := resolve .Did }} 65 65 {{ $repoOwnerHandle := resolve .Repo.Did }} 66 66 <div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm"> 67 67 {{ template "user/fragments/picHandleLink" $starrerHandle }} ··· 72 72 <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" .Created }}</span> 73 73 </div> 74 74 {{ with .Repo }} 75 - {{ template "user/fragments/repoCard" (list $root . true true (dict "IsStarred" $event.IsStarred "RepoAt" .RepoAt "Stats" (dict "StarCount" $event.StarCount))) }} 75 + {{ template "user/fragments/repoCard" (list $root . true true (dict "IsStarred" $event.IsStarred "SubjectAt" .RepoAt "StarCount" $event.StarCount)) }} 76 76 {{ end }} 77 77 {{ end }} 78 78 {{ end }}
+4 -2
appview/pages/templates/user/followers.html
··· 1 - {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท followers {{ end }} 1 + {{ define "title" }}{{ resolve .Card.UserDid }} ยท followers {{ end }} 2 2 3 3 {{ define "profileContent" }} 4 4 <div id="all-followers" class="md:col-span-8 order-2 md:order-2"> ··· 19 19 "FollowersCount" .FollowersCount 20 20 "FollowingCount" .FollowingCount) }} 21 21 {{ else }} 22 - <p class="px-6 dark:text-white">This user does not have any followers yet.</p> 22 + <div class="text-base text-gray-500 flex items-center justify-center italic p-12 border border-gray-200 dark:border-gray-700 rounded"> 23 + <span>This user does not have any followers yet.</span> 24 + </div> 23 25 {{ end }} 24 26 </div> 25 27 {{ end }}
+4 -2
appview/pages/templates/user/following.html
··· 1 - {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท following {{ end }} 1 + {{ define "title" }}{{ resolve .Card.UserDid }} ยท following {{ end }} 2 2 3 3 {{ define "profileContent" }} 4 4 <div id="all-following" class="md:col-span-8 order-2 md:order-2"> ··· 19 19 "FollowersCount" .FollowersCount 20 20 "FollowingCount" .FollowingCount) }} 21 21 {{ else }} 22 - <p class="px-6 dark:text-white">This user does not follow anyone yet.</p> 22 + <div class="text-base text-gray-500 flex items-center justify-center italic p-12 border border-gray-200 dark:border-gray-700 rounded"> 23 + <span>This user does not follow anyone yet.</span> 24 + </div> 23 25 {{ end }} 24 26 </div> 25 27 {{ end }}
+17
appview/pages/templates/user/fragments/editBio.html
··· 20 20 </div> 21 21 22 22 <div class="flex flex-col gap-1"> 23 + <label class="m-0 p-0" for="pronouns">pronouns</label> 24 + <div class="flex items-center gap-2 w-full"> 25 + {{ $pronouns := "" }} 26 + {{ if and .Profile .Profile.Pronouns }} 27 + {{ $pronouns = .Profile.Pronouns }} 28 + {{ end }} 29 + <input 30 + type="text" 31 + class="py-1 px-1 w-full" 32 + name="pronouns" 33 + placeholder="they/them" 34 + value="{{ $pronouns }}" 35 + > 36 + </div> 37 + </div> 38 + 39 + <div class="flex flex-col gap-1"> 23 40 <label class="m-0 p-0" for="location">location</label> 24 41 <div class="flex items-center gap-2 w-full"> 25 42 {{ $location := "" }}
+2 -2
appview/pages/templates/user/fragments/followCard.html
··· 6 6 <img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" alt="{{ $userIdent }}" /> 7 7 </div> 8 8 9 - <div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2 w-full"> 9 + <div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2 w-full min-w-0"> 10 10 <div class="flex-1 min-h-0 justify-around flex flex-col"> 11 11 <a href="/{{ $userIdent }}"> 12 12 <span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $userIdent | truncateAt30 }}</span> 13 13 </a> 14 14 {{ with .Profile }} 15 - <p class="text-sm pb-2 md:pb-2">{{.Description}}</p> 15 + <p class="text-sm pb-2 md:pb-2 break-words">{{.Description}}</p> 16 16 {{ end }} 17 17 <div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 18 18 <span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
+20 -7
appview/pages/templates/user/fragments/profileCard.html
··· 1 1 {{ define "user/fragments/profileCard" }} 2 - {{ $userIdent := didOrHandle .UserDid .UserHandle }} 2 + {{ $userIdent := resolve .UserDid }} 3 3 <div class="grid grid-cols-3 md:grid-cols-1 gap-1 items-center"> 4 4 <div id="avatar" class="col-span-1 flex justify-center items-center"> 5 5 <div class="w-3/4 aspect-square relative"> ··· 12 12 class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap"> 13 13 {{ $userIdent }} 14 14 </p> 15 - <a href="/{{ $userIdent }}/feed.atom">{{ i "rss" "size-4" }}</a> 15 + {{ with .Profile }} 16 + {{ if .Pronouns }} 17 + <p class="text-gray-500 dark:text-gray-400">{{ .Pronouns }}</p> 18 + {{ end }} 19 + {{ end }} 16 20 </div> 17 21 18 22 <div class="md:hidden"> ··· 67 71 {{ end }} 68 72 </div> 69 73 {{ end }} 70 - {{ if ne .FollowStatus.String "IsSelf" }} 71 - {{ template "user/fragments/follow" . }} 72 - {{ else }} 74 + 75 + <div class="flex mt-2 items-center gap-2"> 76 + {{ if ne .FollowStatus.String "IsSelf" }} 77 + {{ template "user/fragments/follow" . }} 78 + {{ else }} 73 79 <button id="editBtn" 74 - class="btn mt-2 w-full flex items-center gap-2 group" 80 + class="btn w-full flex items-center gap-2 group" 75 81 hx-target="#profile-bio" 76 82 hx-get="/profile/edit-bio" 77 83 hx-swap="innerHTML"> ··· 79 85 edit 80 86 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 81 87 </button> 82 - {{ end }} 88 + {{ end }} 89 + 90 + <a class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group" 91 + href="/{{ $userIdent }}/feed.atom"> 92 + {{ i "rss" "size-4" }} 93 + </a> 94 + </div> 95 + 83 96 </div> 84 97 <div id="update-profile" class="text-red-400 dark:text-red-500"></div> 85 98 </div>
+2 -1
appview/pages/templates/user/fragments/repoCard.html
··· 1 1 {{ define "user/fragments/repoCard" }} 2 + {{/* root, repo, fullName [,starButton [,starData]] */}} 2 3 {{ $root := index . 0 }} 3 4 {{ $repo := index . 1 }} 4 5 {{ $fullName := index . 2 }} ··· 29 30 </div> 30 31 {{ if and $starButton $root.LoggedInUser }} 31 32 <div class="shrink-0"> 32 - {{ template "repo/fragments/repoStar" $starData }} 33 + {{ template "fragments/starBtn" $starData }} 33 34 </div> 34 35 {{ end }} 35 36 </div>
+22 -4
appview/pages/templates/user/overview.html
··· 1 - {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }}{{ end }} 1 + {{ define "title" }}{{ resolve .Card.UserDid }}{{ end }} 2 2 3 3 {{ define "profileContent" }} 4 4 <div id="all-repos" class="md:col-span-4 order-2 md:order-2"> ··· 16 16 <p class="text-sm font-bold px-2 pb-4 dark:text-white">ACTIVITY</p> 17 17 <div class="flex flex-col gap-4 relative"> 18 18 {{ if .ProfileTimeline.IsEmpty }} 19 - <p class="dark:text-white">This user does not have any activity yet.</p> 19 + <div class="text-base text-gray-500 flex items-center justify-center italic p-12 border border-gray-200 dark:border-gray-700 rounded"> 20 + <span class="flex items-center gap-2"> 21 + This user does not have any activity yet. 22 + </span> 23 + </div> 20 24 {{ end }} 21 25 22 26 {{ with .ProfileTimeline }} ··· 33 37 </p> 34 38 35 39 <div class="flex flex-col gap-1"> 40 + {{ block "commits" .Commits }} {{ end }} 36 41 {{ block "repoEvents" .RepoEvents }} {{ end }} 37 42 {{ block "issueEvents" .IssueEvents }} {{ end }} 38 43 {{ block "pullEvents" .PullEvents }} {{ end }} ··· 43 48 {{ end }} 44 49 {{ end }} 45 50 </div> 51 + {{ end }} 52 + 53 + {{ define "commits" }} 54 + {{ if . }} 55 + <div class="flex flex-wrap items-center gap-1"> 56 + {{ i "git-commit-horizontal" "size-5" }} 57 + created {{ . }} commits 58 + </div> 59 + {{ end }} 46 60 {{ end }} 47 61 48 62 {{ define "repoEvents" }} ··· 224 238 {{ define "ownRepos" }} 225 239 <div> 226 240 <div class="text-sm font-bold px-2 pb-4 dark:text-white flex items-center gap-2"> 227 - <a href="/@{{ or $.Card.UserHandle $.Card.UserDid }}?tab=repos" 241 + <a href="/{{ resolve $.Card.UserDid }}?tab=repos" 228 242 class="flex text-black dark:text-white items-center gap-2 no-underline hover:no-underline group"> 229 243 <span>PINNED REPOS</span> 230 244 </a> ··· 244 258 {{ template "user/fragments/repoCard" (list $ . false) }} 245 259 </div> 246 260 {{ else }} 247 - <p class="dark:text-white">This user does not have any pinned repos.</p> 261 + <div class="text-base text-gray-500 flex items-center justify-center italic p-12 border border-gray-200 dark:border-gray-700 rounded"> 262 + <span class="flex items-center gap-2"> 263 + This user does not have any pinned repos. 264 + </span> 265 + </div> 248 266 {{ end }} 249 267 </div> 250 268 </div>
+4 -2
appview/pages/templates/user/repos.html
··· 1 - {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท repos {{ end }} 1 + {{ define "title" }}{{ resolve .Card.UserDid }} ยท repos {{ end }} 2 2 3 3 {{ define "profileContent" }} 4 4 <div id="all-repos" class="md:col-span-8 order-2 md:order-2"> ··· 13 13 {{ template "user/fragments/repoCard" (list $ . false) }} 14 14 </div> 15 15 {{ else }} 16 - <p class="px-6 dark:text-white">This user does not have any repos yet.</p> 16 + <div class="text-base text-gray-500 flex items-center justify-center italic p-12 border border-gray-200 dark:border-gray-700 rounded"> 17 + <span>This user does not have any repos yet.</span> 18 + </div> 17 19 {{ end }} 18 20 </div> 19 21 {{ end }}
+14
appview/pages/templates/user/settings/notifications.html
··· 144 144 <div class="flex items-center justify-between p-2"> 145 145 <div class="flex items-center gap-2"> 146 146 <div class="flex flex-col gap-1"> 147 + <span class="font-bold">Mentions</span> 148 + <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 149 + <span>When someone mentions you.</span> 150 + </div> 151 + </div> 152 + </div> 153 + <label class="flex items-center gap-2"> 154 + <input type="checkbox" name="user_mentioned" {{if .Preferences.UserMentioned}}checked{{end}}> 155 + </label> 156 + </div> 157 + 158 + <div class="flex items-center justify-between p-2"> 159 + <div class="flex items-center gap-2"> 160 + <div class="flex flex-col gap-1"> 147 161 <span class="font-bold">Email notifications</span> 148 162 <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 149 163 <span>Receive notifications via email in addition to in-app notifications.</span>
+9 -6
appview/pages/templates/user/signup.html
··· 43 43 page to complete your registration. 44 44 </span> 45 45 <div class="w-full mt-4 text-center"> 46 - <div class="cf-turnstile" data-sitekey="{{ .CloudflareSiteKey }}"></div> 46 + <div class="cf-turnstile" data-sitekey="{{ .CloudflareSiteKey }}" data-size="flexible"></div> 47 47 </div> 48 48 <button class="btn text-base w-full my-2 mt-6" type="submit" id="signup-button" tabindex="7" > 49 49 <span>join now</span> 50 50 </button> 51 + <p class="text-sm text-gray-500"> 52 + Already have an AT Protocol account? <a href="/login" class="underline">Login to Tangled</a>. 53 + </p> 54 + 55 + <p id="signup-msg" class="error w-full"></p> 56 + <p class="text-sm text-gray-500 pt-4"> 57 + By signing up, you agree to our <a href="/terms" class="underline">Terms of Service</a> and <a href="/privacy" class="underline">Privacy Policy</a>. 58 + </p> 51 59 </form> 52 - <p class="text-sm text-gray-500"> 53 - Already have an AT Protocol account? <a href="/login" class="underline">Login to Tangled</a>. 54 - </p> 55 - 56 - <p id="signup-msg" class="error w-full"></p> 57 60 </main> 58 61 </body> 59 62 </html>
+4 -2
appview/pages/templates/user/starred.html
··· 1 - {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท repos {{ end }} 1 + {{ define "title" }}{{ resolve .Card.UserDid }} ยท repos {{ end }} 2 2 3 3 {{ define "profileContent" }} 4 4 <div id="all-repos" class="md:col-span-8 order-2 md:order-2"> ··· 13 13 {{ template "user/fragments/repoCard" (list $ . true) }} 14 14 </div> 15 15 {{ else }} 16 - <p class="px-6 dark:text-white">This user does not have any starred repos yet.</p> 16 + <div class="text-base text-gray-500 flex items-center justify-center italic p-12 border border-gray-200 dark:border-gray-700 rounded"> 17 + <span>This user does not have any starred repos yet.</span> 18 + </div> 17 19 {{ end }} 18 20 </div> 19 21 {{ end }}
+5 -3
appview/pages/templates/user/strings.html
··· 1 - {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท strings {{ end }} 1 + {{ define "title" }}{{ resolve .Card.UserDid }} ยท strings {{ end }} 2 2 3 3 {{ define "profileContent" }} 4 4 <div id="all-strings" class="md:col-span-8 order-2 md:order-2"> ··· 13 13 {{ template "singleString" (list $ .) }} 14 14 </div> 15 15 {{ else }} 16 - <p class="px-6 dark:text-white">This user does not have any strings yet.</p> 16 + <div class="text-base text-gray-500 flex items-center justify-center italic p-12 border border-gray-200 dark:border-gray-700 rounded"> 17 + <span>This user does not have any strings yet.</span> 18 + </div> 17 19 {{ end }} 18 20 </div> 19 21 {{ end }} ··· 23 25 {{ $s := index . 1 }} 24 26 <div class="py-4 px-6 rounded bg-white dark:bg-gray-800"> 25 27 <div class="font-medium dark:text-white flex gap-2 items-center"> 26 - <a href="/strings/{{ or $root.Card.UserHandle $root.Card.UserDid }}/{{ $s.Rkey }}">{{ $s.Filename }}</a> 28 + <a href="/strings/{{ resolve $root.Card.UserDid }}/{{ $s.Rkey }}">{{ $s.Filename }}</a> 27 29 </div> 28 30 {{ with $s.Description }} 29 31 <div class="text-gray-600 dark:text-gray-300 text-sm">
+53 -35
appview/pipelines/pipelines.go
··· 16 16 "tangled.org/core/appview/reporesolver" 17 17 "tangled.org/core/eventconsumer" 18 18 "tangled.org/core/idresolver" 19 + "tangled.org/core/orm" 19 20 "tangled.org/core/rbac" 20 21 spindlemodel "tangled.org/core/spindle/models" 21 22 ··· 33 34 db *db.DB 34 35 enforcer *rbac.Enforcer 35 36 logger *slog.Logger 37 + } 38 + 39 + func (p *Pipelines) Router() http.Handler { 40 + r := chi.NewRouter() 41 + r.Get("/", p.Index) 42 + r.Get("/{pipeline}/workflow/{workflow}", p.Workflow) 43 + r.Get("/{pipeline}/workflow/{workflow}/logs", p.Logs) 44 + 45 + return r 36 46 } 37 47 38 48 func New( ··· 69 79 return 70 80 } 71 81 72 - repoInfo := f.RepoInfo(user) 73 - 74 82 ps, err := db.GetPipelineStatuses( 75 83 p.db, 76 - db.FilterEq("repo_owner", repoInfo.OwnerDid), 77 - db.FilterEq("repo_name", repoInfo.Name), 78 - db.FilterEq("knot", repoInfo.Knot), 84 + 30, 85 + orm.FilterEq("repo_owner", f.Did), 86 + orm.FilterEq("repo_name", f.Name), 87 + orm.FilterEq("knot", f.Knot), 79 88 ) 80 89 if err != nil { 81 90 l.Error("failed to query db", "err", err) ··· 84 93 85 94 p.pages.Pipelines(w, pages.PipelinesParams{ 86 95 LoggedInUser: user, 87 - RepoInfo: repoInfo, 96 + RepoInfo: p.repoResolver.GetRepoInfo(r, user), 88 97 Pipelines: ps, 89 98 }) 90 99 } ··· 99 108 return 100 109 } 101 110 102 - repoInfo := f.RepoInfo(user) 103 - 104 111 pipelineId := chi.URLParam(r, "pipeline") 105 112 if pipelineId == "" { 106 113 l.Error("empty pipeline ID") ··· 115 122 116 123 ps, err := db.GetPipelineStatuses( 117 124 p.db, 118 - db.FilterEq("repo_owner", repoInfo.OwnerDid), 119 - db.FilterEq("repo_name", repoInfo.Name), 120 - db.FilterEq("knot", repoInfo.Knot), 121 - db.FilterEq("id", pipelineId), 125 + 1, 126 + orm.FilterEq("repo_owner", f.Did), 127 + orm.FilterEq("repo_name", f.Name), 128 + orm.FilterEq("knot", f.Knot), 129 + orm.FilterEq("id", pipelineId), 122 130 ) 123 131 if err != nil { 124 132 l.Error("failed to query db", "err", err) ··· 134 142 135 143 p.pages.Workflow(w, pages.WorkflowParams{ 136 144 LoggedInUser: user, 137 - RepoInfo: repoInfo, 145 + RepoInfo: p.repoResolver.GetRepoInfo(r, user), 138 146 Pipeline: singlePipeline, 139 147 Workflow: workflow, 140 148 }) ··· 165 173 ctx, cancel := context.WithCancel(r.Context()) 166 174 defer cancel() 167 175 168 - user := p.oauth.GetUser(r) 169 176 f, err := p.repoResolver.Resolve(r) 170 177 if err != nil { 171 178 l.Error("failed to get repo and knot", "err", err) 172 179 http.Error(w, "bad repo/knot", http.StatusBadRequest) 173 180 return 174 181 } 175 - 176 - repoInfo := f.RepoInfo(user) 177 182 178 183 pipelineId := chi.URLParam(r, "pipeline") 179 184 workflow := chi.URLParam(r, "workflow") ··· 184 189 185 190 ps, err := db.GetPipelineStatuses( 186 191 p.db, 187 - db.FilterEq("repo_owner", repoInfo.OwnerDid), 188 - db.FilterEq("repo_name", repoInfo.Name), 189 - db.FilterEq("knot", repoInfo.Knot), 190 - db.FilterEq("id", pipelineId), 192 + 1, 193 + orm.FilterEq("repo_owner", f.Did), 194 + orm.FilterEq("repo_name", f.Name), 195 + orm.FilterEq("knot", f.Knot), 196 + orm.FilterEq("id", pipelineId), 191 197 ) 192 198 if err != nil || len(ps) != 1 { 193 199 l.Error("pipeline query failed", "err", err, "count", len(ps)) ··· 196 202 } 197 203 198 204 singlePipeline := ps[0] 199 - spindle := repoInfo.Spindle 200 - knot := repoInfo.Knot 205 + spindle := f.Spindle 206 + knot := f.Knot 201 207 rkey := singlePipeline.Rkey 202 208 203 209 if spindle == "" || knot == "" || rkey == "" { ··· 227 233 // start a goroutine to read from spindle 228 234 go readLogs(spindleConn, evChan) 229 235 230 - stepIdx := 0 236 + stepStartTimes := make(map[int]time.Time) 231 237 var fragment bytes.Buffer 232 238 for { 233 239 select { ··· 259 265 260 266 switch logLine.Kind { 261 267 case spindlemodel.LogKindControl: 262 - // control messages create a new step block 263 - stepIdx++ 264 - collapsed := false 265 - if logLine.StepKind == spindlemodel.StepKindSystem { 266 - collapsed = true 268 + switch logLine.StepStatus { 269 + case spindlemodel.StepStatusStart: 270 + stepStartTimes[logLine.StepId] = logLine.Time 271 + collapsed := false 272 + if logLine.StepKind == spindlemodel.StepKindSystem { 273 + collapsed = true 274 + } 275 + err = p.pages.LogBlock(&fragment, pages.LogBlockParams{ 276 + Id: logLine.StepId, 277 + Name: logLine.Content, 278 + Command: logLine.StepCommand, 279 + Collapsed: collapsed, 280 + StartTime: logLine.Time, 281 + }) 282 + case spindlemodel.StepStatusEnd: 283 + startTime := stepStartTimes[logLine.StepId] 284 + endTime := logLine.Time 285 + err = p.pages.LogBlockEnd(&fragment, pages.LogBlockEndParams{ 286 + Id: logLine.StepId, 287 + StartTime: startTime, 288 + EndTime: endTime, 289 + }) 267 290 } 268 - err = p.pages.LogBlock(&fragment, pages.LogBlockParams{ 269 - Id: stepIdx, 270 - Name: logLine.Content, 271 - Command: logLine.StepCommand, 272 - Collapsed: collapsed, 273 - }) 291 + 274 292 case spindlemodel.LogKindData: 275 293 // data messages simply insert new log lines into current step 276 294 err = p.pages.LogLine(&fragment, pages.LogLineParams{ 277 - Id: stepIdx, 295 + Id: logLine.StepId, 278 296 Content: logLine.Content, 279 297 }) 280 298 }
-17
appview/pipelines/router.go
··· 1 - package pipelines 2 - 3 - import ( 4 - "net/http" 5 - 6 - "github.com/go-chi/chi/v5" 7 - "tangled.org/core/appview/middleware" 8 - ) 9 - 10 - func (p *Pipelines) Router(mw *middleware.Middleware) http.Handler { 11 - r := chi.NewRouter() 12 - r.Get("/", p.Index) 13 - r.Get("/{pipeline}/workflow/{workflow}", p.Workflow) 14 - r.Get("/{pipeline}/workflow/{workflow}/logs", p.Logs) 15 - 16 - return r 17 - }
+10 -9
appview/pulls/opengraph.go
··· 13 13 "tangled.org/core/appview/db" 14 14 "tangled.org/core/appview/models" 15 15 "tangled.org/core/appview/ogcard" 16 + "tangled.org/core/orm" 16 17 "tangled.org/core/patchutil" 17 18 "tangled.org/core/types" 18 19 ) ··· 146 147 var statusColor color.RGBA 147 148 148 149 if pull.State.IsOpen() { 149 - statusIcon = "static/icons/git-pull-request.svg" 150 + statusIcon = "git-pull-request" 150 151 statusText = "open" 151 152 statusColor = color.RGBA{34, 139, 34, 255} // green 152 153 } else if pull.State.IsMerged() { 153 - statusIcon = "static/icons/git-merge.svg" 154 + statusIcon = "git-merge" 154 155 statusText = "merged" 155 156 statusColor = color.RGBA{138, 43, 226, 255} // purple 156 157 } else { 157 - statusIcon = "static/icons/git-pull-request-closed.svg" 158 + statusIcon = "git-pull-request-closed" 158 159 statusText = "closed" 159 160 statusColor = color.RGBA{128, 128, 128, 255} // gray 160 161 } ··· 162 163 statusIconSize := 36 163 164 164 165 // Draw icon with status color 165 - err = statusStatsArea.DrawSVGIcon(statusIcon, statsX, statsY+iconBaselineOffset-statusIconSize/2+5, statusIconSize, statusColor) 166 + err = statusStatsArea.DrawLucideIcon(statusIcon, statsX, statsY+iconBaselineOffset-statusIconSize/2+5, statusIconSize, statusColor) 166 167 if err != nil { 167 168 log.Printf("failed to draw status icon: %v", err) 168 169 } ··· 179 180 currentX := statsX + statusIconSize + 12 + statusTextWidth + 40 180 181 181 182 // Draw comment count 182 - err = statusStatsArea.DrawSVGIcon("static/icons/message-square.svg", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) 183 + err = statusStatsArea.DrawLucideIcon("message-square", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) 183 184 if err != nil { 184 185 log.Printf("failed to draw comment icon: %v", err) 185 186 } ··· 198 199 currentX += commentTextWidth + 40 199 200 200 201 // Draw files changed 201 - err = statusStatsArea.DrawSVGIcon("static/icons/file-diff.svg", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) 202 + err = statusStatsArea.DrawLucideIcon("static/icons/file-diff", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) 202 203 if err != nil { 203 204 log.Printf("failed to draw file diff icon: %v", err) 204 205 } ··· 241 242 dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2) 242 243 dollyY := statsY + iconBaselineOffset - dollySize/2 + 25 243 244 dollyColor := color.RGBA{180, 180, 180, 255} // light gray 244 - err = dollyArea.DrawSVGIcon("templates/fragments/dolly/silhouette.svg", dollyX, dollyY, dollySize, dollyColor) 245 + err = dollyArea.DrawDolly(dollyX, dollyY, dollySize, dollyColor) 245 246 if err != nil { 246 247 log.Printf("dolly silhouette not available (this is ok): %v", err) 247 248 } ··· 276 277 } 277 278 278 279 // Get comment count from database 279 - comments, err := db.GetPullComments(s.db, db.FilterEq("pull_id", pull.ID)) 280 + comments, err := db.GetPullComments(s.db, orm.FilterEq("pull_id", pull.ID)) 280 281 if err != nil { 281 282 log.Printf("failed to get pull comments: %v", err) 282 283 } ··· 293 294 filesChanged = niceDiff.Stat.FilesChanged 294 295 } 295 296 296 - card, err := s.drawPullSummaryCard(pull, &f.Repo, commentCount, diffStats, filesChanged) 297 + card, err := s.drawPullSummaryCard(pull, f, commentCount, diffStats, filesChanged) 297 298 if err != nil { 298 299 log.Println("failed to draw pull summary card", err) 299 300 http.Error(w, "failed to draw pull summary card", http.StatusInternalServerError)
+244 -174
appview/pulls/pulls.go
··· 1 1 package pulls 2 2 3 3 import ( 4 + "context" 4 5 "database/sql" 5 6 "encoding/json" 6 7 "errors" ··· 17 18 "tangled.org/core/api/tangled" 18 19 "tangled.org/core/appview/config" 19 20 "tangled.org/core/appview/db" 21 + pulls_indexer "tangled.org/core/appview/indexer/pulls" 22 + "tangled.org/core/appview/mentions" 20 23 "tangled.org/core/appview/models" 21 24 "tangled.org/core/appview/notify" 22 25 "tangled.org/core/appview/oauth" 23 26 "tangled.org/core/appview/pages" 24 27 "tangled.org/core/appview/pages/markup" 28 + "tangled.org/core/appview/pages/repoinfo" 25 29 "tangled.org/core/appview/reporesolver" 26 30 "tangled.org/core/appview/validator" 27 31 "tangled.org/core/appview/xrpcclient" 28 32 "tangled.org/core/idresolver" 33 + "tangled.org/core/orm" 29 34 "tangled.org/core/patchutil" 30 35 "tangled.org/core/rbac" 31 36 "tangled.org/core/tid" 32 37 "tangled.org/core/types" 33 38 34 39 comatproto "github.com/bluesky-social/indigo/api/atproto" 40 + "github.com/bluesky-social/indigo/atproto/syntax" 35 41 lexutil "github.com/bluesky-social/indigo/lex/util" 36 42 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 37 43 "github.com/go-chi/chi/v5" ··· 39 45 ) 40 46 41 47 type Pulls struct { 42 - oauth *oauth.OAuth 43 - repoResolver *reporesolver.RepoResolver 44 - pages *pages.Pages 45 - idResolver *idresolver.Resolver 46 - db *db.DB 47 - config *config.Config 48 - notifier notify.Notifier 49 - enforcer *rbac.Enforcer 50 - logger *slog.Logger 51 - validator *validator.Validator 48 + oauth *oauth.OAuth 49 + repoResolver *reporesolver.RepoResolver 50 + pages *pages.Pages 51 + idResolver *idresolver.Resolver 52 + mentionsResolver *mentions.Resolver 53 + db *db.DB 54 + config *config.Config 55 + notifier notify.Notifier 56 + enforcer *rbac.Enforcer 57 + logger *slog.Logger 58 + validator *validator.Validator 59 + indexer *pulls_indexer.Indexer 52 60 } 53 61 54 62 func New( ··· 56 64 repoResolver *reporesolver.RepoResolver, 57 65 pages *pages.Pages, 58 66 resolver *idresolver.Resolver, 67 + mentionsResolver *mentions.Resolver, 59 68 db *db.DB, 60 69 config *config.Config, 61 70 notifier notify.Notifier, 62 71 enforcer *rbac.Enforcer, 63 72 validator *validator.Validator, 73 + indexer *pulls_indexer.Indexer, 64 74 logger *slog.Logger, 65 75 ) *Pulls { 66 76 return &Pulls{ 67 - oauth: oauth, 68 - repoResolver: repoResolver, 69 - pages: pages, 70 - idResolver: resolver, 71 - db: db, 72 - config: config, 73 - notifier: notifier, 74 - enforcer: enforcer, 75 - logger: logger, 76 - validator: validator, 77 + oauth: oauth, 78 + repoResolver: repoResolver, 79 + pages: pages, 80 + idResolver: resolver, 81 + mentionsResolver: mentionsResolver, 82 + db: db, 83 + config: config, 84 + notifier: notifier, 85 + enforcer: enforcer, 86 + logger: logger, 87 + validator: validator, 88 + indexer: indexer, 77 89 } 78 90 } 79 91 ··· 118 130 119 131 s.pages.PullActionsFragment(w, pages.PullActionsParams{ 120 132 LoggedInUser: user, 121 - RepoInfo: f.RepoInfo(user), 133 + RepoInfo: s.repoResolver.GetRepoInfo(r, user), 122 134 Pull: pull, 123 135 RoundNumber: roundNumber, 124 136 MergeCheck: mergeCheckResponse, ··· 145 157 return 146 158 } 147 159 160 + backlinks, err := db.GetBacklinks(s.db, pull.AtUri()) 161 + if err != nil { 162 + log.Println("failed to get pull backlinks", err) 163 + s.pages.Notice(w, "pull-error", "Failed to get pull. Try again later.") 164 + return 165 + } 166 + 148 167 // can be nil if this pull is not stacked 149 168 stack, _ := r.Context().Value("stack").(models.Stack) 150 169 abandonedPulls, _ := r.Context().Value("abandonedPulls").([]*models.Pull) ··· 155 174 if user != nil && user.Did == pull.OwnerDid { 156 175 resubmitResult = s.resubmitCheck(r, f, pull, stack) 157 176 } 158 - 159 - repoInfo := f.RepoInfo(user) 160 177 161 178 m := make(map[string]models.Pipeline) 162 179 ··· 173 190 174 191 ps, err := db.GetPipelineStatuses( 175 192 s.db, 176 - db.FilterEq("repo_owner", repoInfo.OwnerDid), 177 - db.FilterEq("repo_name", repoInfo.Name), 178 - db.FilterEq("knot", repoInfo.Knot), 179 - db.FilterIn("sha", shas), 193 + len(shas), 194 + orm.FilterEq("repo_owner", f.Did), 195 + orm.FilterEq("repo_name", f.Name), 196 + orm.FilterEq("knot", f.Knot), 197 + orm.FilterIn("sha", shas), 180 198 ) 181 199 if err != nil { 182 200 log.Printf("failed to fetch pipeline statuses: %s", err) ··· 187 205 m[p.Sha] = p 188 206 } 189 207 190 - reactionMap, err := db.GetReactionMap(s.db, 20, pull.PullAt()) 208 + reactionMap, err := db.GetReactionMap(s.db, 20, pull.AtUri()) 191 209 if err != nil { 192 210 log.Println("failed to get pull reactions") 193 211 s.pages.Notice(w, "pulls", "Failed to load pull. Try again later.") ··· 195 213 196 214 userReactions := map[models.ReactionKind]bool{} 197 215 if user != nil { 198 - userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.PullAt()) 216 + userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.AtUri()) 199 217 } 200 218 201 219 labelDefs, err := db.GetLabelDefinitions( 202 220 s.db, 203 - db.FilterIn("at_uri", f.Repo.Labels), 204 - db.FilterContains("scope", tangled.RepoPullNSID), 221 + orm.FilterIn("at_uri", f.Labels), 222 + orm.FilterContains("scope", tangled.RepoPullNSID), 205 223 ) 206 224 if err != nil { 207 225 log.Println("failed to fetch labels", err) ··· 216 234 217 235 s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 218 236 LoggedInUser: user, 219 - RepoInfo: repoInfo, 237 + RepoInfo: s.repoResolver.GetRepoInfo(r, user), 220 238 Pull: pull, 221 239 Stack: stack, 222 240 AbandonedPulls: abandonedPulls, 241 + Backlinks: backlinks, 223 242 BranchDeleteStatus: branchDeleteStatus, 224 243 MergeCheck: mergeCheckResponse, 225 244 ResubmitCheck: resubmitResult, ··· 233 252 }) 234 253 } 235 254 236 - func (s *Pulls) mergeCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *models.Pull, stack models.Stack) types.MergeCheckResponse { 255 + func (s *Pulls) mergeCheck(r *http.Request, f *models.Repo, pull *models.Pull, stack models.Stack) types.MergeCheckResponse { 237 256 if pull.State == models.PullMerged { 238 257 return types.MergeCheckResponse{} 239 258 } ··· 262 281 r.Context(), 263 282 &xrpcc, 264 283 &tangled.RepoMergeCheck_Input{ 265 - Did: f.OwnerDid(), 284 + Did: f.Did, 266 285 Name: f.Name, 267 286 Branch: pull.TargetBranch, 268 287 Patch: patch, ··· 300 319 return result 301 320 } 302 321 303 - func (s *Pulls) branchDeleteStatus(r *http.Request, f *reporesolver.ResolvedRepo, pull *models.Pull) *models.BranchDeleteStatus { 322 + func (s *Pulls) branchDeleteStatus(r *http.Request, repo *models.Repo, pull *models.Pull) *models.BranchDeleteStatus { 304 323 if pull.State != models.PullMerged { 305 324 return nil 306 325 } ··· 311 330 } 312 331 313 332 var branch string 314 - var repo *models.Repo 315 333 // check if the branch exists 316 334 // NOTE: appview could cache branches/tags etc. for every repo by listening for gitRefUpdates 317 335 if pull.IsBranchBased() { 318 336 branch = pull.PullSource.Branch 319 - repo = &f.Repo 320 337 } else if pull.IsForkBased() { 321 338 branch = pull.PullSource.Branch 322 339 repo = pull.PullSource.Repo ··· 355 372 } 356 373 } 357 374 358 - func (s *Pulls) resubmitCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *models.Pull, stack models.Stack) pages.ResubmitResult { 375 + func (s *Pulls) resubmitCheck(r *http.Request, repo *models.Repo, pull *models.Pull, stack models.Stack) pages.ResubmitResult { 359 376 if pull.State == models.PullMerged || pull.State == models.PullDeleted || pull.PullSource == nil { 360 377 return pages.Unknown 361 378 } ··· 375 392 repoName = sourceRepo.Name 376 393 } else { 377 394 // pulls within the same repo 378 - knot = f.Knot 379 - ownerDid = f.OwnerDid() 380 - repoName = f.Name 395 + knot = repo.Knot 396 + ownerDid = repo.Did 397 + repoName = repo.Name 381 398 } 382 399 383 400 scheme := "http" ··· 389 406 Host: host, 390 407 } 391 408 392 - repo := fmt.Sprintf("%s/%s", ownerDid, repoName) 393 - branchResp, err := tangled.RepoBranch(r.Context(), xrpcc, pull.PullSource.Branch, repo) 409 + didSlashName := fmt.Sprintf("%s/%s", ownerDid, repoName) 410 + branchResp, err := tangled.RepoBranch(r.Context(), xrpcc, pull.PullSource.Branch, didSlashName) 394 411 if err != nil { 395 412 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 396 413 log.Println("failed to call XRPC repo.branches", xrpcerr) ··· 418 435 419 436 func (s *Pulls) RepoPullPatch(w http.ResponseWriter, r *http.Request) { 420 437 user := s.oauth.GetUser(r) 421 - f, err := s.repoResolver.Resolve(r) 422 - if err != nil { 423 - log.Println("failed to get repo and knot", err) 424 - return 425 - } 426 438 427 439 var diffOpts types.DiffOpts 428 440 if d := r.URL.Query().Get("diff"); d == "split" { ··· 451 463 452 464 s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{ 453 465 LoggedInUser: user, 454 - RepoInfo: f.RepoInfo(user), 466 + RepoInfo: s.repoResolver.GetRepoInfo(r, user), 455 467 Pull: pull, 456 468 Stack: stack, 457 469 Round: roundIdInt, ··· 465 477 func (s *Pulls) RepoPullInterdiff(w http.ResponseWriter, r *http.Request) { 466 478 user := s.oauth.GetUser(r) 467 479 468 - f, err := s.repoResolver.Resolve(r) 469 - if err != nil { 470 - log.Println("failed to get repo and knot", err) 471 - return 472 - } 473 - 474 480 var diffOpts types.DiffOpts 475 481 if d := r.URL.Query().Get("diff"); d == "split" { 476 482 diffOpts.Split = true ··· 515 521 516 522 s.pages.RepoPullInterdiffPage(w, pages.RepoPullInterdiffParams{ 517 523 LoggedInUser: s.oauth.GetUser(r), 518 - RepoInfo: f.RepoInfo(user), 524 + RepoInfo: s.repoResolver.GetRepoInfo(r, user), 519 525 Pull: pull, 520 526 Round: roundIdInt, 521 527 Interdiff: interdiff, ··· 544 550 } 545 551 546 552 func (s *Pulls) RepoPulls(w http.ResponseWriter, r *http.Request) { 553 + l := s.logger.With("handler", "RepoPulls") 554 + 547 555 user := s.oauth.GetUser(r) 548 556 params := r.URL.Query() 549 557 ··· 561 569 return 562 570 } 563 571 572 + keyword := params.Get("q") 573 + 574 + var ids []int64 575 + searchOpts := models.PullSearchOptions{ 576 + Keyword: keyword, 577 + RepoAt: f.RepoAt().String(), 578 + State: state, 579 + // Page: page, 580 + } 581 + l.Debug("searching with", "searchOpts", searchOpts) 582 + if keyword != "" { 583 + res, err := s.indexer.Search(r.Context(), searchOpts) 584 + if err != nil { 585 + l.Error("failed to search for pulls", "err", err) 586 + return 587 + } 588 + ids = res.Hits 589 + l.Debug("searched pulls with indexer", "count", len(ids)) 590 + } else { 591 + ids, err = db.GetPullIDs(s.db, searchOpts) 592 + if err != nil { 593 + l.Error("failed to get all pull ids", "err", err) 594 + return 595 + } 596 + l.Debug("indexed all pulls from the db", "count", len(ids)) 597 + } 598 + 564 599 pulls, err := db.GetPulls( 565 600 s.db, 566 - db.FilterEq("repo_at", f.RepoAt()), 567 - db.FilterEq("state", state), 601 + orm.FilterIn("id", ids), 568 602 ) 569 603 if err != nil { 570 604 log.Println("failed to get pulls", err) ··· 612 646 } 613 647 pulls = pulls[:n] 614 648 615 - repoInfo := f.RepoInfo(user) 616 649 ps, err := db.GetPipelineStatuses( 617 650 s.db, 618 - db.FilterEq("repo_owner", repoInfo.OwnerDid), 619 - db.FilterEq("repo_name", repoInfo.Name), 620 - db.FilterEq("knot", repoInfo.Knot), 621 - db.FilterIn("sha", shas), 651 + len(shas), 652 + orm.FilterEq("repo_owner", f.Did), 653 + orm.FilterEq("repo_name", f.Name), 654 + orm.FilterEq("knot", f.Knot), 655 + orm.FilterIn("sha", shas), 622 656 ) 623 657 if err != nil { 624 658 log.Printf("failed to fetch pipeline statuses: %s", err) ··· 631 665 632 666 labelDefs, err := db.GetLabelDefinitions( 633 667 s.db, 634 - db.FilterIn("at_uri", f.Repo.Labels), 635 - db.FilterContains("scope", tangled.RepoPullNSID), 668 + orm.FilterIn("at_uri", f.Labels), 669 + orm.FilterContains("scope", tangled.RepoPullNSID), 636 670 ) 637 671 if err != nil { 638 672 log.Println("failed to fetch labels", err) ··· 647 681 648 682 s.pages.RepoPulls(w, pages.RepoPullsParams{ 649 683 LoggedInUser: s.oauth.GetUser(r), 650 - RepoInfo: f.RepoInfo(user), 684 + RepoInfo: s.repoResolver.GetRepoInfo(r, user), 651 685 Pulls: pulls, 652 686 LabelDefs: defs, 653 687 FilteringBy: state, 688 + FilterQuery: keyword, 654 689 Stacks: stacks, 655 690 Pipelines: m, 656 691 }) ··· 683 718 case http.MethodGet: 684 719 s.pages.PullNewCommentFragment(w, pages.PullNewCommentParams{ 685 720 LoggedInUser: user, 686 - RepoInfo: f.RepoInfo(user), 721 + RepoInfo: s.repoResolver.GetRepoInfo(r, user), 687 722 Pull: pull, 688 723 RoundNumber: roundNumber, 689 724 }) ··· 695 730 return 696 731 } 697 732 733 + mentions, references := s.mentionsResolver.Resolve(r.Context(), body) 734 + 698 735 // Start a transaction 699 736 tx, err := s.db.BeginTx(r.Context(), nil) 700 737 if err != nil { ··· 718 755 Rkey: tid.TID(), 719 756 Record: &lexutil.LexiconTypeDecoder{ 720 757 Val: &tangled.RepoPullComment{ 721 - Pull: pull.PullAt().String(), 758 + Pull: pull.AtUri().String(), 722 759 Body: body, 723 760 CreatedAt: createdAt, 724 761 }, ··· 737 774 Body: body, 738 775 CommentAt: atResp.Uri, 739 776 SubmissionId: pull.Submissions[roundNumber].ID, 777 + Mentions: mentions, 778 + References: references, 740 779 } 741 780 742 781 // Create the pull comment in the database with the commentAt field ··· 754 793 return 755 794 } 756 795 757 - s.notifier.NewPullComment(r.Context(), comment) 796 + s.notifier.NewPullComment(r.Context(), comment, mentions) 758 797 759 - s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId)) 798 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 799 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", ownerSlashRepo, pull.PullId, commentId)) 760 800 return 761 801 } 762 802 } ··· 780 820 Host: host, 781 821 } 782 822 783 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 823 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 784 824 xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 785 825 if err != nil { 786 826 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { ··· 807 847 808 848 s.pages.RepoNewPull(w, pages.RepoNewPullParams{ 809 849 LoggedInUser: user, 810 - RepoInfo: f.RepoInfo(user), 850 + RepoInfo: s.repoResolver.GetRepoInfo(r, user), 811 851 Branches: result.Branches, 812 852 Strategy: strategy, 813 853 SourceBranch: sourceBranch, ··· 830 870 } 831 871 832 872 // Determine PR type based on input parameters 833 - isPushAllowed := f.RepoInfo(user).Roles.IsPushAllowed() 873 + roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())} 874 + isPushAllowed := roles.IsPushAllowed() 834 875 isBranchBased := isPushAllowed && sourceBranch != "" && fromFork == "" 835 876 isForkBased := fromFork != "" && sourceBranch != "" 836 877 isPatchBased := patch != "" && !isBranchBased && !isForkBased ··· 928 969 func (s *Pulls) handleBranchBasedPull( 929 970 w http.ResponseWriter, 930 971 r *http.Request, 931 - f *reporesolver.ResolvedRepo, 972 + repo *models.Repo, 932 973 user *oauth.User, 933 974 title, 934 975 body, ··· 940 981 if !s.config.Core.Dev { 941 982 scheme = "https" 942 983 } 943 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 984 + host := fmt.Sprintf("%s://%s", scheme, repo.Knot) 944 985 xrpcc := &indigoxrpc.Client{ 945 986 Host: host, 946 987 } 947 988 948 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 949 - xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, targetBranch, sourceBranch) 989 + didSlashRepo := fmt.Sprintf("%s/%s", repo.Did, repo.Name) 990 + xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, didSlashRepo, targetBranch, sourceBranch) 950 991 if err != nil { 951 992 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 952 993 log.Println("failed to call XRPC repo.compare", xrpcerr) ··· 983 1024 Sha: comparison.Rev2, 984 1025 } 985 1026 986 - s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, combined, sourceRev, pullSource, recordPullSource, isStacked) 1027 + s.createPullRequest(w, r, repo, user, title, body, targetBranch, patch, combined, sourceRev, pullSource, recordPullSource, isStacked) 987 1028 } 988 1029 989 - func (s *Pulls) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, f *reporesolver.ResolvedRepo, user *oauth.User, title, body, targetBranch, patch string, isStacked bool) { 1030 + func (s *Pulls) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, repo *models.Repo, user *oauth.User, title, body, targetBranch, patch string, isStacked bool) { 990 1031 if err := s.validator.ValidatePatch(&patch); err != nil { 991 1032 s.logger.Error("patch validation failed", "err", err) 992 1033 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 993 1034 return 994 1035 } 995 1036 996 - s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, "", "", nil, nil, isStacked) 1037 + s.createPullRequest(w, r, repo, user, title, body, targetBranch, patch, "", "", nil, nil, isStacked) 997 1038 } 998 1039 999 - func (s *Pulls) handleForkBasedPull(w http.ResponseWriter, r *http.Request, f *reporesolver.ResolvedRepo, user *oauth.User, forkRepo string, title, body, targetBranch, sourceBranch string, isStacked bool) { 1040 + func (s *Pulls) handleForkBasedPull(w http.ResponseWriter, r *http.Request, repo *models.Repo, user *oauth.User, forkRepo string, title, body, targetBranch, sourceBranch string, isStacked bool) { 1000 1041 repoString := strings.SplitN(forkRepo, "/", 2) 1001 1042 forkOwnerDid := repoString[0] 1002 1043 repoName := repoString[1] ··· 1098 1139 Sha: sourceRev, 1099 1140 } 1100 1141 1101 - s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, combined, sourceRev, pullSource, recordPullSource, isStacked) 1142 + s.createPullRequest(w, r, repo, user, title, body, targetBranch, patch, combined, sourceRev, pullSource, recordPullSource, isStacked) 1102 1143 } 1103 1144 1104 1145 func (s *Pulls) createPullRequest( 1105 1146 w http.ResponseWriter, 1106 1147 r *http.Request, 1107 - f *reporesolver.ResolvedRepo, 1148 + repo *models.Repo, 1108 1149 user *oauth.User, 1109 1150 title, body, targetBranch string, 1110 1151 patch string, ··· 1119 1160 s.createStackedPullRequest( 1120 1161 w, 1121 1162 r, 1122 - f, 1163 + repo, 1123 1164 user, 1124 1165 targetBranch, 1125 1166 patch, ··· 1165 1206 } 1166 1207 } 1167 1208 1209 + mentions, references := s.mentionsResolver.Resolve(r.Context(), body) 1210 + 1168 1211 rkey := tid.TID() 1169 1212 initialSubmission := models.PullSubmission{ 1170 1213 Patch: patch, ··· 1176 1219 Body: body, 1177 1220 TargetBranch: targetBranch, 1178 1221 OwnerDid: user.Did, 1179 - RepoAt: f.RepoAt(), 1222 + RepoAt: repo.RepoAt(), 1180 1223 Rkey: rkey, 1224 + Mentions: mentions, 1225 + References: references, 1181 1226 Submissions: []*models.PullSubmission{ 1182 1227 &initialSubmission, 1183 1228 }, ··· 1189 1234 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1190 1235 return 1191 1236 } 1192 - pullId, err := db.NextPullId(tx, f.RepoAt()) 1237 + pullId, err := db.NextPullId(tx, repo.RepoAt()) 1193 1238 if err != nil { 1194 1239 log.Println("failed to get pull id", err) 1195 1240 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1196 1241 return 1197 1242 } 1198 1243 1244 + blob, err := comatproto.RepoUploadBlob(r.Context(), client, strings.NewReader(patch)) 1245 + if err != nil { 1246 + log.Println("failed to upload patch", err) 1247 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1248 + return 1249 + } 1250 + 1199 1251 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1200 1252 Collection: tangled.RepoPullNSID, 1201 1253 Repo: user.Did, ··· 1204 1256 Val: &tangled.RepoPull{ 1205 1257 Title: title, 1206 1258 Target: &tangled.RepoPull_Target{ 1207 - Repo: string(f.RepoAt()), 1259 + Repo: string(repo.RepoAt()), 1208 1260 Branch: targetBranch, 1209 1261 }, 1210 - Patch: patch, 1262 + PatchBlob: blob.Blob, 1211 1263 Source: recordPullSource, 1212 1264 CreatedAt: time.Now().Format(time.RFC3339), 1213 1265 }, ··· 1227 1279 1228 1280 s.notifier.NewPull(r.Context(), pull) 1229 1281 1230 - s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId)) 1282 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo) 1283 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pullId)) 1231 1284 } 1232 1285 1233 1286 func (s *Pulls) createStackedPullRequest( 1234 1287 w http.ResponseWriter, 1235 1288 r *http.Request, 1236 - f *reporesolver.ResolvedRepo, 1289 + repo *models.Repo, 1237 1290 user *oauth.User, 1238 1291 targetBranch string, 1239 1292 patch string, ··· 1265 1318 1266 1319 // build a stack out of this patch 1267 1320 stackId := uuid.New() 1268 - stack, err := newStack(f, user, targetBranch, patch, pullSource, stackId.String()) 1321 + stack, err := s.newStack(r.Context(), repo, user, targetBranch, patch, pullSource, stackId.String()) 1269 1322 if err != nil { 1270 1323 log.Println("failed to create stack", err) 1271 1324 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to create stack: %v", err)) ··· 1282 1335 // apply all record creations at once 1283 1336 var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem 1284 1337 for _, p := range stack { 1338 + blob, err := comatproto.RepoUploadBlob(r.Context(), client, strings.NewReader(p.LatestPatch())) 1339 + if err != nil { 1340 + log.Println("failed to upload patch blob", err) 1341 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1342 + return 1343 + } 1344 + 1285 1345 record := p.AsRecord() 1286 - write := comatproto.RepoApplyWrites_Input_Writes_Elem{ 1346 + record.PatchBlob = blob.Blob 1347 + writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 1287 1348 RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{ 1288 1349 Collection: tangled.RepoPullNSID, 1289 1350 Rkey: &p.Rkey, ··· 1291 1352 Val: &record, 1292 1353 }, 1293 1354 }, 1294 - } 1295 - writes = append(writes, &write) 1355 + }) 1296 1356 } 1297 1357 _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 1298 1358 Repo: user.Did, ··· 1320 1380 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1321 1381 return 1322 1382 } 1383 + 1323 1384 } 1324 1385 1325 1386 if err = tx.Commit(); err != nil { ··· 1328 1389 return 1329 1390 } 1330 1391 1331 - s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls", f.OwnerSlashRepo())) 1392 + // notify about each pull 1393 + // 1394 + // this is performed after tx.Commit, because it could result in a locked DB otherwise 1395 + for _, p := range stack { 1396 + s.notifier.NewPull(r.Context(), p) 1397 + } 1398 + 1399 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo) 1400 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls", ownerSlashRepo)) 1332 1401 } 1333 1402 1334 1403 func (s *Pulls) ValidatePatch(w http.ResponseWriter, r *http.Request) { ··· 1359 1428 1360 1429 func (s *Pulls) PatchUploadFragment(w http.ResponseWriter, r *http.Request) { 1361 1430 user := s.oauth.GetUser(r) 1362 - f, err := s.repoResolver.Resolve(r) 1363 - if err != nil { 1364 - log.Println("failed to get repo and knot", err) 1365 - return 1366 - } 1367 1431 1368 1432 s.pages.PullPatchUploadFragment(w, pages.PullPatchUploadParams{ 1369 - RepoInfo: f.RepoInfo(user), 1433 + RepoInfo: s.repoResolver.GetRepoInfo(r, user), 1370 1434 }) 1371 1435 } 1372 1436 ··· 1387 1451 Host: host, 1388 1452 } 1389 1453 1390 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1454 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 1391 1455 xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 1392 1456 if err != nil { 1393 1457 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { ··· 1420 1484 } 1421 1485 1422 1486 s.pages.PullCompareBranchesFragment(w, pages.PullCompareBranchesParams{ 1423 - RepoInfo: f.RepoInfo(user), 1487 + RepoInfo: s.repoResolver.GetRepoInfo(r, user), 1424 1488 Branches: withoutDefault, 1425 1489 }) 1426 1490 } 1427 1491 1428 1492 func (s *Pulls) CompareForksFragment(w http.ResponseWriter, r *http.Request) { 1429 1493 user := s.oauth.GetUser(r) 1430 - f, err := s.repoResolver.Resolve(r) 1431 - if err != nil { 1432 - log.Println("failed to get repo and knot", err) 1433 - return 1434 - } 1435 1494 1436 1495 forks, err := db.GetForksByDid(s.db, user.Did) 1437 1496 if err != nil { ··· 1440 1499 } 1441 1500 1442 1501 s.pages.PullCompareForkFragment(w, pages.PullCompareForkParams{ 1443 - RepoInfo: f.RepoInfo(user), 1502 + RepoInfo: s.repoResolver.GetRepoInfo(r, user), 1444 1503 Forks: forks, 1445 1504 Selected: r.URL.Query().Get("fork"), 1446 1505 }) ··· 1462 1521 // fork repo 1463 1522 repo, err := db.GetRepo( 1464 1523 s.db, 1465 - db.FilterEq("did", forkOwnerDid), 1466 - db.FilterEq("name", forkName), 1524 + orm.FilterEq("did", forkOwnerDid), 1525 + orm.FilterEq("name", forkName), 1467 1526 ) 1468 1527 if err != nil { 1469 1528 log.Println("failed to get repo", "did", forkOwnerDid, "name", forkName, "err", err) ··· 1508 1567 Host: targetHost, 1509 1568 } 1510 1569 1511 - targetRepo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1570 + targetRepo := fmt.Sprintf("%s/%s", f.Did, f.Name) 1512 1571 targetXrpcBytes, err := tangled.RepoBranches(r.Context(), targetXrpcc, "", 0, targetRepo) 1513 1572 if err != nil { 1514 1573 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { ··· 1533 1592 }) 1534 1593 1535 1594 s.pages.PullCompareForkBranchesFragment(w, pages.PullCompareForkBranchesParams{ 1536 - RepoInfo: f.RepoInfo(user), 1595 + RepoInfo: s.repoResolver.GetRepoInfo(r, user), 1537 1596 SourceBranches: sourceBranches.Branches, 1538 1597 TargetBranches: targetBranches.Branches, 1539 1598 }) ··· 1541 1600 1542 1601 func (s *Pulls) ResubmitPull(w http.ResponseWriter, r *http.Request) { 1543 1602 user := s.oauth.GetUser(r) 1544 - f, err := s.repoResolver.Resolve(r) 1545 - if err != nil { 1546 - log.Println("failed to get repo and knot", err) 1547 - return 1548 - } 1549 1603 1550 1604 pull, ok := r.Context().Value("pull").(*models.Pull) 1551 1605 if !ok { ··· 1557 1611 switch r.Method { 1558 1612 case http.MethodGet: 1559 1613 s.pages.PullResubmitFragment(w, pages.PullResubmitParams{ 1560 - RepoInfo: f.RepoInfo(user), 1614 + RepoInfo: s.repoResolver.GetRepoInfo(r, user), 1561 1615 Pull: pull, 1562 1616 }) 1563 1617 return ··· 1624 1678 return 1625 1679 } 1626 1680 1627 - if !f.RepoInfo(user).Roles.IsPushAllowed() { 1681 + roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())} 1682 + if !roles.IsPushAllowed() { 1628 1683 log.Println("unauthorized user") 1629 1684 w.WriteHeader(http.StatusUnauthorized) 1630 1685 return ··· 1639 1694 Host: host, 1640 1695 } 1641 1696 1642 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1697 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 1643 1698 xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, pull.TargetBranch, pull.PullSource.Branch) 1644 1699 if err != nil { 1645 1700 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { ··· 1766 1821 func (s *Pulls) resubmitPullHelper( 1767 1822 w http.ResponseWriter, 1768 1823 r *http.Request, 1769 - f *reporesolver.ResolvedRepo, 1824 + repo *models.Repo, 1770 1825 user *oauth.User, 1771 1826 pull *models.Pull, 1772 1827 patch string, ··· 1775 1830 ) { 1776 1831 if pull.IsStacked() { 1777 1832 log.Println("resubmitting stacked PR") 1778 - s.resubmitStackedPullHelper(w, r, f, user, pull, patch, pull.StackId) 1833 + s.resubmitStackedPullHelper(w, r, repo, user, pull, patch, pull.StackId) 1779 1834 return 1780 1835 } 1781 1836 ··· 1805 1860 } 1806 1861 defer tx.Rollback() 1807 1862 1808 - pullAt := pull.PullAt() 1863 + pullAt := pull.AtUri() 1809 1864 newRoundNumber := len(pull.Submissions) 1810 1865 newPatch := patch 1811 1866 newSourceRev := sourceRev ··· 1830 1885 return 1831 1886 } 1832 1887 1833 - var recordPullSource *tangled.RepoPull_Source 1834 - if pull.IsBranchBased() { 1835 - recordPullSource = &tangled.RepoPull_Source{ 1836 - Branch: pull.PullSource.Branch, 1837 - Sha: sourceRev, 1838 - } 1888 + blob, err := comatproto.RepoUploadBlob(r.Context(), client, strings.NewReader(patch)) 1889 + if err != nil { 1890 + log.Println("failed to upload patch blob", err) 1891 + s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 1892 + return 1839 1893 } 1840 - if pull.IsForkBased() { 1841 - repoAt := pull.PullSource.RepoAt.String() 1842 - recordPullSource = &tangled.RepoPull_Source{ 1843 - Branch: pull.PullSource.Branch, 1844 - Repo: &repoAt, 1845 - Sha: sourceRev, 1846 - } 1847 - } 1894 + record := pull.AsRecord() 1895 + record.PatchBlob = blob.Blob 1896 + record.CreatedAt = time.Now().Format(time.RFC3339) 1848 1897 1849 1898 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1850 1899 Collection: tangled.RepoPullNSID, ··· 1852 1901 Rkey: pull.Rkey, 1853 1902 SwapRecord: ex.Cid, 1854 1903 Record: &lexutil.LexiconTypeDecoder{ 1855 - Val: &tangled.RepoPull{ 1856 - Title: pull.Title, 1857 - Target: &tangled.RepoPull_Target{ 1858 - Repo: string(f.RepoAt()), 1859 - Branch: pull.TargetBranch, 1860 - }, 1861 - Patch: patch, // new patch 1862 - Source: recordPullSource, 1863 - CreatedAt: time.Now().Format(time.RFC3339), 1864 - }, 1904 + Val: &record, 1865 1905 }, 1866 1906 }) 1867 1907 if err != nil { ··· 1876 1916 return 1877 1917 } 1878 1918 1879 - s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 1919 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo) 1920 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 1880 1921 } 1881 1922 1882 1923 func (s *Pulls) resubmitStackedPullHelper( 1883 1924 w http.ResponseWriter, 1884 1925 r *http.Request, 1885 - f *reporesolver.ResolvedRepo, 1926 + repo *models.Repo, 1886 1927 user *oauth.User, 1887 1928 pull *models.Pull, 1888 1929 patch string, ··· 1891 1932 targetBranch := pull.TargetBranch 1892 1933 1893 1934 origStack, _ := r.Context().Value("stack").(models.Stack) 1894 - newStack, err := newStack(f, user, targetBranch, patch, pull.PullSource, stackId) 1935 + newStack, err := s.newStack(r.Context(), repo, user, targetBranch, patch, pull.PullSource, stackId) 1895 1936 if err != nil { 1896 1937 log.Println("failed to create resubmitted stack", err) 1897 1938 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") ··· 1946 1987 } 1947 1988 defer tx.Rollback() 1948 1989 1990 + client, err := s.oauth.AuthorizedClient(r) 1991 + if err != nil { 1992 + log.Println("failed to authorize client") 1993 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1994 + return 1995 + } 1996 + 1949 1997 // pds updates to make 1950 1998 var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem 1951 1999 ··· 1979 2027 return 1980 2028 } 1981 2029 2030 + blob, err := comatproto.RepoUploadBlob(r.Context(), client, strings.NewReader(patch)) 2031 + if err != nil { 2032 + log.Println("failed to upload patch blob", err) 2033 + s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 2034 + return 2035 + } 1982 2036 record := p.AsRecord() 2037 + record.PatchBlob = blob.Blob 1983 2038 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 1984 2039 RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{ 1985 2040 Collection: tangled.RepoPullNSID, ··· 2002 2057 } 2003 2058 2004 2059 // resubmit the new pull 2005 - pullAt := op.PullAt() 2060 + pullAt := op.AtUri() 2006 2061 newRoundNumber := len(op.Submissions) 2007 2062 newPatch := np.LatestPatch() 2008 2063 combinedPatch := np.LatestSubmission().Combined ··· 2014 2069 return 2015 2070 } 2016 2071 2072 + blob, err := comatproto.RepoUploadBlob(r.Context(), client, strings.NewReader(patch)) 2073 + if err != nil { 2074 + log.Println("failed to upload patch blob", err) 2075 + s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 2076 + return 2077 + } 2017 2078 record := np.AsRecord() 2018 - 2079 + record.PatchBlob = blob.Blob 2019 2080 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 2020 2081 RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{ 2021 2082 Collection: tangled.RepoPullNSID, ··· 2033 2094 tx, 2034 2095 p.ParentChangeId, 2035 2096 // these should be enough filters to be unique per-stack 2036 - db.FilterEq("repo_at", p.RepoAt.String()), 2037 - db.FilterEq("owner_did", p.OwnerDid), 2038 - db.FilterEq("change_id", p.ChangeId), 2097 + orm.FilterEq("repo_at", p.RepoAt.String()), 2098 + orm.FilterEq("owner_did", p.OwnerDid), 2099 + orm.FilterEq("change_id", p.ChangeId), 2039 2100 ) 2040 2101 2041 2102 if err != nil { ··· 2052 2113 return 2053 2114 } 2054 2115 2055 - client, err := s.oauth.AuthorizedClient(r) 2056 - if err != nil { 2057 - log.Println("failed to authorize client") 2058 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 2059 - return 2060 - } 2061 - 2062 2116 _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 2063 2117 Repo: user.Did, 2064 2118 Writes: writes, ··· 2069 2123 return 2070 2124 } 2071 2125 2072 - s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 2126 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo) 2127 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 2073 2128 } 2074 2129 2075 2130 func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) { 2131 + user := s.oauth.GetUser(r) 2076 2132 f, err := s.repoResolver.Resolve(r) 2077 2133 if err != nil { 2078 2134 log.Println("failed to resolve repo:", err) ··· 2121 2177 2122 2178 authorName := ident.Handle.String() 2123 2179 mergeInput := &tangled.RepoMerge_Input{ 2124 - Did: f.OwnerDid(), 2180 + Did: f.Did, 2125 2181 Name: f.Name, 2126 2182 Branch: pull.TargetBranch, 2127 2183 Patch: patch, ··· 2170 2226 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 2171 2227 return 2172 2228 } 2229 + p.State = models.PullMerged 2173 2230 } 2174 2231 2175 2232 err = tx.Commit() ··· 2182 2239 2183 2240 // notify about the pull merge 2184 2241 for _, p := range pullsToMerge { 2185 - s.notifier.NewPullMerged(r.Context(), p) 2242 + s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p) 2186 2243 } 2187 2244 2188 - s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.Name, pull.PullId)) 2245 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 2246 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 2189 2247 } 2190 2248 2191 2249 func (s *Pulls) ClosePull(w http.ResponseWriter, r *http.Request) { ··· 2205 2263 } 2206 2264 2207 2265 // auth filter: only owner or collaborators can close 2208 - roles := f.RolesInRepo(user) 2266 + roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())} 2209 2267 isOwner := roles.IsOwner() 2210 2268 isCollaborator := roles.IsCollaborator() 2211 2269 isPullAuthor := user.Did == pull.OwnerDid ··· 2243 2301 s.pages.Notice(w, "pull-close", "Failed to close pull.") 2244 2302 return 2245 2303 } 2304 + p.State = models.PullClosed 2246 2305 } 2247 2306 2248 2307 // Commit the transaction ··· 2253 2312 } 2254 2313 2255 2314 for _, p := range pullsToClose { 2256 - s.notifier.NewPullClosed(r.Context(), p) 2315 + s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p) 2257 2316 } 2258 2317 2259 - s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 2318 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 2319 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 2260 2320 } 2261 2321 2262 2322 func (s *Pulls) ReopenPull(w http.ResponseWriter, r *http.Request) { ··· 2277 2337 } 2278 2338 2279 2339 // auth filter: only owner or collaborators can close 2280 - roles := f.RolesInRepo(user) 2340 + roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())} 2281 2341 isOwner := roles.IsOwner() 2282 2342 isCollaborator := roles.IsCollaborator() 2283 2343 isPullAuthor := user.Did == pull.OwnerDid ··· 2315 2375 s.pages.Notice(w, "pull-close", "Failed to close pull.") 2316 2376 return 2317 2377 } 2378 + p.State = models.PullOpen 2318 2379 } 2319 2380 2320 2381 // Commit the transaction ··· 2324 2385 return 2325 2386 } 2326 2387 2327 - s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 2388 + for _, p := range pullsToReopen { 2389 + s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p) 2390 + } 2391 + 2392 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 2393 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 2328 2394 } 2329 2395 2330 - func newStack(f *reporesolver.ResolvedRepo, user *oauth.User, targetBranch, patch string, pullSource *models.PullSource, stackId string) (models.Stack, error) { 2396 + func (s *Pulls) newStack(ctx context.Context, repo *models.Repo, user *oauth.User, targetBranch, patch string, pullSource *models.PullSource, stackId string) (models.Stack, error) { 2331 2397 formatPatches, err := patchutil.ExtractPatches(patch) 2332 2398 if err != nil { 2333 2399 return nil, fmt.Errorf("Failed to extract patches: %v", err) ··· 2352 2418 body := fp.Body 2353 2419 rkey := tid.TID() 2354 2420 2421 + mentions, references := s.mentionsResolver.Resolve(ctx, body) 2422 + 2355 2423 initialSubmission := models.PullSubmission{ 2356 2424 Patch: fp.Raw, 2357 2425 SourceRev: fp.SHA, ··· 2362 2430 Body: body, 2363 2431 TargetBranch: targetBranch, 2364 2432 OwnerDid: user.Did, 2365 - RepoAt: f.RepoAt(), 2433 + RepoAt: repo.RepoAt(), 2366 2434 Rkey: rkey, 2435 + Mentions: mentions, 2436 + References: references, 2367 2437 Submissions: []*models.PullSubmission{ 2368 2438 &initialSubmission, 2369 2439 },
+50
appview/repo/archive.go
··· 1 + package repo 2 + 3 + import ( 4 + "fmt" 5 + "net/http" 6 + "net/url" 7 + "strings" 8 + 9 + "tangled.org/core/api/tangled" 10 + xrpcclient "tangled.org/core/appview/xrpcclient" 11 + 12 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 13 + "github.com/go-chi/chi/v5" 14 + "github.com/go-git/go-git/v5/plumbing" 15 + ) 16 + 17 + func (rp *Repo) DownloadArchive(w http.ResponseWriter, r *http.Request) { 18 + l := rp.logger.With("handler", "DownloadArchive") 19 + ref := chi.URLParam(r, "ref") 20 + ref, _ = url.PathUnescape(ref) 21 + ref = strings.TrimSuffix(ref, ".tar.gz") 22 + f, err := rp.repoResolver.Resolve(r) 23 + if err != nil { 24 + l.Error("failed to get repo and knot", "err", err) 25 + return 26 + } 27 + scheme := "http" 28 + if !rp.config.Core.Dev { 29 + scheme = "https" 30 + } 31 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 32 + xrpcc := &indigoxrpc.Client{ 33 + Host: host, 34 + } 35 + didSlashRepo := f.DidSlashRepo() 36 + archiveBytes, err := tangled.RepoArchive(r.Context(), xrpcc, "tar.gz", "", ref, didSlashRepo) 37 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 38 + l.Error("failed to call XRPC repo.archive", "err", xrpcerr) 39 + rp.pages.Error503(w) 40 + return 41 + } 42 + // Set headers for file download, just pass along whatever the knot specifies 43 + safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(ref).Short(), "/", "-") 44 + filename := fmt.Sprintf("%s-%s.tar.gz", f.Name, safeRefFilename) 45 + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) 46 + w.Header().Set("Content-Type", "application/gzip") 47 + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(archiveBytes))) 48 + // Write the archive data directly 49 + w.Write(archiveBytes) 50 + }
+21 -14
appview/repo/artifact.go
··· 14 14 "tangled.org/core/appview/db" 15 15 "tangled.org/core/appview/models" 16 16 "tangled.org/core/appview/pages" 17 - "tangled.org/core/appview/reporesolver" 18 17 "tangled.org/core/appview/xrpcclient" 18 + "tangled.org/core/orm" 19 19 "tangled.org/core/tid" 20 20 "tangled.org/core/types" 21 21 ··· 131 131 132 132 rp.pages.RepoArtifactFragment(w, pages.RepoArtifactParams{ 133 133 LoggedInUser: user, 134 - RepoInfo: f.RepoInfo(user), 134 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 135 135 Artifact: artifact, 136 136 }) 137 137 } ··· 156 156 157 157 artifacts, err := db.GetArtifact( 158 158 rp.db, 159 - db.FilterEq("repo_at", f.RepoAt()), 160 - db.FilterEq("tag", tag.Tag.Hash[:]), 161 - db.FilterEq("name", filename), 159 + orm.FilterEq("repo_at", f.RepoAt()), 160 + orm.FilterEq("tag", tag.Tag.Hash[:]), 161 + orm.FilterEq("name", filename), 162 162 ) 163 163 if err != nil { 164 164 log.Println("failed to get artifacts", err) ··· 174 174 175 175 artifact := artifacts[0] 176 176 177 - ownerPds := f.OwnerId.PDSEndpoint() 177 + ownerId, err := rp.idResolver.ResolveIdent(r.Context(), f.Did) 178 + if err != nil { 179 + log.Println("failed to resolve repo owner did", f.Did, err) 180 + http.Error(w, "repository owner not found", http.StatusNotFound) 181 + return 182 + } 183 + 184 + ownerPds := ownerId.PDSEndpoint() 178 185 url, _ := url.Parse(fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob", ownerPds)) 179 186 q := url.Query() 180 187 q.Set("cid", artifact.BlobCid.String()) ··· 228 235 229 236 artifacts, err := db.GetArtifact( 230 237 rp.db, 231 - db.FilterEq("repo_at", f.RepoAt()), 232 - db.FilterEq("tag", tag[:]), 233 - db.FilterEq("name", filename), 238 + orm.FilterEq("repo_at", f.RepoAt()), 239 + orm.FilterEq("tag", tag[:]), 240 + orm.FilterEq("name", filename), 234 241 ) 235 242 if err != nil { 236 243 log.Println("failed to get artifacts", err) ··· 270 277 defer tx.Rollback() 271 278 272 279 err = db.DeleteArtifact(tx, 273 - db.FilterEq("repo_at", f.RepoAt()), 274 - db.FilterEq("tag", artifact.Tag[:]), 275 - db.FilterEq("name", filename), 280 + orm.FilterEq("repo_at", f.RepoAt()), 281 + orm.FilterEq("tag", artifact.Tag[:]), 282 + orm.FilterEq("name", filename), 276 283 ) 277 284 if err != nil { 278 285 log.Println("failed to remove artifact record from db", err) ··· 290 297 w.Write([]byte{}) 291 298 } 292 299 293 - func (rp *Repo) resolveTag(ctx context.Context, f *reporesolver.ResolvedRepo, tagParam string) (*types.TagReference, error) { 300 + func (rp *Repo) resolveTag(ctx context.Context, f *models.Repo, tagParam string) (*types.TagReference, error) { 294 301 tagParam, err := url.QueryUnescape(tagParam) 295 302 if err != nil { 296 303 return nil, err ··· 305 312 Host: host, 306 313 } 307 314 308 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 315 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 309 316 xrpcBytes, err := tangled.RepoTags(ctx, xrpcc, "", 0, repo) 310 317 if err != nil { 311 318 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+293
appview/repo/blob.go
··· 1 + package repo 2 + 3 + import ( 4 + "encoding/base64" 5 + "fmt" 6 + "io" 7 + "net/http" 8 + "net/url" 9 + "path/filepath" 10 + "slices" 11 + "strings" 12 + 13 + "tangled.org/core/api/tangled" 14 + "tangled.org/core/appview/config" 15 + "tangled.org/core/appview/models" 16 + "tangled.org/core/appview/pages" 17 + "tangled.org/core/appview/pages/markup" 18 + "tangled.org/core/appview/reporesolver" 19 + xrpcclient "tangled.org/core/appview/xrpcclient" 20 + 21 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 22 + "github.com/go-chi/chi/v5" 23 + ) 24 + 25 + // the content can be one of the following: 26 + // 27 + // - code : text | | raw 28 + // - markup : text | rendered | raw 29 + // - svg : text | rendered | raw 30 + // - png : | rendered | raw 31 + // - video : | rendered | raw 32 + // - submodule : | rendered | 33 + // - rest : | | 34 + func (rp *Repo) Blob(w http.ResponseWriter, r *http.Request) { 35 + l := rp.logger.With("handler", "RepoBlob") 36 + 37 + f, err := rp.repoResolver.Resolve(r) 38 + if err != nil { 39 + l.Error("failed to get repo and knot", "err", err) 40 + return 41 + } 42 + 43 + ref := chi.URLParam(r, "ref") 44 + ref, _ = url.PathUnescape(ref) 45 + 46 + filePath := chi.URLParam(r, "*") 47 + filePath, _ = url.PathUnescape(filePath) 48 + 49 + scheme := "http" 50 + if !rp.config.Core.Dev { 51 + scheme = "https" 52 + } 53 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 54 + xrpcc := &indigoxrpc.Client{ 55 + Host: host, 56 + } 57 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 58 + resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, repo) 59 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 60 + l.Error("failed to call XRPC repo.blob", "err", xrpcerr) 61 + rp.pages.Error503(w) 62 + return 63 + } 64 + 65 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 66 + 67 + // Use XRPC response directly instead of converting to internal types 68 + var breadcrumbs [][]string 69 + breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", ownerSlashRepo, url.PathEscape(ref))}) 70 + if filePath != "" { 71 + for idx, elem := range strings.Split(filePath, "/") { 72 + breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))}) 73 + } 74 + } 75 + 76 + // Create the blob view 77 + blobView := NewBlobView(resp, rp.config, f, ref, filePath, r.URL.Query()) 78 + 79 + user := rp.oauth.GetUser(r) 80 + 81 + rp.pages.RepoBlob(w, pages.RepoBlobParams{ 82 + LoggedInUser: user, 83 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 84 + BreadCrumbs: breadcrumbs, 85 + BlobView: blobView, 86 + RepoBlob_Output: resp, 87 + }) 88 + } 89 + 90 + func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) { 91 + l := rp.logger.With("handler", "RepoBlobRaw") 92 + 93 + f, err := rp.repoResolver.Resolve(r) 94 + if err != nil { 95 + l.Error("failed to get repo and knot", "err", err) 96 + w.WriteHeader(http.StatusBadRequest) 97 + return 98 + } 99 + 100 + ref := chi.URLParam(r, "ref") 101 + ref, _ = url.PathUnescape(ref) 102 + 103 + filePath := chi.URLParam(r, "*") 104 + filePath, _ = url.PathUnescape(filePath) 105 + 106 + scheme := "http" 107 + if !rp.config.Core.Dev { 108 + scheme = "https" 109 + } 110 + repo := f.DidSlashRepo() 111 + baseURL := &url.URL{ 112 + Scheme: scheme, 113 + Host: f.Knot, 114 + Path: "/xrpc/sh.tangled.repo.blob", 115 + } 116 + query := baseURL.Query() 117 + query.Set("repo", repo) 118 + query.Set("ref", ref) 119 + query.Set("path", filePath) 120 + query.Set("raw", "true") 121 + baseURL.RawQuery = query.Encode() 122 + blobURL := baseURL.String() 123 + req, err := http.NewRequest("GET", blobURL, nil) 124 + if err != nil { 125 + l.Error("failed to create request", "err", err) 126 + return 127 + } 128 + 129 + // forward the If-None-Match header 130 + if clientETag := r.Header.Get("If-None-Match"); clientETag != "" { 131 + req.Header.Set("If-None-Match", clientETag) 132 + } 133 + client := &http.Client{} 134 + 135 + resp, err := client.Do(req) 136 + if err != nil { 137 + l.Error("failed to reach knotserver", "err", err) 138 + rp.pages.Error503(w) 139 + return 140 + } 141 + 142 + defer resp.Body.Close() 143 + 144 + // forward 304 not modified 145 + if resp.StatusCode == http.StatusNotModified { 146 + w.WriteHeader(http.StatusNotModified) 147 + return 148 + } 149 + 150 + if resp.StatusCode != http.StatusOK { 151 + l.Error("knotserver returned non-OK status for raw blob", "url", blobURL, "statuscode", resp.StatusCode) 152 + w.WriteHeader(resp.StatusCode) 153 + _, _ = io.Copy(w, resp.Body) 154 + return 155 + } 156 + 157 + contentType := resp.Header.Get("Content-Type") 158 + body, err := io.ReadAll(resp.Body) 159 + if err != nil { 160 + l.Error("error reading response body from knotserver", "err", err) 161 + w.WriteHeader(http.StatusInternalServerError) 162 + return 163 + } 164 + 165 + if strings.HasPrefix(contentType, "text/") || isTextualMimeType(contentType) { 166 + // serve all textual content as text/plain 167 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 168 + w.Write(body) 169 + } else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") { 170 + // serve images and videos with their original content type 171 + w.Header().Set("Content-Type", contentType) 172 + w.Write(body) 173 + } else { 174 + w.WriteHeader(http.StatusUnsupportedMediaType) 175 + w.Write([]byte("unsupported content type")) 176 + return 177 + } 178 + } 179 + 180 + // NewBlobView creates a BlobView from the XRPC response 181 + func NewBlobView(resp *tangled.RepoBlob_Output, config *config.Config, repo *models.Repo, ref, filePath string, queryParams url.Values) models.BlobView { 182 + view := models.BlobView{ 183 + Contents: "", 184 + Lines: 0, 185 + } 186 + 187 + // Set size 188 + if resp.Size != nil { 189 + view.SizeHint = uint64(*resp.Size) 190 + } else if resp.Content != nil { 191 + view.SizeHint = uint64(len(*resp.Content)) 192 + } 193 + 194 + if resp.Submodule != nil { 195 + view.ContentType = models.BlobContentTypeSubmodule 196 + view.HasRenderedView = true 197 + view.ContentSrc = resp.Submodule.Url 198 + return view 199 + } 200 + 201 + // Determine if binary 202 + if resp.IsBinary != nil && *resp.IsBinary { 203 + view.ContentSrc = generateBlobURL(config, repo, ref, filePath) 204 + ext := strings.ToLower(filepath.Ext(resp.Path)) 205 + 206 + switch ext { 207 + case ".jpg", ".jpeg", ".png", ".gif", ".webp": 208 + view.ContentType = models.BlobContentTypeImage 209 + view.HasRawView = true 210 + view.HasRenderedView = true 211 + view.ShowingRendered = true 212 + 213 + case ".svg": 214 + view.ContentType = models.BlobContentTypeSvg 215 + view.HasRawView = true 216 + view.HasTextView = true 217 + view.HasRenderedView = true 218 + view.ShowingRendered = queryParams.Get("code") != "true" 219 + if resp.Content != nil { 220 + bytes, _ := base64.StdEncoding.DecodeString(*resp.Content) 221 + view.Contents = string(bytes) 222 + view.Lines = strings.Count(view.Contents, "\n") + 1 223 + } 224 + 225 + case ".mp4", ".webm", ".ogg", ".mov", ".avi": 226 + view.ContentType = models.BlobContentTypeVideo 227 + view.HasRawView = true 228 + view.HasRenderedView = true 229 + view.ShowingRendered = true 230 + } 231 + 232 + return view 233 + } 234 + 235 + // otherwise, we are dealing with text content 236 + view.HasRawView = true 237 + view.HasTextView = true 238 + 239 + if resp.Content != nil { 240 + view.Contents = *resp.Content 241 + view.Lines = strings.Count(view.Contents, "\n") + 1 242 + } 243 + 244 + // with text, we may be dealing with markdown 245 + format := markup.GetFormat(resp.Path) 246 + if format == markup.FormatMarkdown { 247 + view.ContentType = models.BlobContentTypeMarkup 248 + view.HasRenderedView = true 249 + view.ShowingRendered = queryParams.Get("code") != "true" 250 + } 251 + 252 + return view 253 + } 254 + 255 + func generateBlobURL(config *config.Config, repo *models.Repo, ref, filePath string) string { 256 + scheme := "http" 257 + if !config.Core.Dev { 258 + scheme = "https" 259 + } 260 + 261 + repoName := fmt.Sprintf("%s/%s", repo.Did, repo.Name) 262 + baseURL := &url.URL{ 263 + Scheme: scheme, 264 + Host: repo.Knot, 265 + Path: "/xrpc/sh.tangled.repo.blob", 266 + } 267 + query := baseURL.Query() 268 + query.Set("repo", repoName) 269 + query.Set("ref", ref) 270 + query.Set("path", filePath) 271 + query.Set("raw", "true") 272 + baseURL.RawQuery = query.Encode() 273 + blobURL := baseURL.String() 274 + 275 + if !config.Core.Dev { 276 + return markup.GenerateCamoURL(config.Camo.Host, config.Camo.SharedSecret, blobURL) 277 + } 278 + return blobURL 279 + } 280 + 281 + func isTextualMimeType(mimeType string) bool { 282 + textualTypes := []string{ 283 + "application/json", 284 + "application/xml", 285 + "application/yaml", 286 + "application/x-yaml", 287 + "application/toml", 288 + "application/javascript", 289 + "application/ecmascript", 290 + "message/", 291 + } 292 + return slices.Contains(textualTypes, mimeType) 293 + }
+95
appview/repo/branches.go
··· 1 + package repo 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + 8 + "tangled.org/core/api/tangled" 9 + "tangled.org/core/appview/oauth" 10 + "tangled.org/core/appview/pages" 11 + xrpcclient "tangled.org/core/appview/xrpcclient" 12 + "tangled.org/core/types" 13 + 14 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 15 + ) 16 + 17 + func (rp *Repo) Branches(w http.ResponseWriter, r *http.Request) { 18 + l := rp.logger.With("handler", "RepoBranches") 19 + f, err := rp.repoResolver.Resolve(r) 20 + if err != nil { 21 + l.Error("failed to get repo and knot", "err", err) 22 + return 23 + } 24 + scheme := "http" 25 + if !rp.config.Core.Dev { 26 + scheme = "https" 27 + } 28 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 29 + xrpcc := &indigoxrpc.Client{ 30 + Host: host, 31 + } 32 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 33 + xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 34 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 35 + l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 36 + rp.pages.Error503(w) 37 + return 38 + } 39 + var result types.RepoBranchesResponse 40 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 41 + l.Error("failed to decode XRPC response", "err", err) 42 + rp.pages.Error503(w) 43 + return 44 + } 45 + sortBranches(result.Branches) 46 + user := rp.oauth.GetUser(r) 47 + rp.pages.RepoBranches(w, pages.RepoBranchesParams{ 48 + LoggedInUser: user, 49 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 50 + RepoBranchesResponse: result, 51 + }) 52 + } 53 + 54 + func (rp *Repo) DeleteBranch(w http.ResponseWriter, r *http.Request) { 55 + l := rp.logger.With("handler", "DeleteBranch") 56 + f, err := rp.repoResolver.Resolve(r) 57 + if err != nil { 58 + l.Error("failed to get repo and knot", "err", err) 59 + return 60 + } 61 + noticeId := "delete-branch-error" 62 + fail := func(msg string, err error) { 63 + l.Error(msg, "err", err) 64 + rp.pages.Notice(w, noticeId, msg) 65 + } 66 + branch := r.FormValue("branch") 67 + if branch == "" { 68 + fail("No branch provided.", nil) 69 + return 70 + } 71 + client, err := rp.oauth.ServiceClient( 72 + r, 73 + oauth.WithService(f.Knot), 74 + oauth.WithLxm(tangled.RepoDeleteBranchNSID), 75 + oauth.WithDev(rp.config.Core.Dev), 76 + ) 77 + if err != nil { 78 + fail("Failed to connect to knotserver", nil) 79 + return 80 + } 81 + err = tangled.RepoDeleteBranch( 82 + r.Context(), 83 + client, 84 + &tangled.RepoDeleteBranch_Input{ 85 + Branch: branch, 86 + Repo: f.RepoAt().String(), 87 + }, 88 + ) 89 + if err := xrpcclient.HandleXrpcErr(err); err != nil { 90 + fail(fmt.Sprintf("Failed to delete branch: %s", err), err) 91 + return 92 + } 93 + l.Error("deleted branch from knot", "branch", branch, "repo", f.RepoAt()) 94 + rp.pages.HxRefresh(w) 95 + }
+214
appview/repo/compare.go
··· 1 + package repo 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + "net/url" 8 + "strings" 9 + 10 + "tangled.org/core/api/tangled" 11 + "tangled.org/core/appview/pages" 12 + xrpcclient "tangled.org/core/appview/xrpcclient" 13 + "tangled.org/core/patchutil" 14 + "tangled.org/core/types" 15 + 16 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 17 + "github.com/go-chi/chi/v5" 18 + ) 19 + 20 + func (rp *Repo) CompareNew(w http.ResponseWriter, r *http.Request) { 21 + l := rp.logger.With("handler", "RepoCompareNew") 22 + 23 + user := rp.oauth.GetUser(r) 24 + f, err := rp.repoResolver.Resolve(r) 25 + if err != nil { 26 + l.Error("failed to get repo and knot", "err", err) 27 + return 28 + } 29 + 30 + scheme := "http" 31 + if !rp.config.Core.Dev { 32 + scheme = "https" 33 + } 34 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 35 + xrpcc := &indigoxrpc.Client{ 36 + Host: host, 37 + } 38 + 39 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 40 + branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 41 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 42 + l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 43 + rp.pages.Error503(w) 44 + return 45 + } 46 + 47 + var branchResult types.RepoBranchesResponse 48 + if err := json.Unmarshal(branchBytes, &branchResult); err != nil { 49 + l.Error("failed to decode XRPC branches response", "err", err) 50 + rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 51 + return 52 + } 53 + branches := branchResult.Branches 54 + 55 + sortBranches(branches) 56 + 57 + var defaultBranch string 58 + for _, b := range branches { 59 + if b.IsDefault { 60 + defaultBranch = b.Name 61 + } 62 + } 63 + 64 + base := defaultBranch 65 + head := defaultBranch 66 + 67 + params := r.URL.Query() 68 + queryBase := params.Get("base") 69 + queryHead := params.Get("head") 70 + if queryBase != "" { 71 + base = queryBase 72 + } 73 + if queryHead != "" { 74 + head = queryHead 75 + } 76 + 77 + tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 78 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 79 + l.Error("failed to call XRPC repo.tags", "err", xrpcerr) 80 + rp.pages.Error503(w) 81 + return 82 + } 83 + 84 + var tags types.RepoTagsResponse 85 + if err := json.Unmarshal(tagBytes, &tags); err != nil { 86 + l.Error("failed to decode XRPC tags response", "err", err) 87 + rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 88 + return 89 + } 90 + 91 + rp.pages.RepoCompareNew(w, pages.RepoCompareNewParams{ 92 + LoggedInUser: user, 93 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 94 + Branches: branches, 95 + Tags: tags.Tags, 96 + Base: base, 97 + Head: head, 98 + }) 99 + } 100 + 101 + func (rp *Repo) Compare(w http.ResponseWriter, r *http.Request) { 102 + l := rp.logger.With("handler", "RepoCompare") 103 + 104 + user := rp.oauth.GetUser(r) 105 + f, err := rp.repoResolver.Resolve(r) 106 + if err != nil { 107 + l.Error("failed to get repo and knot", "err", err) 108 + return 109 + } 110 + 111 + var diffOpts types.DiffOpts 112 + if d := r.URL.Query().Get("diff"); d == "split" { 113 + diffOpts.Split = true 114 + } 115 + 116 + // if user is navigating to one of 117 + // /compare/{base}...{head} 118 + // /compare/{base}/{head} 119 + var base, head string 120 + rest := chi.URLParam(r, "*") 121 + 122 + var parts []string 123 + if strings.Contains(rest, "...") { 124 + parts = strings.SplitN(rest, "...", 2) 125 + } else if strings.Contains(rest, "/") { 126 + parts = strings.SplitN(rest, "/", 2) 127 + } 128 + 129 + if len(parts) == 2 { 130 + base = parts[0] 131 + head = parts[1] 132 + } 133 + 134 + base, _ = url.PathUnescape(base) 135 + head, _ = url.PathUnescape(head) 136 + 137 + if base == "" || head == "" { 138 + l.Error("invalid comparison") 139 + rp.pages.Error404(w) 140 + return 141 + } 142 + 143 + scheme := "http" 144 + if !rp.config.Core.Dev { 145 + scheme = "https" 146 + } 147 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 148 + xrpcc := &indigoxrpc.Client{ 149 + Host: host, 150 + } 151 + 152 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 153 + 154 + branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 155 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 156 + l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 157 + rp.pages.Error503(w) 158 + return 159 + } 160 + 161 + var branches types.RepoBranchesResponse 162 + if err := json.Unmarshal(branchBytes, &branches); err != nil { 163 + l.Error("failed to decode XRPC branches response", "err", err) 164 + rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 165 + return 166 + } 167 + 168 + tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 169 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 170 + l.Error("failed to call XRPC repo.tags", "err", xrpcerr) 171 + rp.pages.Error503(w) 172 + return 173 + } 174 + 175 + var tags types.RepoTagsResponse 176 + if err := json.Unmarshal(tagBytes, &tags); err != nil { 177 + l.Error("failed to decode XRPC tags response", "err", err) 178 + rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 179 + return 180 + } 181 + 182 + compareBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, base, head) 183 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 184 + l.Error("failed to call XRPC repo.compare", "err", xrpcerr) 185 + rp.pages.Error503(w) 186 + return 187 + } 188 + 189 + var formatPatch types.RepoFormatPatchResponse 190 + if err := json.Unmarshal(compareBytes, &formatPatch); err != nil { 191 + l.Error("failed to decode XRPC compare response", "err", err) 192 + rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 193 + return 194 + } 195 + 196 + var diff types.NiceDiff 197 + if formatPatch.CombinedPatchRaw != "" { 198 + diff = patchutil.AsNiceDiff(formatPatch.CombinedPatchRaw, base) 199 + } else { 200 + diff = patchutil.AsNiceDiff(formatPatch.FormatPatchRaw, base) 201 + } 202 + 203 + rp.pages.RepoCompare(w, pages.RepoCompareParams{ 204 + LoggedInUser: user, 205 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 206 + Branches: branches.Branches, 207 + Tags: tags.Tags, 208 + Base: base, 209 + Head: head, 210 + Diff: &diff, 211 + DiffOpts: diffOpts, 212 + }) 213 + 214 + }
+25 -18
appview/repo/feed.go
··· 11 11 "tangled.org/core/appview/db" 12 12 "tangled.org/core/appview/models" 13 13 "tangled.org/core/appview/pagination" 14 - "tangled.org/core/appview/reporesolver" 14 + "tangled.org/core/orm" 15 15 16 + "github.com/bluesky-social/indigo/atproto/identity" 16 17 "github.com/bluesky-social/indigo/atproto/syntax" 17 18 "github.com/gorilla/feeds" 18 19 ) 19 20 20 - func (rp *Repo) getRepoFeed(ctx context.Context, f *reporesolver.ResolvedRepo) (*feeds.Feed, error) { 21 + func (rp *Repo) getRepoFeed(ctx context.Context, repo *models.Repo, ownerSlashRepo string) (*feeds.Feed, error) { 21 22 const feedLimitPerType = 100 22 23 23 - pulls, err := db.GetPullsWithLimit(rp.db, feedLimitPerType, db.FilterEq("repo_at", f.RepoAt())) 24 + pulls, err := db.GetPullsWithLimit(rp.db, feedLimitPerType, orm.FilterEq("repo_at", repo.RepoAt())) 24 25 if err != nil { 25 26 return nil, err 26 27 } ··· 28 29 issues, err := db.GetIssuesPaginated( 29 30 rp.db, 30 31 pagination.Page{Limit: feedLimitPerType}, 31 - db.FilterEq("repo_at", f.RepoAt()), 32 + orm.FilterEq("repo_at", repo.RepoAt()), 32 33 ) 33 34 if err != nil { 34 35 return nil, err 35 36 } 36 37 37 38 feed := &feeds.Feed{ 38 - Title: fmt.Sprintf("activity feed for %s", f.OwnerSlashRepo()), 39 - Link: &feeds.Link{Href: fmt.Sprintf("%s/%s", rp.config.Core.AppviewHost, f.OwnerSlashRepo()), Type: "text/html", Rel: "alternate"}, 39 + Title: fmt.Sprintf("activity feed for @%s", ownerSlashRepo), 40 + Link: &feeds.Link{Href: fmt.Sprintf("%s/%s", rp.config.Core.AppviewHost, ownerSlashRepo), Type: "text/html", Rel: "alternate"}, 40 41 Items: make([]*feeds.Item, 0), 41 42 Updated: time.UnixMilli(0), 42 43 } 43 44 44 45 for _, pull := range pulls { 45 - items, err := rp.createPullItems(ctx, pull, f) 46 + items, err := rp.createPullItems(ctx, pull, repo, ownerSlashRepo) 46 47 if err != nil { 47 48 return nil, err 48 49 } ··· 50 51 } 51 52 52 53 for _, issue := range issues { 53 - item, err := rp.createIssueItem(ctx, issue, f) 54 + item, err := rp.createIssueItem(ctx, issue, repo, ownerSlashRepo) 54 55 if err != nil { 55 56 return nil, err 56 57 } ··· 71 72 return feed, nil 72 73 } 73 74 74 - func (rp *Repo) createPullItems(ctx context.Context, pull *models.Pull, f *reporesolver.ResolvedRepo) ([]*feeds.Item, error) { 75 + func (rp *Repo) createPullItems(ctx context.Context, pull *models.Pull, repo *models.Repo, ownerSlashRepo string) ([]*feeds.Item, error) { 75 76 owner, err := rp.idResolver.ResolveIdent(ctx, pull.OwnerDid) 76 77 if err != nil { 77 78 return nil, err ··· 80 81 var items []*feeds.Item 81 82 82 83 state := rp.getPullState(pull) 83 - description := rp.buildPullDescription(owner.Handle, state, pull, f.OwnerSlashRepo()) 84 + description := rp.buildPullDescription(owner.Handle, state, pull, ownerSlashRepo) 84 85 85 86 mainItem := &feeds.Item{ 86 87 Title: fmt.Sprintf("[PR #%d] %s", pull.PullId, pull.Title), 87 88 Description: description, 88 - Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/pulls/%d", rp.config.Core.AppviewHost, f.OwnerSlashRepo(), pull.PullId)}, 89 + Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/pulls/%d", rp.config.Core.AppviewHost, ownerSlashRepo, pull.PullId)}, 89 90 Created: pull.Created, 90 91 Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)}, 91 92 } ··· 98 99 99 100 roundItem := &feeds.Item{ 100 101 Title: fmt.Sprintf("[PR #%d] %s (round #%d)", pull.PullId, pull.Title, round.RoundNumber), 101 - Description: fmt.Sprintf("@%s submitted changes (at round #%d) on PR #%d in %s", owner.Handle, round.RoundNumber, pull.PullId, f.OwnerSlashRepo()), 102 - Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/pulls/%d/round/%d/", rp.config.Core.AppviewHost, f.OwnerSlashRepo(), pull.PullId, round.RoundNumber)}, 102 + Description: fmt.Sprintf("@%s submitted changes (at round #%d) on PR #%d in @%s", owner.Handle, round.RoundNumber, pull.PullId, ownerSlashRepo), 103 + Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/pulls/%d/round/%d/", rp.config.Core.AppviewHost, ownerSlashRepo, pull.PullId, round.RoundNumber)}, 103 104 Created: round.Created, 104 105 Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)}, 105 106 } ··· 109 110 return items, nil 110 111 } 111 112 112 - func (rp *Repo) createIssueItem(ctx context.Context, issue models.Issue, f *reporesolver.ResolvedRepo) (*feeds.Item, error) { 113 + func (rp *Repo) createIssueItem(ctx context.Context, issue models.Issue, repo *models.Repo, ownerSlashRepo string) (*feeds.Item, error) { 113 114 owner, err := rp.idResolver.ResolveIdent(ctx, issue.Did) 114 115 if err != nil { 115 116 return nil, err ··· 122 123 123 124 return &feeds.Item{ 124 125 Title: fmt.Sprintf("[Issue #%d] %s", issue.IssueId, issue.Title), 125 - Description: fmt.Sprintf("@%s %s issue #%d in %s", owner.Handle, state, issue.IssueId, f.OwnerSlashRepo()), 126 - Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/issues/%d", rp.config.Core.AppviewHost, f.OwnerSlashRepo(), issue.IssueId)}, 126 + Description: fmt.Sprintf("@%s %s issue #%d in @%s", owner.Handle, state, issue.IssueId, ownerSlashRepo), 127 + Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/issues/%d", rp.config.Core.AppviewHost, ownerSlashRepo, issue.IssueId)}, 127 128 Created: issue.Created, 128 129 Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)}, 129 130 }, nil ··· 146 147 return fmt.Sprintf("%s in %s", base, repoName) 147 148 } 148 149 149 - func (rp *Repo) RepoAtomFeed(w http.ResponseWriter, r *http.Request) { 150 + func (rp *Repo) AtomFeed(w http.ResponseWriter, r *http.Request) { 150 151 f, err := rp.repoResolver.Resolve(r) 151 152 if err != nil { 152 153 log.Println("failed to fully resolve repo:", err) 153 154 return 154 155 } 156 + repoOwnerId, ok := r.Context().Value("resolvedId").(identity.Identity) 157 + if !ok || repoOwnerId.Handle.IsInvalidHandle() { 158 + log.Println("failed to get resolved repo owner id") 159 + return 160 + } 161 + ownerSlashRepo := repoOwnerId.Handle.String() + "/" + f.Name 155 162 156 - feed, err := rp.getRepoFeed(r.Context(), f) 163 + feed, err := rp.getRepoFeed(r.Context(), f, ownerSlashRepo) 157 164 if err != nil { 158 165 log.Println("failed to get repo feed:", err) 159 166 rp.pages.Error500(w)
+28 -30
appview/repo/index.go
··· 22 22 "tangled.org/core/appview/db" 23 23 "tangled.org/core/appview/models" 24 24 "tangled.org/core/appview/pages" 25 - "tangled.org/core/appview/reporesolver" 26 25 "tangled.org/core/appview/xrpcclient" 26 + "tangled.org/core/orm" 27 27 "tangled.org/core/types" 28 28 29 29 "github.com/go-chi/chi/v5" 30 30 "github.com/go-enry/go-enry/v2" 31 31 ) 32 32 33 - func (rp *Repo) RepoIndex(w http.ResponseWriter, r *http.Request) { 33 + func (rp *Repo) Index(w http.ResponseWriter, r *http.Request) { 34 34 l := rp.logger.With("handler", "RepoIndex") 35 35 36 36 ref := chi.URLParam(r, "ref") ··· 52 52 } 53 53 54 54 user := rp.oauth.GetUser(r) 55 - repoInfo := f.RepoInfo(user) 56 55 57 56 // Build index response from multiple XRPC calls 58 57 result, err := rp.buildIndexResponse(r.Context(), xrpcc, f, ref) ··· 62 61 rp.pages.RepoIndexPage(w, pages.RepoIndexParams{ 63 62 LoggedInUser: user, 64 63 NeedsKnotUpgrade: true, 65 - RepoInfo: repoInfo, 64 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 66 65 }) 67 66 return 68 67 } ··· 124 123 l.Error("failed to get email to did map", "err", err) 125 124 } 126 125 127 - vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, commitsTrunc) 126 + vc, err := commitverify.GetVerifiedCommits(rp.db, emailToDidMap, commitsTrunc) 128 127 if err != nil { 129 128 l.Error("failed to GetVerifiedObjectCommits", "err", err) 130 129 } ··· 140 139 for _, c := range commitsTrunc { 141 140 shas = append(shas, c.Hash.String()) 142 141 } 143 - pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas) 142 + pipelines, err := getPipelineStatuses(rp.db, f, shas) 144 143 if err != nil { 145 144 l.Error("failed to fetch pipeline statuses", "err", err) 146 145 // non-fatal ··· 148 147 149 148 rp.pages.RepoIndexPage(w, pages.RepoIndexParams{ 150 149 LoggedInUser: user, 151 - RepoInfo: repoInfo, 150 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 152 151 TagMap: tagMap, 153 152 RepoIndexResponse: *result, 154 153 CommitsTrunc: commitsTrunc, 155 154 TagsTrunc: tagsTrunc, 156 155 // ForkInfo: forkInfo, // TODO: reinstate this after xrpc properly lands 157 - BranchesTrunc: branchesTrunc, 158 - EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap), 159 - VerifiedCommits: vc, 160 - Languages: languageInfo, 161 - Pipelines: pipelines, 156 + BranchesTrunc: branchesTrunc, 157 + EmailToDid: emailToDidMap, 158 + VerifiedCommits: vc, 159 + Languages: languageInfo, 160 + Pipelines: pipelines, 162 161 }) 163 162 } 164 163 165 164 func (rp *Repo) getLanguageInfo( 166 165 ctx context.Context, 167 166 l *slog.Logger, 168 - f *reporesolver.ResolvedRepo, 167 + repo *models.Repo, 169 168 xrpcc *indigoxrpc.Client, 170 169 currentRef string, 171 170 isDefaultRef bool, ··· 173 172 // first attempt to fetch from db 174 173 langs, err := db.GetRepoLanguages( 175 174 rp.db, 176 - db.FilterEq("repo_at", f.RepoAt()), 177 - db.FilterEq("ref", currentRef), 175 + orm.FilterEq("repo_at", repo.RepoAt()), 176 + orm.FilterEq("ref", currentRef), 178 177 ) 179 178 180 179 if err != nil || langs == nil { 181 180 // non-fatal, fetch langs from ks via XRPC 182 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 183 - ls, err := tangled.RepoLanguages(ctx, xrpcc, currentRef, repo) 181 + didSlashRepo := fmt.Sprintf("%s/%s", repo.Did, repo.Name) 182 + ls, err := tangled.RepoLanguages(ctx, xrpcc, currentRef, didSlashRepo) 184 183 if err != nil { 185 184 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 186 185 l.Error("failed to call XRPC repo.languages", "err", xrpcerr) ··· 195 194 196 195 for _, lang := range ls.Languages { 197 196 langs = append(langs, models.RepoLanguage{ 198 - RepoAt: f.RepoAt(), 197 + RepoAt: repo.RepoAt(), 199 198 Ref: currentRef, 200 199 IsDefaultRef: isDefaultRef, 201 200 Language: lang.Name, ··· 210 209 defer tx.Rollback() 211 210 212 211 // update appview's cache 213 - err = db.UpdateRepoLanguages(tx, f.RepoAt(), currentRef, langs) 212 + err = db.UpdateRepoLanguages(tx, repo.RepoAt(), currentRef, langs) 214 213 if err != nil { 215 214 // non-fatal 216 215 l.Error("failed to cache lang results", "err", err) ··· 255 254 } 256 255 257 256 // buildIndexResponse creates a RepoIndexResponse by combining multiple xrpc calls in parallel 258 - func (rp *Repo) buildIndexResponse(ctx context.Context, xrpcc *indigoxrpc.Client, f *reporesolver.ResolvedRepo, ref string) (*types.RepoIndexResponse, error) { 259 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 257 + func (rp *Repo) buildIndexResponse(ctx context.Context, xrpcc *indigoxrpc.Client, repo *models.Repo, ref string) (*types.RepoIndexResponse, error) { 258 + didSlashRepo := fmt.Sprintf("%s/%s", repo.Did, repo.Name) 260 259 261 260 // first get branches to determine the ref if not specified 262 - branchesBytes, err := tangled.RepoBranches(ctx, xrpcc, "", 0, repo) 261 + branchesBytes, err := tangled.RepoBranches(ctx, xrpcc, "", 0, didSlashRepo) 263 262 if err != nil { 264 263 return nil, fmt.Errorf("failed to call repoBranches: %w", err) 265 264 } ··· 303 302 wg.Add(1) 304 303 go func() { 305 304 defer wg.Done() 306 - tagsBytes, err := tangled.RepoTags(ctx, xrpcc, "", 0, repo) 305 + tagsBytes, err := tangled.RepoTags(ctx, xrpcc, "", 0, didSlashRepo) 307 306 if err != nil { 308 307 errs = errors.Join(errs, fmt.Errorf("failed to call repoTags: %w", err)) 309 308 return ··· 318 317 wg.Add(1) 319 318 go func() { 320 319 defer wg.Done() 321 - resp, err := tangled.RepoTree(ctx, xrpcc, "", ref, repo) 320 + resp, err := tangled.RepoTree(ctx, xrpcc, "", ref, didSlashRepo) 322 321 if err != nil { 323 322 errs = errors.Join(errs, fmt.Errorf("failed to call repoTree: %w", err)) 324 323 return ··· 330 329 wg.Add(1) 331 330 go func() { 332 331 defer wg.Done() 333 - logBytes, err := tangled.RepoLog(ctx, xrpcc, "", 50, "", ref, repo) 332 + logBytes, err := tangled.RepoLog(ctx, xrpcc, "", 50, "", ref, didSlashRepo) 334 333 if err != nil { 335 334 errs = errors.Join(errs, fmt.Errorf("failed to call repoLog: %w", err)) 336 335 return ··· 351 350 if treeResp != nil && treeResp.Files != nil { 352 351 for _, file := range treeResp.Files { 353 352 niceFile := types.NiceTree{ 354 - IsFile: file.Is_file, 355 - IsSubtree: file.Is_subtree, 356 - Name: file.Name, 357 - Mode: file.Mode, 358 - Size: file.Size, 353 + Name: file.Name, 354 + Mode: file.Mode, 355 + Size: file.Size, 359 356 } 357 + 360 358 if file.Last_commit != nil { 361 359 when, _ := time.Parse(time.RFC3339, file.Last_commit.When) 362 360 niceFile.LastCommit = &types.LastCommitInfo{
+220
appview/repo/log.go
··· 1 + package repo 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + "net/url" 8 + "strconv" 9 + 10 + "tangled.org/core/api/tangled" 11 + "tangled.org/core/appview/commitverify" 12 + "tangled.org/core/appview/db" 13 + "tangled.org/core/appview/models" 14 + "tangled.org/core/appview/pages" 15 + xrpcclient "tangled.org/core/appview/xrpcclient" 16 + "tangled.org/core/types" 17 + 18 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 19 + "github.com/go-chi/chi/v5" 20 + "github.com/go-git/go-git/v5/plumbing" 21 + ) 22 + 23 + func (rp *Repo) Log(w http.ResponseWriter, r *http.Request) { 24 + l := rp.logger.With("handler", "RepoLog") 25 + 26 + f, err := rp.repoResolver.Resolve(r) 27 + if err != nil { 28 + l.Error("failed to fully resolve repo", "err", err) 29 + return 30 + } 31 + 32 + page := 1 33 + if r.URL.Query().Get("page") != "" { 34 + page, err = strconv.Atoi(r.URL.Query().Get("page")) 35 + if err != nil { 36 + page = 1 37 + } 38 + } 39 + 40 + ref := chi.URLParam(r, "ref") 41 + ref, _ = url.PathUnescape(ref) 42 + 43 + scheme := "http" 44 + if !rp.config.Core.Dev { 45 + scheme = "https" 46 + } 47 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 48 + xrpcc := &indigoxrpc.Client{ 49 + Host: host, 50 + } 51 + 52 + limit := int64(60) 53 + cursor := "" 54 + if page > 1 { 55 + // Convert page number to cursor (offset) 56 + offset := (page - 1) * int(limit) 57 + cursor = strconv.Itoa(offset) 58 + } 59 + 60 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 61 + xrpcBytes, err := tangled.RepoLog(r.Context(), xrpcc, cursor, limit, "", ref, repo) 62 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 63 + l.Error("failed to call XRPC repo.log", "err", xrpcerr) 64 + rp.pages.Error503(w) 65 + return 66 + } 67 + 68 + var xrpcResp types.RepoLogResponse 69 + if err := json.Unmarshal(xrpcBytes, &xrpcResp); err != nil { 70 + l.Error("failed to decode XRPC response", "err", err) 71 + rp.pages.Error503(w) 72 + return 73 + } 74 + 75 + tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 76 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 77 + l.Error("failed to call XRPC repo.tags", "err", xrpcerr) 78 + rp.pages.Error503(w) 79 + return 80 + } 81 + 82 + tagMap := make(map[string][]string) 83 + if tagBytes != nil { 84 + var tagResp types.RepoTagsResponse 85 + if err := json.Unmarshal(tagBytes, &tagResp); err == nil { 86 + for _, tag := range tagResp.Tags { 87 + hash := tag.Hash 88 + if tag.Tag != nil { 89 + hash = tag.Tag.Target.String() 90 + } 91 + tagMap[hash] = append(tagMap[hash], tag.Name) 92 + } 93 + } 94 + } 95 + 96 + branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 97 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 98 + l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 99 + rp.pages.Error503(w) 100 + return 101 + } 102 + 103 + if branchBytes != nil { 104 + var branchResp types.RepoBranchesResponse 105 + if err := json.Unmarshal(branchBytes, &branchResp); err == nil { 106 + for _, branch := range branchResp.Branches { 107 + tagMap[branch.Hash] = append(tagMap[branch.Hash], branch.Name) 108 + } 109 + } 110 + } 111 + 112 + user := rp.oauth.GetUser(r) 113 + 114 + emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(xrpcResp.Commits), true) 115 + if err != nil { 116 + l.Error("failed to fetch email to did mapping", "err", err) 117 + } 118 + 119 + vc, err := commitverify.GetVerifiedCommits(rp.db, emailToDidMap, xrpcResp.Commits) 120 + if err != nil { 121 + l.Error("failed to GetVerifiedObjectCommits", "err", err) 122 + } 123 + 124 + var shas []string 125 + for _, c := range xrpcResp.Commits { 126 + shas = append(shas, c.Hash.String()) 127 + } 128 + pipelines, err := getPipelineStatuses(rp.db, f, shas) 129 + if err != nil { 130 + l.Error("failed to getPipelineStatuses", "err", err) 131 + // non-fatal 132 + } 133 + 134 + rp.pages.RepoLog(w, pages.RepoLogParams{ 135 + LoggedInUser: user, 136 + TagMap: tagMap, 137 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 138 + RepoLogResponse: xrpcResp, 139 + EmailToDid: emailToDidMap, 140 + VerifiedCommits: vc, 141 + Pipelines: pipelines, 142 + }) 143 + } 144 + 145 + func (rp *Repo) Commit(w http.ResponseWriter, r *http.Request) { 146 + l := rp.logger.With("handler", "RepoCommit") 147 + 148 + f, err := rp.repoResolver.Resolve(r) 149 + if err != nil { 150 + l.Error("failed to fully resolve repo", "err", err) 151 + return 152 + } 153 + ref := chi.URLParam(r, "ref") 154 + ref, _ = url.PathUnescape(ref) 155 + 156 + var diffOpts types.DiffOpts 157 + if d := r.URL.Query().Get("diff"); d == "split" { 158 + diffOpts.Split = true 159 + } 160 + 161 + if !plumbing.IsHash(ref) { 162 + rp.pages.Error404(w) 163 + return 164 + } 165 + 166 + scheme := "http" 167 + if !rp.config.Core.Dev { 168 + scheme = "https" 169 + } 170 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 171 + xrpcc := &indigoxrpc.Client{ 172 + Host: host, 173 + } 174 + 175 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 176 + xrpcBytes, err := tangled.RepoDiff(r.Context(), xrpcc, ref, repo) 177 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 178 + l.Error("failed to call XRPC repo.diff", "err", xrpcerr) 179 + rp.pages.Error503(w) 180 + return 181 + } 182 + 183 + var result types.RepoCommitResponse 184 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 185 + l.Error("failed to decode XRPC response", "err", err) 186 + rp.pages.Error503(w) 187 + return 188 + } 189 + 190 + emailToDidMap, err := db.GetEmailToDid(rp.db, []string{result.Diff.Commit.Committer.Email, result.Diff.Commit.Author.Email}, true) 191 + if err != nil { 192 + l.Error("failed to get email to did mapping", "err", err) 193 + } 194 + 195 + vc, err := commitverify.GetVerifiedCommits(rp.db, emailToDidMap, []types.Commit{result.Diff.Commit}) 196 + if err != nil { 197 + l.Error("failed to GetVerifiedCommits", "err", err) 198 + } 199 + 200 + user := rp.oauth.GetUser(r) 201 + pipelines, err := getPipelineStatuses(rp.db, f, []string{result.Diff.Commit.This}) 202 + if err != nil { 203 + l.Error("failed to getPipelineStatuses", "err", err) 204 + // non-fatal 205 + } 206 + var pipeline *models.Pipeline 207 + if p, ok := pipelines[result.Diff.Commit.This]; ok { 208 + pipeline = &p 209 + } 210 + 211 + rp.pages.RepoCommit(w, pages.RepoCommitParams{ 212 + LoggedInUser: user, 213 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 214 + RepoCommitResponse: result, 215 + EmailToDid: emailToDidMap, 216 + VerifiedCommit: vc, 217 + Pipeline: pipeline, 218 + DiffOpts: diffOpts, 219 + }) 220 + }
+9 -8
appview/repo/opengraph.go
··· 16 16 "tangled.org/core/appview/db" 17 17 "tangled.org/core/appview/models" 18 18 "tangled.org/core/appview/ogcard" 19 + "tangled.org/core/orm" 19 20 "tangled.org/core/types" 20 21 ) 21 22 ··· 158 159 // Draw star icon, count, and label 159 160 // Align icon baseline with text baseline 160 161 iconBaselineOffset := int(textSize) / 2 161 - err = statsArea.DrawSVGIcon("static/icons/star.svg", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) 162 + err = statsArea.DrawLucideIcon("star", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) 162 163 if err != nil { 163 164 log.Printf("failed to draw star icon: %v", err) 164 165 } ··· 185 186 186 187 // Draw issues icon, count, and label 187 188 issueStartX := currentX 188 - err = statsArea.DrawSVGIcon("static/icons/circle-dot.svg", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) 189 + err = statsArea.DrawLucideIcon("circle-dot", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) 189 190 if err != nil { 190 191 log.Printf("failed to draw circle-dot icon: %v", err) 191 192 } ··· 210 211 211 212 // Draw pull request icon, count, and label 212 213 prStartX := currentX 213 - err = statsArea.DrawSVGIcon("static/icons/git-pull-request.svg", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) 214 + err = statsArea.DrawLucideIcon("git-pull-request", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) 214 215 if err != nil { 215 216 log.Printf("failed to draw git-pull-request icon: %v", err) 216 217 } ··· 236 237 dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2) 237 238 dollyY := statsY + iconBaselineOffset - dollySize/2 + 25 238 239 dollyColor := color.RGBA{180, 180, 180, 255} // light gray 239 - err = dollyArea.DrawSVGIcon("templates/fragments/dolly/silhouette.svg", dollyX, dollyY, dollySize, dollyColor) 240 + err = dollyArea.DrawDolly(dollyX, dollyY, dollySize, dollyColor) 240 241 if err != nil { 241 242 log.Printf("dolly silhouette not available (this is ok): %v", err) 242 243 } ··· 327 328 return nil 328 329 } 329 330 330 - func (rp *Repo) RepoOpenGraphSummary(w http.ResponseWriter, r *http.Request) { 331 + func (rp *Repo) Opengraph(w http.ResponseWriter, r *http.Request) { 331 332 f, err := rp.repoResolver.Resolve(r) 332 333 if err != nil { 333 334 log.Println("failed to get repo and knot", err) ··· 338 339 var languageStats []types.RepoLanguageDetails 339 340 langs, err := db.GetRepoLanguages( 340 341 rp.db, 341 - db.FilterEq("repo_at", f.RepoAt()), 342 - db.FilterEq("is_default_ref", 1), 342 + orm.FilterEq("repo_at", f.RepoAt()), 343 + orm.FilterEq("is_default_ref", 1), 343 344 ) 344 345 if err != nil { 345 346 log.Printf("failed to get language stats from db: %v", err) ··· 374 375 }) 375 376 } 376 377 377 - card, err := rp.drawRepoSummaryCard(&f.Repo, languageStats) 378 + card, err := rp.drawRepoSummaryCard(f, languageStats) 378 379 if err != nil { 379 380 log.Println("failed to draw repo summary card", err) 380 381 http.Error(w, "failed to draw repo summary card", http.StatusInternalServerError)
+39 -1413
appview/repo/repo.go
··· 3 3 import ( 4 4 "context" 5 5 "database/sql" 6 - "encoding/json" 7 6 "errors" 8 7 "fmt" 9 - "io" 10 8 "log/slog" 11 9 "net/http" 12 10 "net/url" 13 - "path/filepath" 14 11 "slices" 15 - "strconv" 16 12 "strings" 17 13 "time" 18 14 19 15 "tangled.org/core/api/tangled" 20 - "tangled.org/core/appview/commitverify" 21 16 "tangled.org/core/appview/config" 22 17 "tangled.org/core/appview/db" 23 18 "tangled.org/core/appview/models" 24 19 "tangled.org/core/appview/notify" 25 20 "tangled.org/core/appview/oauth" 26 21 "tangled.org/core/appview/pages" 27 - "tangled.org/core/appview/pages/markup" 28 22 "tangled.org/core/appview/reporesolver" 29 23 "tangled.org/core/appview/validator" 30 24 xrpcclient "tangled.org/core/appview/xrpcclient" 31 25 "tangled.org/core/eventconsumer" 32 26 "tangled.org/core/idresolver" 33 - "tangled.org/core/patchutil" 27 + "tangled.org/core/orm" 34 28 "tangled.org/core/rbac" 35 29 "tangled.org/core/tid" 36 - "tangled.org/core/types" 37 30 "tangled.org/core/xrpc/serviceauth" 38 31 39 32 comatproto "github.com/bluesky-social/indigo/api/atproto" 40 33 atpclient "github.com/bluesky-social/indigo/atproto/client" 41 34 "github.com/bluesky-social/indigo/atproto/syntax" 42 35 lexutil "github.com/bluesky-social/indigo/lex/util" 43 - indigoxrpc "github.com/bluesky-social/indigo/xrpc" 44 36 securejoin "github.com/cyphar/filepath-securejoin" 45 37 "github.com/go-chi/chi/v5" 46 - "github.com/go-git/go-git/v5/plumbing" 47 38 ) 48 39 49 40 type Repo struct { ··· 88 79 } 89 80 } 90 81 91 - func (rp *Repo) DownloadArchive(w http.ResponseWriter, r *http.Request) { 92 - l := rp.logger.With("handler", "DownloadArchive") 93 - 94 - ref := chi.URLParam(r, "ref") 95 - ref, _ = url.PathUnescape(ref) 96 - 97 - f, err := rp.repoResolver.Resolve(r) 98 - if err != nil { 99 - l.Error("failed to get repo and knot", "err", err) 100 - return 101 - } 102 - 103 - scheme := "http" 104 - if !rp.config.Core.Dev { 105 - scheme = "https" 106 - } 107 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 108 - xrpcc := &indigoxrpc.Client{ 109 - Host: host, 110 - } 111 - 112 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 113 - archiveBytes, err := tangled.RepoArchive(r.Context(), xrpcc, "tar.gz", "", ref, repo) 114 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 115 - l.Error("failed to call XRPC repo.archive", "err", xrpcerr) 116 - rp.pages.Error503(w) 117 - return 118 - } 119 - 120 - // Set headers for file download, just pass along whatever the knot specifies 121 - safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(ref).Short(), "/", "-") 122 - filename := fmt.Sprintf("%s-%s.tar.gz", f.Name, safeRefFilename) 123 - w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) 124 - w.Header().Set("Content-Type", "application/gzip") 125 - w.Header().Set("Content-Length", fmt.Sprintf("%d", len(archiveBytes))) 126 - 127 - // Write the archive data directly 128 - w.Write(archiveBytes) 129 - } 130 - 131 - func (rp *Repo) RepoLog(w http.ResponseWriter, r *http.Request) { 132 - l := rp.logger.With("handler", "RepoLog") 133 - 134 - f, err := rp.repoResolver.Resolve(r) 135 - if err != nil { 136 - l.Error("failed to fully resolve repo", "err", err) 137 - return 138 - } 139 - 140 - page := 1 141 - if r.URL.Query().Get("page") != "" { 142 - page, err = strconv.Atoi(r.URL.Query().Get("page")) 143 - if err != nil { 144 - page = 1 145 - } 146 - } 147 - 148 - ref := chi.URLParam(r, "ref") 149 - ref, _ = url.PathUnescape(ref) 150 - 151 - scheme := "http" 152 - if !rp.config.Core.Dev { 153 - scheme = "https" 154 - } 155 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 156 - xrpcc := &indigoxrpc.Client{ 157 - Host: host, 158 - } 159 - 160 - limit := int64(60) 161 - cursor := "" 162 - if page > 1 { 163 - // Convert page number to cursor (offset) 164 - offset := (page - 1) * int(limit) 165 - cursor = strconv.Itoa(offset) 166 - } 167 - 168 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 169 - xrpcBytes, err := tangled.RepoLog(r.Context(), xrpcc, cursor, limit, "", ref, repo) 170 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 171 - l.Error("failed to call XRPC repo.log", "err", xrpcerr) 172 - rp.pages.Error503(w) 173 - return 174 - } 175 - 176 - var xrpcResp types.RepoLogResponse 177 - if err := json.Unmarshal(xrpcBytes, &xrpcResp); err != nil { 178 - l.Error("failed to decode XRPC response", "err", err) 179 - rp.pages.Error503(w) 180 - return 181 - } 182 - 183 - tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 184 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 185 - l.Error("failed to call XRPC repo.tags", "err", xrpcerr) 186 - rp.pages.Error503(w) 187 - return 188 - } 189 - 190 - tagMap := make(map[string][]string) 191 - if tagBytes != nil { 192 - var tagResp types.RepoTagsResponse 193 - if err := json.Unmarshal(tagBytes, &tagResp); err == nil { 194 - for _, tag := range tagResp.Tags { 195 - hash := tag.Hash 196 - if tag.Tag != nil { 197 - hash = tag.Tag.Target.String() 198 - } 199 - tagMap[hash] = append(tagMap[hash], tag.Name) 200 - } 201 - } 202 - } 203 - 204 - branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 205 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 206 - l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 207 - rp.pages.Error503(w) 208 - return 209 - } 210 - 211 - if branchBytes != nil { 212 - var branchResp types.RepoBranchesResponse 213 - if err := json.Unmarshal(branchBytes, &branchResp); err == nil { 214 - for _, branch := range branchResp.Branches { 215 - tagMap[branch.Hash] = append(tagMap[branch.Hash], branch.Name) 216 - } 217 - } 218 - } 219 - 220 - user := rp.oauth.GetUser(r) 221 - 222 - emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(xrpcResp.Commits), true) 223 - if err != nil { 224 - l.Error("failed to fetch email to did mapping", "err", err) 225 - } 226 - 227 - vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, xrpcResp.Commits) 228 - if err != nil { 229 - l.Error("failed to GetVerifiedObjectCommits", "err", err) 230 - } 231 - 232 - repoInfo := f.RepoInfo(user) 233 - 234 - var shas []string 235 - for _, c := range xrpcResp.Commits { 236 - shas = append(shas, c.Hash.String()) 237 - } 238 - pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas) 239 - if err != nil { 240 - l.Error("failed to getPipelineStatuses", "err", err) 241 - // non-fatal 242 - } 243 - 244 - rp.pages.RepoLog(w, pages.RepoLogParams{ 245 - LoggedInUser: user, 246 - TagMap: tagMap, 247 - RepoInfo: repoInfo, 248 - RepoLogResponse: xrpcResp, 249 - EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap), 250 - VerifiedCommits: vc, 251 - Pipelines: pipelines, 252 - }) 253 - } 254 - 255 - func (rp *Repo) RepoDescriptionEdit(w http.ResponseWriter, r *http.Request) { 256 - l := rp.logger.With("handler", "RepoDescriptionEdit") 257 - 258 - f, err := rp.repoResolver.Resolve(r) 259 - if err != nil { 260 - l.Error("failed to get repo and knot", "err", err) 261 - w.WriteHeader(http.StatusBadRequest) 262 - return 263 - } 264 - 265 - user := rp.oauth.GetUser(r) 266 - rp.pages.EditRepoDescriptionFragment(w, pages.RepoDescriptionParams{ 267 - RepoInfo: f.RepoInfo(user), 268 - }) 269 - } 270 - 271 - func (rp *Repo) RepoDescription(w http.ResponseWriter, r *http.Request) { 272 - l := rp.logger.With("handler", "RepoDescription") 273 - 274 - f, err := rp.repoResolver.Resolve(r) 275 - if err != nil { 276 - l.Error("failed to get repo and knot", "err", err) 277 - w.WriteHeader(http.StatusBadRequest) 278 - return 279 - } 280 - 281 - repoAt := f.RepoAt() 282 - rkey := repoAt.RecordKey().String() 283 - if rkey == "" { 284 - l.Error("invalid aturi for repo", "err", err) 285 - w.WriteHeader(http.StatusInternalServerError) 286 - return 287 - } 288 - 289 - user := rp.oauth.GetUser(r) 290 - 291 - switch r.Method { 292 - case http.MethodGet: 293 - rp.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{ 294 - RepoInfo: f.RepoInfo(user), 295 - }) 296 - return 297 - case http.MethodPut: 298 - newDescription := r.FormValue("description") 299 - client, err := rp.oauth.AuthorizedClient(r) 300 - if err != nil { 301 - l.Error("failed to get client") 302 - rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 303 - return 304 - } 305 - 306 - // optimistic update 307 - err = db.UpdateDescription(rp.db, string(repoAt), newDescription) 308 - if err != nil { 309 - l.Error("failed to perform update-description query", "err", err) 310 - rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 311 - return 312 - } 313 - 314 - newRepo := f.Repo 315 - newRepo.Description = newDescription 316 - record := newRepo.AsRecord() 317 - 318 - // this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field 319 - // 320 - // SwapRecord is optional and should happen automagically, but given that it does not, we have to perform two requests 321 - ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 322 - if err != nil { 323 - // failed to get record 324 - rp.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.") 325 - return 326 - } 327 - _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 328 - Collection: tangled.RepoNSID, 329 - Repo: newRepo.Did, 330 - Rkey: newRepo.Rkey, 331 - SwapRecord: ex.Cid, 332 - Record: &lexutil.LexiconTypeDecoder{ 333 - Val: &record, 334 - }, 335 - }) 336 - 337 - if err != nil { 338 - l.Error("failed to perferom update-description query", "err", err) 339 - // failed to get record 340 - rp.pages.Notice(w, "repo-notice", "Failed to update description, unable to save to PDS.") 341 - return 342 - } 343 - 344 - newRepoInfo := f.RepoInfo(user) 345 - newRepoInfo.Description = newDescription 346 - 347 - rp.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{ 348 - RepoInfo: newRepoInfo, 349 - }) 350 - return 351 - } 352 - } 353 - 354 - func (rp *Repo) RepoCommit(w http.ResponseWriter, r *http.Request) { 355 - l := rp.logger.With("handler", "RepoCommit") 356 - 357 - f, err := rp.repoResolver.Resolve(r) 358 - if err != nil { 359 - l.Error("failed to fully resolve repo", "err", err) 360 - return 361 - } 362 - ref := chi.URLParam(r, "ref") 363 - ref, _ = url.PathUnescape(ref) 364 - 365 - var diffOpts types.DiffOpts 366 - if d := r.URL.Query().Get("diff"); d == "split" { 367 - diffOpts.Split = true 368 - } 369 - 370 - if !plumbing.IsHash(ref) { 371 - rp.pages.Error404(w) 372 - return 373 - } 374 - 375 - scheme := "http" 376 - if !rp.config.Core.Dev { 377 - scheme = "https" 378 - } 379 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 380 - xrpcc := &indigoxrpc.Client{ 381 - Host: host, 382 - } 383 - 384 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 385 - xrpcBytes, err := tangled.RepoDiff(r.Context(), xrpcc, ref, repo) 386 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 387 - l.Error("failed to call XRPC repo.diff", "err", xrpcerr) 388 - rp.pages.Error503(w) 389 - return 390 - } 391 - 392 - var result types.RepoCommitResponse 393 - if err := json.Unmarshal(xrpcBytes, &result); err != nil { 394 - l.Error("failed to decode XRPC response", "err", err) 395 - rp.pages.Error503(w) 396 - return 397 - } 398 - 399 - emailToDidMap, err := db.GetEmailToDid(rp.db, []string{result.Diff.Commit.Committer.Email, result.Diff.Commit.Author.Email}, true) 400 - if err != nil { 401 - l.Error("failed to get email to did mapping", "err", err) 402 - } 403 - 404 - vc, err := commitverify.GetVerifiedCommits(rp.db, emailToDidMap, []types.NiceDiff{*result.Diff}) 405 - if err != nil { 406 - l.Error("failed to GetVerifiedCommits", "err", err) 407 - } 408 - 409 - user := rp.oauth.GetUser(r) 410 - repoInfo := f.RepoInfo(user) 411 - pipelines, err := getPipelineStatuses(rp.db, repoInfo, []string{result.Diff.Commit.This}) 412 - if err != nil { 413 - l.Error("failed to getPipelineStatuses", "err", err) 414 - // non-fatal 415 - } 416 - var pipeline *models.Pipeline 417 - if p, ok := pipelines[result.Diff.Commit.This]; ok { 418 - pipeline = &p 419 - } 420 - 421 - rp.pages.RepoCommit(w, pages.RepoCommitParams{ 422 - LoggedInUser: user, 423 - RepoInfo: f.RepoInfo(user), 424 - RepoCommitResponse: result, 425 - EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap), 426 - VerifiedCommit: vc, 427 - Pipeline: pipeline, 428 - DiffOpts: diffOpts, 429 - }) 430 - } 431 - 432 - func (rp *Repo) RepoTree(w http.ResponseWriter, r *http.Request) { 433 - l := rp.logger.With("handler", "RepoTree") 434 - 435 - f, err := rp.repoResolver.Resolve(r) 436 - if err != nil { 437 - l.Error("failed to fully resolve repo", "err", err) 438 - return 439 - } 440 - 441 - ref := chi.URLParam(r, "ref") 442 - ref, _ = url.PathUnescape(ref) 443 - 444 - // if the tree path has a trailing slash, let's strip it 445 - // so we don't 404 446 - treePath := chi.URLParam(r, "*") 447 - treePath, _ = url.PathUnescape(treePath) 448 - treePath = strings.TrimSuffix(treePath, "/") 449 - 450 - scheme := "http" 451 - if !rp.config.Core.Dev { 452 - scheme = "https" 453 - } 454 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 455 - xrpcc := &indigoxrpc.Client{ 456 - Host: host, 457 - } 458 - 459 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 460 - xrpcResp, err := tangled.RepoTree(r.Context(), xrpcc, treePath, ref, repo) 461 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 462 - l.Error("failed to call XRPC repo.tree", "err", xrpcerr) 463 - rp.pages.Error503(w) 464 - return 465 - } 466 - 467 - // Convert XRPC response to internal types.RepoTreeResponse 468 - files := make([]types.NiceTree, len(xrpcResp.Files)) 469 - for i, xrpcFile := range xrpcResp.Files { 470 - file := types.NiceTree{ 471 - Name: xrpcFile.Name, 472 - Mode: xrpcFile.Mode, 473 - Size: int64(xrpcFile.Size), 474 - IsFile: xrpcFile.Is_file, 475 - IsSubtree: xrpcFile.Is_subtree, 476 - } 477 - 478 - // Convert last commit info if present 479 - if xrpcFile.Last_commit != nil { 480 - commitWhen, _ := time.Parse(time.RFC3339, xrpcFile.Last_commit.When) 481 - file.LastCommit = &types.LastCommitInfo{ 482 - Hash: plumbing.NewHash(xrpcFile.Last_commit.Hash), 483 - Message: xrpcFile.Last_commit.Message, 484 - When: commitWhen, 485 - } 486 - } 487 - 488 - files[i] = file 489 - } 490 - 491 - result := types.RepoTreeResponse{ 492 - Ref: xrpcResp.Ref, 493 - Files: files, 494 - } 495 - 496 - if xrpcResp.Parent != nil { 497 - result.Parent = *xrpcResp.Parent 498 - } 499 - if xrpcResp.Dotdot != nil { 500 - result.DotDot = *xrpcResp.Dotdot 501 - } 502 - if xrpcResp.Readme != nil { 503 - result.ReadmeFileName = xrpcResp.Readme.Filename 504 - result.Readme = xrpcResp.Readme.Contents 505 - } 506 - 507 - // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated, 508 - // so we can safely redirect to the "parent" (which is the same file). 509 - if len(result.Files) == 0 && result.Parent == treePath { 510 - redirectTo := fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), url.PathEscape(ref), result.Parent) 511 - http.Redirect(w, r, redirectTo, http.StatusFound) 512 - return 513 - } 514 - 515 - user := rp.oauth.GetUser(r) 516 - 517 - var breadcrumbs [][]string 518 - breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))}) 519 - if treePath != "" { 520 - for idx, elem := range strings.Split(treePath, "/") { 521 - breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))}) 522 - } 523 - } 524 - 525 - sortFiles(result.Files) 526 - 527 - rp.pages.RepoTree(w, pages.RepoTreeParams{ 528 - LoggedInUser: user, 529 - BreadCrumbs: breadcrumbs, 530 - TreePath: treePath, 531 - RepoInfo: f.RepoInfo(user), 532 - RepoTreeResponse: result, 533 - }) 534 - } 535 - 536 - func (rp *Repo) RepoTags(w http.ResponseWriter, r *http.Request) { 537 - l := rp.logger.With("handler", "RepoTags") 538 - 539 - f, err := rp.repoResolver.Resolve(r) 540 - if err != nil { 541 - l.Error("failed to get repo and knot", "err", err) 542 - return 543 - } 544 - 545 - scheme := "http" 546 - if !rp.config.Core.Dev { 547 - scheme = "https" 548 - } 549 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 550 - xrpcc := &indigoxrpc.Client{ 551 - Host: host, 552 - } 553 - 554 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 555 - xrpcBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 556 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 557 - l.Error("failed to call XRPC repo.tags", "err", xrpcerr) 558 - rp.pages.Error503(w) 559 - return 560 - } 561 - 562 - var result types.RepoTagsResponse 563 - if err := json.Unmarshal(xrpcBytes, &result); err != nil { 564 - l.Error("failed to decode XRPC response", "err", err) 565 - rp.pages.Error503(w) 566 - return 567 - } 568 - 569 - artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt())) 570 - if err != nil { 571 - l.Error("failed grab artifacts", "err", err) 572 - return 573 - } 574 - 575 - // convert artifacts to map for easy UI building 576 - artifactMap := make(map[plumbing.Hash][]models.Artifact) 577 - for _, a := range artifacts { 578 - artifactMap[a.Tag] = append(artifactMap[a.Tag], a) 579 - } 580 - 581 - var danglingArtifacts []models.Artifact 582 - for _, a := range artifacts { 583 - found := false 584 - for _, t := range result.Tags { 585 - if t.Tag != nil { 586 - if t.Tag.Hash == a.Tag { 587 - found = true 588 - } 589 - } 590 - } 591 - 592 - if !found { 593 - danglingArtifacts = append(danglingArtifacts, a) 594 - } 595 - } 596 - 597 - user := rp.oauth.GetUser(r) 598 - rp.pages.RepoTags(w, pages.RepoTagsParams{ 599 - LoggedInUser: user, 600 - RepoInfo: f.RepoInfo(user), 601 - RepoTagsResponse: result, 602 - ArtifactMap: artifactMap, 603 - DanglingArtifacts: danglingArtifacts, 604 - }) 605 - } 606 - 607 - func (rp *Repo) RepoBranches(w http.ResponseWriter, r *http.Request) { 608 - l := rp.logger.With("handler", "RepoBranches") 609 - 610 - f, err := rp.repoResolver.Resolve(r) 611 - if err != nil { 612 - l.Error("failed to get repo and knot", "err", err) 613 - return 614 - } 615 - 616 - scheme := "http" 617 - if !rp.config.Core.Dev { 618 - scheme = "https" 619 - } 620 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 621 - xrpcc := &indigoxrpc.Client{ 622 - Host: host, 623 - } 624 - 625 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 626 - xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 627 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 628 - l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 629 - rp.pages.Error503(w) 630 - return 631 - } 632 - 633 - var result types.RepoBranchesResponse 634 - if err := json.Unmarshal(xrpcBytes, &result); err != nil { 635 - l.Error("failed to decode XRPC response", "err", err) 636 - rp.pages.Error503(w) 637 - return 638 - } 639 - 640 - sortBranches(result.Branches) 641 - 642 - user := rp.oauth.GetUser(r) 643 - rp.pages.RepoBranches(w, pages.RepoBranchesParams{ 644 - LoggedInUser: user, 645 - RepoInfo: f.RepoInfo(user), 646 - RepoBranchesResponse: result, 647 - }) 648 - } 649 - 650 - func (rp *Repo) DeleteBranch(w http.ResponseWriter, r *http.Request) { 651 - l := rp.logger.With("handler", "DeleteBranch") 652 - 653 - f, err := rp.repoResolver.Resolve(r) 654 - if err != nil { 655 - l.Error("failed to get repo and knot", "err", err) 656 - return 657 - } 658 - 659 - noticeId := "delete-branch-error" 660 - fail := func(msg string, err error) { 661 - l.Error(msg, "err", err) 662 - rp.pages.Notice(w, noticeId, msg) 663 - } 664 - 665 - branch := r.FormValue("branch") 666 - if branch == "" { 667 - fail("No branch provided.", nil) 668 - return 669 - } 670 - 671 - client, err := rp.oauth.ServiceClient( 672 - r, 673 - oauth.WithService(f.Knot), 674 - oauth.WithLxm(tangled.RepoDeleteBranchNSID), 675 - oauth.WithDev(rp.config.Core.Dev), 676 - ) 677 - if err != nil { 678 - fail("Failed to connect to knotserver", nil) 679 - return 680 - } 681 - 682 - err = tangled.RepoDeleteBranch( 683 - r.Context(), 684 - client, 685 - &tangled.RepoDeleteBranch_Input{ 686 - Branch: branch, 687 - Repo: f.RepoAt().String(), 688 - }, 689 - ) 690 - if err := xrpcclient.HandleXrpcErr(err); err != nil { 691 - fail(fmt.Sprintf("Failed to delete branch: %s", err), err) 692 - return 693 - } 694 - l.Error("deleted branch from knot", "branch", branch, "repo", f.RepoAt()) 695 - 696 - rp.pages.HxRefresh(w) 697 - } 698 - 699 - func (rp *Repo) RepoBlob(w http.ResponseWriter, r *http.Request) { 700 - l := rp.logger.With("handler", "RepoBlob") 701 - 702 - f, err := rp.repoResolver.Resolve(r) 703 - if err != nil { 704 - l.Error("failed to get repo and knot", "err", err) 705 - return 706 - } 707 - 708 - ref := chi.URLParam(r, "ref") 709 - ref, _ = url.PathUnescape(ref) 710 - 711 - filePath := chi.URLParam(r, "*") 712 - filePath, _ = url.PathUnescape(filePath) 713 - 714 - scheme := "http" 715 - if !rp.config.Core.Dev { 716 - scheme = "https" 717 - } 718 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 719 - xrpcc := &indigoxrpc.Client{ 720 - Host: host, 721 - } 722 - 723 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name) 724 - resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, repo) 725 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 726 - l.Error("failed to call XRPC repo.blob", "err", xrpcerr) 727 - rp.pages.Error503(w) 728 - return 729 - } 730 - 731 - // Use XRPC response directly instead of converting to internal types 732 - 733 - var breadcrumbs [][]string 734 - breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))}) 735 - if filePath != "" { 736 - for idx, elem := range strings.Split(filePath, "/") { 737 - breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))}) 738 - } 739 - } 740 - 741 - showRendered := false 742 - renderToggle := false 743 - 744 - if markup.GetFormat(resp.Path) == markup.FormatMarkdown { 745 - renderToggle = true 746 - showRendered = r.URL.Query().Get("code") != "true" 747 - } 748 - 749 - var unsupported bool 750 - var isImage bool 751 - var isVideo bool 752 - var contentSrc string 753 - 754 - if resp.IsBinary != nil && *resp.IsBinary { 755 - ext := strings.ToLower(filepath.Ext(resp.Path)) 756 - switch ext { 757 - case ".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp": 758 - isImage = true 759 - case ".mp4", ".webm", ".ogg", ".mov", ".avi": 760 - isVideo = true 761 - default: 762 - unsupported = true 763 - } 764 - 765 - // fetch the raw binary content using sh.tangled.repo.blob xrpc 766 - repoName := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 767 - 768 - baseURL := &url.URL{ 769 - Scheme: scheme, 770 - Host: f.Knot, 771 - Path: "/xrpc/sh.tangled.repo.blob", 772 - } 773 - query := baseURL.Query() 774 - query.Set("repo", repoName) 775 - query.Set("ref", ref) 776 - query.Set("path", filePath) 777 - query.Set("raw", "true") 778 - baseURL.RawQuery = query.Encode() 779 - blobURL := baseURL.String() 780 - 781 - contentSrc = blobURL 782 - if !rp.config.Core.Dev { 783 - contentSrc = markup.GenerateCamoURL(rp.config.Camo.Host, rp.config.Camo.SharedSecret, blobURL) 784 - } 785 - } 786 - 787 - lines := 0 788 - if resp.IsBinary == nil || !*resp.IsBinary { 789 - lines = strings.Count(resp.Content, "\n") + 1 790 - } 791 - 792 - var sizeHint uint64 793 - if resp.Size != nil { 794 - sizeHint = uint64(*resp.Size) 795 - } else { 796 - sizeHint = uint64(len(resp.Content)) 797 - } 798 - 799 - user := rp.oauth.GetUser(r) 800 - 801 - // Determine if content is binary (dereference pointer) 802 - isBinary := false 803 - if resp.IsBinary != nil { 804 - isBinary = *resp.IsBinary 805 - } 806 - 807 - rp.pages.RepoBlob(w, pages.RepoBlobParams{ 808 - LoggedInUser: user, 809 - RepoInfo: f.RepoInfo(user), 810 - BreadCrumbs: breadcrumbs, 811 - ShowRendered: showRendered, 812 - RenderToggle: renderToggle, 813 - Unsupported: unsupported, 814 - IsImage: isImage, 815 - IsVideo: isVideo, 816 - ContentSrc: contentSrc, 817 - RepoBlob_Output: resp, 818 - Contents: resp.Content, 819 - Lines: lines, 820 - SizeHint: sizeHint, 821 - IsBinary: isBinary, 822 - }) 823 - } 824 - 825 - func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) { 826 - l := rp.logger.With("handler", "RepoBlobRaw") 827 - 828 - f, err := rp.repoResolver.Resolve(r) 829 - if err != nil { 830 - l.Error("failed to get repo and knot", "err", err) 831 - w.WriteHeader(http.StatusBadRequest) 832 - return 833 - } 834 - 835 - ref := chi.URLParam(r, "ref") 836 - ref, _ = url.PathUnescape(ref) 837 - 838 - filePath := chi.URLParam(r, "*") 839 - filePath, _ = url.PathUnescape(filePath) 840 - 841 - scheme := "http" 842 - if !rp.config.Core.Dev { 843 - scheme = "https" 844 - } 845 - 846 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name) 847 - baseURL := &url.URL{ 848 - Scheme: scheme, 849 - Host: f.Knot, 850 - Path: "/xrpc/sh.tangled.repo.blob", 851 - } 852 - query := baseURL.Query() 853 - query.Set("repo", repo) 854 - query.Set("ref", ref) 855 - query.Set("path", filePath) 856 - query.Set("raw", "true") 857 - baseURL.RawQuery = query.Encode() 858 - blobURL := baseURL.String() 859 - 860 - req, err := http.NewRequest("GET", blobURL, nil) 861 - if err != nil { 862 - l.Error("failed to create request", "err", err) 863 - return 864 - } 865 - 866 - // forward the If-None-Match header 867 - if clientETag := r.Header.Get("If-None-Match"); clientETag != "" { 868 - req.Header.Set("If-None-Match", clientETag) 869 - } 870 - 871 - client := &http.Client{} 872 - resp, err := client.Do(req) 873 - if err != nil { 874 - l.Error("failed to reach knotserver", "err", err) 875 - rp.pages.Error503(w) 876 - return 877 - } 878 - defer resp.Body.Close() 879 - 880 - // forward 304 not modified 881 - if resp.StatusCode == http.StatusNotModified { 882 - w.WriteHeader(http.StatusNotModified) 883 - return 884 - } 885 - 886 - if resp.StatusCode != http.StatusOK { 887 - l.Error("knotserver returned non-OK status for raw blob", "url", blobURL, "statuscode", resp.StatusCode) 888 - w.WriteHeader(resp.StatusCode) 889 - _, _ = io.Copy(w, resp.Body) 890 - return 891 - } 892 - 893 - contentType := resp.Header.Get("Content-Type") 894 - body, err := io.ReadAll(resp.Body) 895 - if err != nil { 896 - l.Error("error reading response body from knotserver", "err", err) 897 - w.WriteHeader(http.StatusInternalServerError) 898 - return 899 - } 900 - 901 - if strings.HasPrefix(contentType, "text/") || isTextualMimeType(contentType) { 902 - // serve all textual content as text/plain 903 - w.Header().Set("Content-Type", "text/plain; charset=utf-8") 904 - w.Write(body) 905 - } else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") { 906 - // serve images and videos with their original content type 907 - w.Header().Set("Content-Type", contentType) 908 - w.Write(body) 909 - } else { 910 - w.WriteHeader(http.StatusUnsupportedMediaType) 911 - w.Write([]byte("unsupported content type")) 912 - return 913 - } 914 - } 915 - 916 - // isTextualMimeType returns true if the MIME type represents textual content 917 - // that should be served as text/plain 918 - func isTextualMimeType(mimeType string) bool { 919 - textualTypes := []string{ 920 - "application/json", 921 - "application/xml", 922 - "application/yaml", 923 - "application/x-yaml", 924 - "application/toml", 925 - "application/javascript", 926 - "application/ecmascript", 927 - "message/", 928 - } 929 - 930 - return slices.Contains(textualTypes, mimeType) 931 - } 932 - 933 82 // modify the spindle configured for this repo 934 83 func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) { 935 84 user := rp.oauth.GetUser(r) ··· 970 119 } 971 120 } 972 121 973 - newRepo := f.Repo 122 + newRepo := *f 974 123 newRepo.Spindle = newSpindle 975 124 record := newRepo.AsRecord() 976 125 ··· 1109 258 l.Info("wrote label record to PDS") 1110 259 1111 260 // update the repo to subscribe to this label 1112 - newRepo := f.Repo 261 + newRepo := *f 1113 262 newRepo.Labels = append(newRepo.Labels, aturi) 1114 263 repoRecord := newRepo.AsRecord() 1115 264 ··· 1197 346 // get form values 1198 347 labelId := r.FormValue("label-id") 1199 348 1200 - label, err := db.GetLabelDefinition(rp.db, db.FilterEq("id", labelId)) 349 + label, err := db.GetLabelDefinition(rp.db, orm.FilterEq("id", labelId)) 1201 350 if err != nil { 1202 351 fail("Failed to find label definition.", err) 1203 352 return ··· 1221 370 } 1222 371 1223 372 // update repo record to remove the label reference 1224 - newRepo := f.Repo 373 + newRepo := *f 1225 374 var updated []string 1226 375 removedAt := label.AtUri().String() 1227 376 for _, l := range newRepo.Labels { ··· 1261 410 1262 411 err = db.UnsubscribeLabel( 1263 412 tx, 1264 - db.FilterEq("repo_at", f.RepoAt()), 1265 - db.FilterEq("label_at", removedAt), 413 + orm.FilterEq("repo_at", f.RepoAt()), 414 + orm.FilterEq("label_at", removedAt), 1266 415 ) 1267 416 if err != nil { 1268 417 fail("Failed to unsubscribe label.", err) 1269 418 return 1270 419 } 1271 420 1272 - err = db.DeleteLabelDefinition(tx, db.FilterEq("id", label.Id)) 421 + err = db.DeleteLabelDefinition(tx, orm.FilterEq("id", label.Id)) 1273 422 if err != nil { 1274 423 fail("Failed to delete label definition.", err) 1275 424 return ··· 1308 457 } 1309 458 1310 459 labelAts := r.Form["label"] 1311 - _, err = db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", labelAts)) 460 + _, err = db.GetLabelDefinitions(rp.db, orm.FilterIn("at_uri", labelAts)) 1312 461 if err != nil { 1313 462 fail("Failed to subscribe to label.", err) 1314 463 return 1315 464 } 1316 465 1317 - newRepo := f.Repo 466 + newRepo := *f 1318 467 newRepo.Labels = append(newRepo.Labels, labelAts...) 1319 468 1320 469 // dedup ··· 1329 478 return 1330 479 } 1331 480 1332 - ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey) 481 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Did, f.Rkey) 1333 482 if err != nil { 1334 483 fail("Failed to update labels, no record found on PDS.", err) 1335 484 return ··· 1394 543 } 1395 544 1396 545 labelAts := r.Form["label"] 1397 - _, err = db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", labelAts)) 546 + _, err = db.GetLabelDefinitions(rp.db, orm.FilterIn("at_uri", labelAts)) 1398 547 if err != nil { 1399 548 fail("Failed to unsubscribe to label.", err) 1400 549 return 1401 550 } 1402 551 1403 552 // update repo record to remove the label reference 1404 - newRepo := f.Repo 553 + newRepo := *f 1405 554 var updated []string 1406 555 for _, l := range newRepo.Labels { 1407 556 if !slices.Contains(labelAts, l) { ··· 1417 566 return 1418 567 } 1419 568 1420 - ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey) 569 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Did, f.Rkey) 1421 570 if err != nil { 1422 571 fail("Failed to update labels, no record found on PDS.", err) 1423 572 return ··· 1434 583 1435 584 err = db.UnsubscribeLabel( 1436 585 rp.db, 1437 - db.FilterEq("repo_at", f.RepoAt()), 1438 - db.FilterIn("label_at", labelAts), 586 + orm.FilterEq("repo_at", f.RepoAt()), 587 + orm.FilterIn("label_at", labelAts), 1439 588 ) 1440 589 if err != nil { 1441 590 fail("Failed to unsubscribe label.", err) ··· 1464 613 1465 614 labelDefs, err := db.GetLabelDefinitions( 1466 615 rp.db, 1467 - db.FilterIn("at_uri", f.Repo.Labels), 1468 - db.FilterContains("scope", subject.Collection().String()), 616 + orm.FilterIn("at_uri", f.Labels), 617 + orm.FilterContains("scope", subject.Collection().String()), 1469 618 ) 1470 619 if err != nil { 1471 620 l.Error("failed to fetch label defs", "err", err) ··· 1477 626 defs[l.AtUri().String()] = &l 1478 627 } 1479 628 1480 - states, err := db.GetLabels(rp.db, db.FilterEq("subject", subject)) 629 + states, err := db.GetLabels(rp.db, orm.FilterEq("subject", subject)) 1481 630 if err != nil { 1482 631 l.Error("failed to build label state", "err", err) 1483 632 return ··· 1487 636 user := rp.oauth.GetUser(r) 1488 637 rp.pages.LabelPanel(w, pages.LabelPanelParams{ 1489 638 LoggedInUser: user, 1490 - RepoInfo: f.RepoInfo(user), 639 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 1491 640 Defs: defs, 1492 641 Subject: subject.String(), 1493 642 State: state, ··· 1512 661 1513 662 labelDefs, err := db.GetLabelDefinitions( 1514 663 rp.db, 1515 - db.FilterIn("at_uri", f.Repo.Labels), 1516 - db.FilterContains("scope", subject.Collection().String()), 664 + orm.FilterIn("at_uri", f.Labels), 665 + orm.FilterContains("scope", subject.Collection().String()), 1517 666 ) 1518 667 if err != nil { 1519 668 l.Error("failed to fetch labels", "err", err) ··· 1525 674 defs[l.AtUri().String()] = &l 1526 675 } 1527 676 1528 - states, err := db.GetLabels(rp.db, db.FilterEq("subject", subject)) 677 + states, err := db.GetLabels(rp.db, orm.FilterEq("subject", subject)) 1529 678 if err != nil { 1530 679 l.Error("failed to build label state", "err", err) 1531 680 return ··· 1535 684 user := rp.oauth.GetUser(r) 1536 685 rp.pages.EditLabelPanel(w, pages.EditLabelPanelParams{ 1537 686 LoggedInUser: user, 1538 - RepoInfo: f.RepoInfo(user), 687 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 1539 688 Defs: defs, 1540 689 Subject: subject.String(), 1541 690 State: state, ··· 1716 865 r.Context(), 1717 866 client, 1718 867 &tangled.RepoDelete_Input{ 1719 - Did: f.OwnerDid(), 868 + Did: f.Did, 1720 869 Name: f.Name, 1721 870 Rkey: f.Rkey, 1722 871 }, ··· 1754 903 l.Info("removed collaborators") 1755 904 1756 905 // remove repo RBAC 1757 - err = rp.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo()) 906 + err = rp.enforcer.RemoveRepo(f.Did, f.Knot, f.DidSlashRepo()) 1758 907 if err != nil { 1759 908 rp.pages.Notice(w, noticeId, "Failed to update RBAC rules") 1760 909 return 1761 910 } 1762 911 1763 912 // remove repo from db 1764 - err = db.RemoveRepo(tx, f.OwnerDid(), f.Name) 913 + err = db.RemoveRepo(tx, f.Did, f.Name) 1765 914 if err != nil { 1766 915 rp.pages.Notice(w, noticeId, "Failed to update appview") 1767 916 return ··· 1782 931 return 1783 932 } 1784 933 1785 - rp.pages.HxRedirect(w, fmt.Sprintf("/%s", f.OwnerDid())) 1786 - } 1787 - 1788 - func (rp *Repo) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { 1789 - l := rp.logger.With("handler", "SetDefaultBranch") 1790 - 1791 - f, err := rp.repoResolver.Resolve(r) 1792 - if err != nil { 1793 - l.Error("failed to get repo and knot", "err", err) 1794 - return 1795 - } 1796 - 1797 - noticeId := "operation-error" 1798 - branch := r.FormValue("branch") 1799 - if branch == "" { 1800 - http.Error(w, "malformed form", http.StatusBadRequest) 1801 - return 1802 - } 1803 - 1804 - client, err := rp.oauth.ServiceClient( 1805 - r, 1806 - oauth.WithService(f.Knot), 1807 - oauth.WithLxm(tangled.RepoSetDefaultBranchNSID), 1808 - oauth.WithDev(rp.config.Core.Dev), 1809 - ) 1810 - if err != nil { 1811 - l.Error("failed to connect to knot server", "err", err) 1812 - rp.pages.Notice(w, noticeId, "Failed to connect to knot server.") 1813 - return 1814 - } 1815 - 1816 - xe := tangled.RepoSetDefaultBranch( 1817 - r.Context(), 1818 - client, 1819 - &tangled.RepoSetDefaultBranch_Input{ 1820 - Repo: f.RepoAt().String(), 1821 - DefaultBranch: branch, 1822 - }, 1823 - ) 1824 - if err := xrpcclient.HandleXrpcErr(xe); err != nil { 1825 - l.Error("xrpc failed", "err", xe) 1826 - rp.pages.Notice(w, noticeId, err.Error()) 1827 - return 1828 - } 1829 - 1830 - rp.pages.HxRefresh(w) 1831 - } 1832 - 1833 - func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) { 1834 - user := rp.oauth.GetUser(r) 1835 - l := rp.logger.With("handler", "Secrets") 1836 - l = l.With("did", user.Did) 1837 - 1838 - f, err := rp.repoResolver.Resolve(r) 1839 - if err != nil { 1840 - l.Error("failed to get repo and knot", "err", err) 1841 - return 1842 - } 1843 - 1844 - if f.Spindle == "" { 1845 - l.Error("empty spindle cannot add/rm secret", "err", err) 1846 - return 1847 - } 1848 - 1849 - lxm := tangled.RepoAddSecretNSID 1850 - if r.Method == http.MethodDelete { 1851 - lxm = tangled.RepoRemoveSecretNSID 1852 - } 1853 - 1854 - spindleClient, err := rp.oauth.ServiceClient( 1855 - r, 1856 - oauth.WithService(f.Spindle), 1857 - oauth.WithLxm(lxm), 1858 - oauth.WithExp(60), 1859 - oauth.WithDev(rp.config.Core.Dev), 1860 - ) 1861 - if err != nil { 1862 - l.Error("failed to create spindle client", "err", err) 1863 - return 1864 - } 1865 - 1866 - key := r.FormValue("key") 1867 - if key == "" { 1868 - w.WriteHeader(http.StatusBadRequest) 1869 - return 1870 - } 1871 - 1872 - switch r.Method { 1873 - case http.MethodPut: 1874 - errorId := "add-secret-error" 1875 - 1876 - value := r.FormValue("value") 1877 - if value == "" { 1878 - w.WriteHeader(http.StatusBadRequest) 1879 - return 1880 - } 1881 - 1882 - err = tangled.RepoAddSecret( 1883 - r.Context(), 1884 - spindleClient, 1885 - &tangled.RepoAddSecret_Input{ 1886 - Repo: f.RepoAt().String(), 1887 - Key: key, 1888 - Value: value, 1889 - }, 1890 - ) 1891 - if err != nil { 1892 - l.Error("Failed to add secret.", "err", err) 1893 - rp.pages.Notice(w, errorId, "Failed to add secret.") 1894 - return 1895 - } 1896 - 1897 - case http.MethodDelete: 1898 - errorId := "operation-error" 1899 - 1900 - err = tangled.RepoRemoveSecret( 1901 - r.Context(), 1902 - spindleClient, 1903 - &tangled.RepoRemoveSecret_Input{ 1904 - Repo: f.RepoAt().String(), 1905 - Key: key, 1906 - }, 1907 - ) 1908 - if err != nil { 1909 - l.Error("Failed to delete secret.", "err", err) 1910 - rp.pages.Notice(w, errorId, "Failed to delete secret.") 1911 - return 1912 - } 1913 - } 1914 - 1915 - rp.pages.HxRefresh(w) 1916 - } 1917 - 1918 - type tab = map[string]any 1919 - 1920 - var ( 1921 - // would be great to have ordered maps right about now 1922 - settingsTabs []tab = []tab{ 1923 - {"Name": "general", "Icon": "sliders-horizontal"}, 1924 - {"Name": "access", "Icon": "users"}, 1925 - {"Name": "pipelines", "Icon": "layers-2"}, 1926 - } 1927 - ) 1928 - 1929 - func (rp *Repo) RepoSettings(w http.ResponseWriter, r *http.Request) { 1930 - tabVal := r.URL.Query().Get("tab") 1931 - if tabVal == "" { 1932 - tabVal = "general" 1933 - } 1934 - 1935 - switch tabVal { 1936 - case "general": 1937 - rp.generalSettings(w, r) 1938 - 1939 - case "access": 1940 - rp.accessSettings(w, r) 1941 - 1942 - case "pipelines": 1943 - rp.pipelineSettings(w, r) 1944 - } 1945 - } 1946 - 1947 - func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) { 1948 - l := rp.logger.With("handler", "generalSettings") 1949 - 1950 - f, err := rp.repoResolver.Resolve(r) 1951 - user := rp.oauth.GetUser(r) 1952 - 1953 - scheme := "http" 1954 - if !rp.config.Core.Dev { 1955 - scheme = "https" 1956 - } 1957 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 1958 - xrpcc := &indigoxrpc.Client{ 1959 - Host: host, 1960 - } 1961 - 1962 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1963 - xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 1964 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1965 - l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 1966 - rp.pages.Error503(w) 1967 - return 1968 - } 1969 - 1970 - var result types.RepoBranchesResponse 1971 - if err := json.Unmarshal(xrpcBytes, &result); err != nil { 1972 - l.Error("failed to decode XRPC response", "err", err) 1973 - rp.pages.Error503(w) 1974 - return 1975 - } 1976 - 1977 - defaultLabels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", models.DefaultLabelDefs())) 1978 - if err != nil { 1979 - l.Error("failed to fetch labels", "err", err) 1980 - rp.pages.Error503(w) 1981 - return 1982 - } 1983 - 1984 - labels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Repo.Labels)) 1985 - if err != nil { 1986 - l.Error("failed to fetch labels", "err", err) 1987 - rp.pages.Error503(w) 1988 - return 1989 - } 1990 - // remove default labels from the labels list, if present 1991 - defaultLabelMap := make(map[string]bool) 1992 - for _, dl := range defaultLabels { 1993 - defaultLabelMap[dl.AtUri().String()] = true 1994 - } 1995 - n := 0 1996 - for _, l := range labels { 1997 - if !defaultLabelMap[l.AtUri().String()] { 1998 - labels[n] = l 1999 - n++ 2000 - } 2001 - } 2002 - labels = labels[:n] 2003 - 2004 - subscribedLabels := make(map[string]struct{}) 2005 - for _, l := range f.Repo.Labels { 2006 - subscribedLabels[l] = struct{}{} 2007 - } 2008 - 2009 - // if there is atleast 1 unsubbed default label, show the "subscribe all" button, 2010 - // if all default labels are subbed, show the "unsubscribe all" button 2011 - shouldSubscribeAll := false 2012 - for _, dl := range defaultLabels { 2013 - if _, ok := subscribedLabels[dl.AtUri().String()]; !ok { 2014 - // one of the default labels is not subscribed to 2015 - shouldSubscribeAll = true 2016 - break 2017 - } 2018 - } 2019 - 2020 - rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{ 2021 - LoggedInUser: user, 2022 - RepoInfo: f.RepoInfo(user), 2023 - Branches: result.Branches, 2024 - Labels: labels, 2025 - DefaultLabels: defaultLabels, 2026 - SubscribedLabels: subscribedLabels, 2027 - ShouldSubscribeAll: shouldSubscribeAll, 2028 - Tabs: settingsTabs, 2029 - Tab: "general", 2030 - }) 2031 - } 2032 - 2033 - func (rp *Repo) accessSettings(w http.ResponseWriter, r *http.Request) { 2034 - l := rp.logger.With("handler", "accessSettings") 2035 - 2036 - f, err := rp.repoResolver.Resolve(r) 2037 - user := rp.oauth.GetUser(r) 2038 - 2039 - repoCollaborators, err := f.Collaborators(r.Context()) 2040 - if err != nil { 2041 - l.Error("failed to get collaborators", "err", err) 2042 - } 2043 - 2044 - rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{ 2045 - LoggedInUser: user, 2046 - RepoInfo: f.RepoInfo(user), 2047 - Tabs: settingsTabs, 2048 - Tab: "access", 2049 - Collaborators: repoCollaborators, 2050 - }) 2051 - } 2052 - 2053 - func (rp *Repo) pipelineSettings(w http.ResponseWriter, r *http.Request) { 2054 - l := rp.logger.With("handler", "pipelineSettings") 2055 - 2056 - f, err := rp.repoResolver.Resolve(r) 2057 - user := rp.oauth.GetUser(r) 2058 - 2059 - // all spindles that the repo owner is a member of 2060 - spindles, err := rp.enforcer.GetSpindlesForUser(f.OwnerDid()) 2061 - if err != nil { 2062 - l.Error("failed to fetch spindles", "err", err) 2063 - return 2064 - } 2065 - 2066 - var secrets []*tangled.RepoListSecrets_Secret 2067 - if f.Spindle != "" { 2068 - if spindleClient, err := rp.oauth.ServiceClient( 2069 - r, 2070 - oauth.WithService(f.Spindle), 2071 - oauth.WithLxm(tangled.RepoListSecretsNSID), 2072 - oauth.WithExp(60), 2073 - oauth.WithDev(rp.config.Core.Dev), 2074 - ); err != nil { 2075 - l.Error("failed to create spindle client", "err", err) 2076 - } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil { 2077 - l.Error("failed to fetch secrets", "err", err) 2078 - } else { 2079 - secrets = resp.Secrets 2080 - } 2081 - } 2082 - 2083 - slices.SortFunc(secrets, func(a, b *tangled.RepoListSecrets_Secret) int { 2084 - return strings.Compare(a.Key, b.Key) 2085 - }) 2086 - 2087 - var dids []string 2088 - for _, s := range secrets { 2089 - dids = append(dids, s.CreatedBy) 2090 - } 2091 - resolvedIdents := rp.idResolver.ResolveIdents(r.Context(), dids) 2092 - 2093 - // convert to a more manageable form 2094 - var niceSecret []map[string]any 2095 - for id, s := range secrets { 2096 - when, _ := time.Parse(time.RFC3339, s.CreatedAt) 2097 - niceSecret = append(niceSecret, map[string]any{ 2098 - "Id": id, 2099 - "Key": s.Key, 2100 - "CreatedAt": when, 2101 - "CreatedBy": resolvedIdents[id].Handle.String(), 2102 - }) 2103 - } 2104 - 2105 - rp.pages.RepoPipelineSettings(w, pages.RepoPipelineSettingsParams{ 2106 - LoggedInUser: user, 2107 - RepoInfo: f.RepoInfo(user), 2108 - Tabs: settingsTabs, 2109 - Tab: "pipelines", 2110 - Spindles: spindles, 2111 - CurrentSpindle: f.Spindle, 2112 - Secrets: niceSecret, 2113 - }) 934 + rp.pages.HxRedirect(w, fmt.Sprintf("/%s", f.Did)) 2114 935 } 2115 936 2116 937 func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) { ··· 2139 960 return 2140 961 } 2141 962 2142 - repoInfo := f.RepoInfo(user) 2143 - if repoInfo.Source == nil { 963 + if f.Source == "" { 2144 964 rp.pages.Notice(w, "repo", "This repository is not a fork.") 2145 965 return 2146 966 } ··· 2151 971 &tangled.RepoForkSync_Input{ 2152 972 Did: user.Did, 2153 973 Name: f.Name, 2154 - Source: repoInfo.Source.RepoAt().String(), 974 + Source: f.Source, 2155 975 Branch: ref, 2156 976 }, 2157 977 ) ··· 2187 1007 rp.pages.ForkRepo(w, pages.ForkRepoParams{ 2188 1008 LoggedInUser: user, 2189 1009 Knots: knots, 2190 - RepoInfo: f.RepoInfo(user), 1010 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 2191 1011 }) 2192 1012 2193 1013 case http.MethodPost: ··· 2217 1037 // in the user's account. 2218 1038 existingRepo, err := db.GetRepo( 2219 1039 rp.db, 2220 - db.FilterEq("did", user.Did), 2221 - db.FilterEq("name", forkName), 1040 + orm.FilterEq("did", user.Did), 1041 + orm.FilterEq("name", forkName), 2222 1042 ) 2223 1043 if err != nil { 2224 1044 if !errors.Is(err, sql.ErrNoRows) { ··· 2238 1058 uri = "http" 2239 1059 } 2240 1060 2241 - forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.Repo.Name) 1061 + forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.Did, f.Name) 2242 1062 l = l.With("cloneUrl", forkSourceUrl) 2243 1063 2244 1064 sourceAt := f.RepoAt().String() ··· 2251 1071 Knot: targetKnot, 2252 1072 Rkey: rkey, 2253 1073 Source: sourceAt, 2254 - Description: f.Repo.Description, 1074 + Description: f.Description, 2255 1075 Created: time.Now(), 2256 - Labels: models.DefaultLabelDefs(), 1076 + Labels: rp.config.Label.DefaultLabelDefs, 2257 1077 } 2258 1078 record := repo.AsRecord() 2259 1079 ··· 2310 1130 } 2311 1131 defer rollback() 2312 1132 1133 + // TODO: this could coordinate better with the knot to recieve a clone status 2313 1134 client, err := rp.oauth.ServiceClient( 2314 1135 r, 2315 1136 oauth.WithService(targetKnot), 2316 1137 oauth.WithLxm(tangled.RepoCreateNSID), 2317 1138 oauth.WithDev(rp.config.Core.Dev), 1139 + oauth.WithTimeout(time.Second*20), // big repos take time to clone 2318 1140 ) 2319 1141 if err != nil { 2320 1142 l.Error("could not create service client", "err", err) ··· 2369 1191 aturi = "" 2370 1192 2371 1193 rp.notifier.NewRepo(r.Context(), repo) 2372 - rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Did, forkName)) 1194 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/%s", user.Did, forkName)) 2373 1195 } 2374 1196 } 2375 1197 ··· 2394 1216 }) 2395 1217 return err 2396 1218 } 2397 - 2398 - func (rp *Repo) RepoCompareNew(w http.ResponseWriter, r *http.Request) { 2399 - l := rp.logger.With("handler", "RepoCompareNew") 2400 - 2401 - user := rp.oauth.GetUser(r) 2402 - f, err := rp.repoResolver.Resolve(r) 2403 - if err != nil { 2404 - l.Error("failed to get repo and knot", "err", err) 2405 - return 2406 - } 2407 - 2408 - scheme := "http" 2409 - if !rp.config.Core.Dev { 2410 - scheme = "https" 2411 - } 2412 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 2413 - xrpcc := &indigoxrpc.Client{ 2414 - Host: host, 2415 - } 2416 - 2417 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 2418 - branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 2419 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2420 - l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 2421 - rp.pages.Error503(w) 2422 - return 2423 - } 2424 - 2425 - var branchResult types.RepoBranchesResponse 2426 - if err := json.Unmarshal(branchBytes, &branchResult); err != nil { 2427 - l.Error("failed to decode XRPC branches response", "err", err) 2428 - rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2429 - return 2430 - } 2431 - branches := branchResult.Branches 2432 - 2433 - sortBranches(branches) 2434 - 2435 - var defaultBranch string 2436 - for _, b := range branches { 2437 - if b.IsDefault { 2438 - defaultBranch = b.Name 2439 - } 2440 - } 2441 - 2442 - base := defaultBranch 2443 - head := defaultBranch 2444 - 2445 - params := r.URL.Query() 2446 - queryBase := params.Get("base") 2447 - queryHead := params.Get("head") 2448 - if queryBase != "" { 2449 - base = queryBase 2450 - } 2451 - if queryHead != "" { 2452 - head = queryHead 2453 - } 2454 - 2455 - tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 2456 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2457 - l.Error("failed to call XRPC repo.tags", "err", xrpcerr) 2458 - rp.pages.Error503(w) 2459 - return 2460 - } 2461 - 2462 - var tags types.RepoTagsResponse 2463 - if err := json.Unmarshal(tagBytes, &tags); err != nil { 2464 - l.Error("failed to decode XRPC tags response", "err", err) 2465 - rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2466 - return 2467 - } 2468 - 2469 - repoinfo := f.RepoInfo(user) 2470 - 2471 - rp.pages.RepoCompareNew(w, pages.RepoCompareNewParams{ 2472 - LoggedInUser: user, 2473 - RepoInfo: repoinfo, 2474 - Branches: branches, 2475 - Tags: tags.Tags, 2476 - Base: base, 2477 - Head: head, 2478 - }) 2479 - } 2480 - 2481 - func (rp *Repo) RepoCompare(w http.ResponseWriter, r *http.Request) { 2482 - l := rp.logger.With("handler", "RepoCompare") 2483 - 2484 - user := rp.oauth.GetUser(r) 2485 - f, err := rp.repoResolver.Resolve(r) 2486 - if err != nil { 2487 - l.Error("failed to get repo and knot", "err", err) 2488 - return 2489 - } 2490 - 2491 - var diffOpts types.DiffOpts 2492 - if d := r.URL.Query().Get("diff"); d == "split" { 2493 - diffOpts.Split = true 2494 - } 2495 - 2496 - // if user is navigating to one of 2497 - // /compare/{base}/{head} 2498 - // /compare/{base}...{head} 2499 - base := chi.URLParam(r, "base") 2500 - head := chi.URLParam(r, "head") 2501 - if base == "" && head == "" { 2502 - rest := chi.URLParam(r, "*") // master...feature/xyz 2503 - parts := strings.SplitN(rest, "...", 2) 2504 - if len(parts) == 2 { 2505 - base = parts[0] 2506 - head = parts[1] 2507 - } 2508 - } 2509 - 2510 - base, _ = url.PathUnescape(base) 2511 - head, _ = url.PathUnescape(head) 2512 - 2513 - if base == "" || head == "" { 2514 - l.Error("invalid comparison") 2515 - rp.pages.Error404(w) 2516 - return 2517 - } 2518 - 2519 - scheme := "http" 2520 - if !rp.config.Core.Dev { 2521 - scheme = "https" 2522 - } 2523 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 2524 - xrpcc := &indigoxrpc.Client{ 2525 - Host: host, 2526 - } 2527 - 2528 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 2529 - 2530 - branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 2531 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2532 - l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 2533 - rp.pages.Error503(w) 2534 - return 2535 - } 2536 - 2537 - var branches types.RepoBranchesResponse 2538 - if err := json.Unmarshal(branchBytes, &branches); err != nil { 2539 - l.Error("failed to decode XRPC branches response", "err", err) 2540 - rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2541 - return 2542 - } 2543 - 2544 - tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 2545 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2546 - l.Error("failed to call XRPC repo.tags", "err", xrpcerr) 2547 - rp.pages.Error503(w) 2548 - return 2549 - } 2550 - 2551 - var tags types.RepoTagsResponse 2552 - if err := json.Unmarshal(tagBytes, &tags); err != nil { 2553 - l.Error("failed to decode XRPC tags response", "err", err) 2554 - rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2555 - return 2556 - } 2557 - 2558 - compareBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, base, head) 2559 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2560 - l.Error("failed to call XRPC repo.compare", "err", xrpcerr) 2561 - rp.pages.Error503(w) 2562 - return 2563 - } 2564 - 2565 - var formatPatch types.RepoFormatPatchResponse 2566 - if err := json.Unmarshal(compareBytes, &formatPatch); err != nil { 2567 - l.Error("failed to decode XRPC compare response", "err", err) 2568 - rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2569 - return 2570 - } 2571 - 2572 - var diff types.NiceDiff 2573 - if formatPatch.CombinedPatchRaw != "" { 2574 - diff = patchutil.AsNiceDiff(formatPatch.CombinedPatchRaw, base) 2575 - } else { 2576 - diff = patchutil.AsNiceDiff(formatPatch.FormatPatchRaw, base) 2577 - } 2578 - 2579 - repoinfo := f.RepoInfo(user) 2580 - 2581 - rp.pages.RepoCompare(w, pages.RepoCompareParams{ 2582 - LoggedInUser: user, 2583 - RepoInfo: repoinfo, 2584 - Branches: branches.Branches, 2585 - Tags: tags.Tags, 2586 - Base: base, 2587 - Head: head, 2588 - Diff: &diff, 2589 - DiffOpts: diffOpts, 2590 - }) 2591 - 2592 - }
+20 -70
appview/repo/repo_util.go
··· 1 1 package repo 2 2 3 3 import ( 4 - "context" 5 - "crypto/rand" 6 - "fmt" 7 - "math/big" 4 + "maps" 8 5 "slices" 9 6 "sort" 10 7 "strings" 11 8 12 9 "tangled.org/core/appview/db" 13 10 "tangled.org/core/appview/models" 14 - "tangled.org/core/appview/pages/repoinfo" 11 + "tangled.org/core/orm" 15 12 "tangled.org/core/types" 16 - 17 - "github.com/go-git/go-git/v5/plumbing/object" 18 13 ) 19 14 20 15 func sortFiles(files []types.NiceTree) { 21 16 sort.Slice(files, func(i, j int) bool { 22 - iIsFile := files[i].IsFile 23 - jIsFile := files[j].IsFile 17 + iIsFile := files[i].IsFile() 18 + jIsFile := files[j].IsFile() 24 19 if iIsFile != jIsFile { 25 20 return !iIsFile 26 21 } ··· 47 42 }) 48 43 } 49 44 50 - func uniqueEmails(commits []*object.Commit) []string { 45 + func uniqueEmails(commits []types.Commit) []string { 51 46 emails := make(map[string]struct{}) 52 47 for _, commit := range commits { 53 - if commit.Author.Email != "" { 54 - emails[commit.Author.Email] = struct{}{} 55 - } 56 - if commit.Committer.Email != "" { 57 - emails[commit.Committer.Email] = struct{}{} 48 + emails[commit.Author.Email] = struct{}{} 49 + emails[commit.Committer.Email] = struct{}{} 50 + for _, c := range commit.CoAuthors() { 51 + emails[c.Email] = struct{}{} 58 52 } 59 53 } 60 - var uniqueEmails []string 61 - for email := range emails { 62 - uniqueEmails = append(uniqueEmails, email) 63 - } 64 - return uniqueEmails 54 + 55 + // delete empty emails if any, from the set 56 + delete(emails, "") 57 + 58 + return slices.Collect(maps.Keys(emails)) 65 59 } 66 60 67 61 func balanceIndexItems(commitCount, branchCount, tagCount, fileCount int) (commitsTrunc int, branchesTrunc int, tagsTrunc int) { ··· 92 86 return 93 87 } 94 88 95 - // emailToDidOrHandle takes an emailToDidMap from db.GetEmailToDid 96 - // and resolves all dids to handles and returns a new map[string]string 97 - func emailToDidOrHandle(r *Repo, emailToDidMap map[string]string) map[string]string { 98 - if emailToDidMap == nil { 99 - return nil 100 - } 101 - 102 - var dids []string 103 - for _, v := range emailToDidMap { 104 - dids = append(dids, v) 105 - } 106 - resolvedIdents := r.idResolver.ResolveIdents(context.Background(), dids) 107 - 108 - didHandleMap := make(map[string]string) 109 - for _, identity := range resolvedIdents { 110 - if !identity.Handle.IsInvalidHandle() { 111 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 112 - } else { 113 - didHandleMap[identity.DID.String()] = identity.DID.String() 114 - } 115 - } 116 - 117 - // Create map of email to didOrHandle for commit display 118 - emailToDidOrHandle := make(map[string]string) 119 - for email, did := range emailToDidMap { 120 - if didOrHandle, ok := didHandleMap[did]; ok { 121 - emailToDidOrHandle[email] = didOrHandle 122 - } 123 - } 124 - 125 - return emailToDidOrHandle 126 - } 127 - 128 - func randomString(n int) string { 129 - const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" 130 - result := make([]byte, n) 131 - 132 - for i := 0; i < n; i++ { 133 - n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letters)))) 134 - result[i] = letters[n.Int64()] 135 - } 136 - 137 - return string(result) 138 - } 139 - 140 89 // grab pipelines from DB and munge that into a hashmap with commit sha as key 141 90 // 142 91 // golang is so blessed that it requires 35 lines of imperative code for this 143 92 func getPipelineStatuses( 144 93 d *db.DB, 145 - repoInfo repoinfo.RepoInfo, 94 + repo *models.Repo, 146 95 shas []string, 147 96 ) (map[string]models.Pipeline, error) { 148 97 m := make(map[string]models.Pipeline) ··· 153 102 154 103 ps, err := db.GetPipelineStatuses( 155 104 d, 156 - db.FilterEq("repo_owner", repoInfo.OwnerDid), 157 - db.FilterEq("repo_name", repoInfo.Name), 158 - db.FilterEq("knot", repoInfo.Knot), 159 - db.FilterIn("sha", shas), 105 + len(shas), 106 + orm.FilterEq("repo_owner", repo.Did), 107 + orm.FilterEq("repo_name", repo.Name), 108 + orm.FilterEq("knot", repo.Knot), 109 + orm.FilterIn("sha", shas), 160 110 ) 161 111 if err != nil { 162 112 return nil, err
+14 -20
appview/repo/router.go
··· 9 9 10 10 func (rp *Repo) Router(mw *middleware.Middleware) http.Handler { 11 11 r := chi.NewRouter() 12 - r.Get("/", rp.RepoIndex) 13 - r.Get("/opengraph", rp.RepoOpenGraphSummary) 14 - r.Get("/feed.atom", rp.RepoAtomFeed) 15 - r.Get("/commits/{ref}", rp.RepoLog) 12 + r.Get("/", rp.Index) 13 + r.Get("/opengraph", rp.Opengraph) 14 + r.Get("/feed.atom", rp.AtomFeed) 15 + r.Get("/commits/{ref}", rp.Log) 16 16 r.Route("/tree/{ref}", func(r chi.Router) { 17 - r.Get("/", rp.RepoIndex) 18 - r.Get("/*", rp.RepoTree) 17 + r.Get("/", rp.Index) 18 + r.Get("/*", rp.Tree) 19 19 }) 20 - r.Get("/commit/{ref}", rp.RepoCommit) 21 - r.Get("/branches", rp.RepoBranches) 20 + r.Get("/commit/{ref}", rp.Commit) 21 + r.Get("/branches", rp.Branches) 22 22 r.Delete("/branches", rp.DeleteBranch) 23 23 r.Route("/tags", func(r chi.Router) { 24 - r.Get("/", rp.RepoTags) 24 + r.Get("/", rp.Tags) 25 25 r.Route("/{tag}", func(r chi.Router) { 26 26 r.Get("/download/{file}", rp.DownloadArtifact) 27 27 ··· 37 37 }) 38 38 }) 39 39 }) 40 - r.Get("/blob/{ref}/*", rp.RepoBlob) 40 + r.Get("/blob/{ref}/*", rp.Blob) 41 41 r.Get("/raw/{ref}/*", rp.RepoBlobRaw) 42 42 43 43 // intentionally doesn't use /* as this isn't ··· 54 54 }) 55 55 56 56 r.Route("/compare", func(r chi.Router) { 57 - r.Get("/", rp.RepoCompareNew) // start an new comparison 57 + r.Get("/", rp.CompareNew) // start an new comparison 58 58 59 59 // we have to wildcard here since we want to support GitHub's compare syntax 60 60 // /compare/{ref1}...{ref2} 61 61 // for example: 62 62 // /compare/master...some/feature 63 63 // /compare/master...example.com:another/feature <- this is a fork 64 - r.Get("/{base}/{head}", rp.RepoCompare) 65 - r.Get("/*", rp.RepoCompare) 64 + r.Get("/*", rp.Compare) 66 65 }) 67 66 68 67 // label panel in issues/pulls/discussions/tasks ··· 74 73 // settings routes, needs auth 75 74 r.Group(func(r chi.Router) { 76 75 r.Use(middleware.AuthMiddleware(rp.oauth)) 77 - // repo description can only be edited by owner 78 - r.With(mw.RepoPermissionMiddleware("repo:owner")).Route("/description", func(r chi.Router) { 79 - r.Put("/", rp.RepoDescription) 80 - r.Get("/", rp.RepoDescription) 81 - r.Get("/edit", rp.RepoDescriptionEdit) 82 - }) 83 76 r.With(mw.RepoPermissionMiddleware("repo:settings")).Route("/settings", func(r chi.Router) { 84 - r.Get("/", rp.RepoSettings) 77 + r.Get("/", rp.Settings) 78 + r.With(mw.RepoPermissionMiddleware("repo:owner")).Put("/base", rp.EditBaseSettings) 85 79 r.With(mw.RepoPermissionMiddleware("repo:owner")).Post("/spindle", rp.EditSpindle) 86 80 r.With(mw.RepoPermissionMiddleware("repo:owner")).Put("/label", rp.AddLabelDef) 87 81 r.With(mw.RepoPermissionMiddleware("repo:owner")).Delete("/label", rp.DeleteLabelDef)
+471
appview/repo/settings.go
··· 1 + package repo 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + "slices" 8 + "strings" 9 + "time" 10 + 11 + "tangled.org/core/api/tangled" 12 + "tangled.org/core/appview/db" 13 + "tangled.org/core/appview/models" 14 + "tangled.org/core/appview/oauth" 15 + "tangled.org/core/appview/pages" 16 + xrpcclient "tangled.org/core/appview/xrpcclient" 17 + "tangled.org/core/orm" 18 + "tangled.org/core/types" 19 + 20 + comatproto "github.com/bluesky-social/indigo/api/atproto" 21 + lexutil "github.com/bluesky-social/indigo/lex/util" 22 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 23 + ) 24 + 25 + type tab = map[string]any 26 + 27 + var ( 28 + // would be great to have ordered maps right about now 29 + settingsTabs []tab = []tab{ 30 + {"Name": "general", "Icon": "sliders-horizontal"}, 31 + {"Name": "access", "Icon": "users"}, 32 + {"Name": "pipelines", "Icon": "layers-2"}, 33 + } 34 + ) 35 + 36 + func (rp *Repo) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { 37 + l := rp.logger.With("handler", "SetDefaultBranch") 38 + 39 + f, err := rp.repoResolver.Resolve(r) 40 + if err != nil { 41 + l.Error("failed to get repo and knot", "err", err) 42 + return 43 + } 44 + 45 + noticeId := "operation-error" 46 + branch := r.FormValue("branch") 47 + if branch == "" { 48 + http.Error(w, "malformed form", http.StatusBadRequest) 49 + return 50 + } 51 + 52 + client, err := rp.oauth.ServiceClient( 53 + r, 54 + oauth.WithService(f.Knot), 55 + oauth.WithLxm(tangled.RepoSetDefaultBranchNSID), 56 + oauth.WithDev(rp.config.Core.Dev), 57 + ) 58 + if err != nil { 59 + l.Error("failed to connect to knot server", "err", err) 60 + rp.pages.Notice(w, noticeId, "Failed to connect to knot server.") 61 + return 62 + } 63 + 64 + xe := tangled.RepoSetDefaultBranch( 65 + r.Context(), 66 + client, 67 + &tangled.RepoSetDefaultBranch_Input{ 68 + Repo: f.RepoAt().String(), 69 + DefaultBranch: branch, 70 + }, 71 + ) 72 + if err := xrpcclient.HandleXrpcErr(xe); err != nil { 73 + l.Error("xrpc failed", "err", xe) 74 + rp.pages.Notice(w, noticeId, err.Error()) 75 + return 76 + } 77 + 78 + rp.pages.HxRefresh(w) 79 + } 80 + 81 + func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) { 82 + user := rp.oauth.GetUser(r) 83 + l := rp.logger.With("handler", "Secrets") 84 + l = l.With("did", user.Did) 85 + 86 + f, err := rp.repoResolver.Resolve(r) 87 + if err != nil { 88 + l.Error("failed to get repo and knot", "err", err) 89 + return 90 + } 91 + 92 + if f.Spindle == "" { 93 + l.Error("empty spindle cannot add/rm secret", "err", err) 94 + return 95 + } 96 + 97 + lxm := tangled.RepoAddSecretNSID 98 + if r.Method == http.MethodDelete { 99 + lxm = tangled.RepoRemoveSecretNSID 100 + } 101 + 102 + spindleClient, err := rp.oauth.ServiceClient( 103 + r, 104 + oauth.WithService(f.Spindle), 105 + oauth.WithLxm(lxm), 106 + oauth.WithExp(60), 107 + oauth.WithDev(rp.config.Core.Dev), 108 + ) 109 + if err != nil { 110 + l.Error("failed to create spindle client", "err", err) 111 + return 112 + } 113 + 114 + key := r.FormValue("key") 115 + if key == "" { 116 + w.WriteHeader(http.StatusBadRequest) 117 + return 118 + } 119 + 120 + switch r.Method { 121 + case http.MethodPut: 122 + errorId := "add-secret-error" 123 + 124 + value := r.FormValue("value") 125 + if value == "" { 126 + w.WriteHeader(http.StatusBadRequest) 127 + return 128 + } 129 + 130 + err = tangled.RepoAddSecret( 131 + r.Context(), 132 + spindleClient, 133 + &tangled.RepoAddSecret_Input{ 134 + Repo: f.RepoAt().String(), 135 + Key: key, 136 + Value: value, 137 + }, 138 + ) 139 + if err != nil { 140 + l.Error("Failed to add secret.", "err", err) 141 + rp.pages.Notice(w, errorId, "Failed to add secret.") 142 + return 143 + } 144 + 145 + case http.MethodDelete: 146 + errorId := "operation-error" 147 + 148 + err = tangled.RepoRemoveSecret( 149 + r.Context(), 150 + spindleClient, 151 + &tangled.RepoRemoveSecret_Input{ 152 + Repo: f.RepoAt().String(), 153 + Key: key, 154 + }, 155 + ) 156 + if err != nil { 157 + l.Error("Failed to delete secret.", "err", err) 158 + rp.pages.Notice(w, errorId, "Failed to delete secret.") 159 + return 160 + } 161 + } 162 + 163 + rp.pages.HxRefresh(w) 164 + } 165 + 166 + func (rp *Repo) Settings(w http.ResponseWriter, r *http.Request) { 167 + tabVal := r.URL.Query().Get("tab") 168 + if tabVal == "" { 169 + tabVal = "general" 170 + } 171 + 172 + switch tabVal { 173 + case "general": 174 + rp.generalSettings(w, r) 175 + 176 + case "access": 177 + rp.accessSettings(w, r) 178 + 179 + case "pipelines": 180 + rp.pipelineSettings(w, r) 181 + } 182 + } 183 + 184 + func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) { 185 + l := rp.logger.With("handler", "generalSettings") 186 + 187 + f, err := rp.repoResolver.Resolve(r) 188 + user := rp.oauth.GetUser(r) 189 + 190 + scheme := "http" 191 + if !rp.config.Core.Dev { 192 + scheme = "https" 193 + } 194 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 195 + xrpcc := &indigoxrpc.Client{ 196 + Host: host, 197 + } 198 + 199 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 200 + xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 201 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 202 + l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 203 + rp.pages.Error503(w) 204 + return 205 + } 206 + 207 + var result types.RepoBranchesResponse 208 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 209 + l.Error("failed to decode XRPC response", "err", err) 210 + rp.pages.Error503(w) 211 + return 212 + } 213 + 214 + defaultLabels, err := db.GetLabelDefinitions(rp.db, orm.FilterIn("at_uri", rp.config.Label.DefaultLabelDefs)) 215 + if err != nil { 216 + l.Error("failed to fetch labels", "err", err) 217 + rp.pages.Error503(w) 218 + return 219 + } 220 + 221 + labels, err := db.GetLabelDefinitions(rp.db, orm.FilterIn("at_uri", f.Labels)) 222 + if err != nil { 223 + l.Error("failed to fetch labels", "err", err) 224 + rp.pages.Error503(w) 225 + return 226 + } 227 + // remove default labels from the labels list, if present 228 + defaultLabelMap := make(map[string]bool) 229 + for _, dl := range defaultLabels { 230 + defaultLabelMap[dl.AtUri().String()] = true 231 + } 232 + n := 0 233 + for _, l := range labels { 234 + if !defaultLabelMap[l.AtUri().String()] { 235 + labels[n] = l 236 + n++ 237 + } 238 + } 239 + labels = labels[:n] 240 + 241 + subscribedLabels := make(map[string]struct{}) 242 + for _, l := range f.Labels { 243 + subscribedLabels[l] = struct{}{} 244 + } 245 + 246 + // if there is atleast 1 unsubbed default label, show the "subscribe all" button, 247 + // if all default labels are subbed, show the "unsubscribe all" button 248 + shouldSubscribeAll := false 249 + for _, dl := range defaultLabels { 250 + if _, ok := subscribedLabels[dl.AtUri().String()]; !ok { 251 + // one of the default labels is not subscribed to 252 + shouldSubscribeAll = true 253 + break 254 + } 255 + } 256 + 257 + rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{ 258 + LoggedInUser: user, 259 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 260 + Branches: result.Branches, 261 + Labels: labels, 262 + DefaultLabels: defaultLabels, 263 + SubscribedLabels: subscribedLabels, 264 + ShouldSubscribeAll: shouldSubscribeAll, 265 + Tabs: settingsTabs, 266 + Tab: "general", 267 + }) 268 + } 269 + 270 + func (rp *Repo) accessSettings(w http.ResponseWriter, r *http.Request) { 271 + l := rp.logger.With("handler", "accessSettings") 272 + 273 + f, err := rp.repoResolver.Resolve(r) 274 + user := rp.oauth.GetUser(r) 275 + 276 + collaborators, err := func(repo *models.Repo) ([]pages.Collaborator, error) { 277 + repoCollaborators, err := rp.enforcer.E.GetImplicitUsersForResourceByDomain(repo.DidSlashRepo(), repo.Knot) 278 + if err != nil { 279 + return nil, err 280 + } 281 + var collaborators []pages.Collaborator 282 + for _, item := range repoCollaborators { 283 + // currently only two roles: owner and member 284 + var role string 285 + switch item[3] { 286 + case "repo:owner": 287 + role = "owner" 288 + case "repo:collaborator": 289 + role = "collaborator" 290 + default: 291 + continue 292 + } 293 + 294 + did := item[0] 295 + 296 + c := pages.Collaborator{ 297 + Did: did, 298 + Role: role, 299 + } 300 + collaborators = append(collaborators, c) 301 + } 302 + return collaborators, nil 303 + }(f) 304 + if err != nil { 305 + l.Error("failed to get collaborators", "err", err) 306 + } 307 + 308 + rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{ 309 + LoggedInUser: user, 310 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 311 + Tabs: settingsTabs, 312 + Tab: "access", 313 + Collaborators: collaborators, 314 + }) 315 + } 316 + 317 + func (rp *Repo) pipelineSettings(w http.ResponseWriter, r *http.Request) { 318 + l := rp.logger.With("handler", "pipelineSettings") 319 + 320 + f, err := rp.repoResolver.Resolve(r) 321 + user := rp.oauth.GetUser(r) 322 + 323 + // all spindles that the repo owner is a member of 324 + spindles, err := rp.enforcer.GetSpindlesForUser(f.Did) 325 + if err != nil { 326 + l.Error("failed to fetch spindles", "err", err) 327 + return 328 + } 329 + 330 + var secrets []*tangled.RepoListSecrets_Secret 331 + if f.Spindle != "" { 332 + if spindleClient, err := rp.oauth.ServiceClient( 333 + r, 334 + oauth.WithService(f.Spindle), 335 + oauth.WithLxm(tangled.RepoListSecretsNSID), 336 + oauth.WithExp(60), 337 + oauth.WithDev(rp.config.Core.Dev), 338 + ); err != nil { 339 + l.Error("failed to create spindle client", "err", err) 340 + } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil { 341 + l.Error("failed to fetch secrets", "err", err) 342 + } else { 343 + secrets = resp.Secrets 344 + } 345 + } 346 + 347 + slices.SortFunc(secrets, func(a, b *tangled.RepoListSecrets_Secret) int { 348 + return strings.Compare(a.Key, b.Key) 349 + }) 350 + 351 + var dids []string 352 + for _, s := range secrets { 353 + dids = append(dids, s.CreatedBy) 354 + } 355 + resolvedIdents := rp.idResolver.ResolveIdents(r.Context(), dids) 356 + 357 + // convert to a more manageable form 358 + var niceSecret []map[string]any 359 + for id, s := range secrets { 360 + when, _ := time.Parse(time.RFC3339, s.CreatedAt) 361 + niceSecret = append(niceSecret, map[string]any{ 362 + "Id": id, 363 + "Key": s.Key, 364 + "CreatedAt": when, 365 + "CreatedBy": resolvedIdents[id].Handle.String(), 366 + }) 367 + } 368 + 369 + rp.pages.RepoPipelineSettings(w, pages.RepoPipelineSettingsParams{ 370 + LoggedInUser: user, 371 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 372 + Tabs: settingsTabs, 373 + Tab: "pipelines", 374 + Spindles: spindles, 375 + CurrentSpindle: f.Spindle, 376 + Secrets: niceSecret, 377 + }) 378 + } 379 + 380 + func (rp *Repo) EditBaseSettings(w http.ResponseWriter, r *http.Request) { 381 + l := rp.logger.With("handler", "EditBaseSettings") 382 + 383 + noticeId := "repo-base-settings-error" 384 + 385 + f, err := rp.repoResolver.Resolve(r) 386 + if err != nil { 387 + l.Error("failed to get repo and knot", "err", err) 388 + w.WriteHeader(http.StatusBadRequest) 389 + return 390 + } 391 + 392 + client, err := rp.oauth.AuthorizedClient(r) 393 + if err != nil { 394 + l.Error("failed to get client") 395 + rp.pages.Notice(w, noticeId, "Failed to update repository information, try again later.") 396 + return 397 + } 398 + 399 + var ( 400 + description = r.FormValue("description") 401 + website = r.FormValue("website") 402 + topicStr = r.FormValue("topics") 403 + ) 404 + 405 + err = rp.validator.ValidateURI(website) 406 + if website != "" && err != nil { 407 + l.Error("invalid uri", "err", err) 408 + rp.pages.Notice(w, noticeId, err.Error()) 409 + return 410 + } 411 + 412 + topics, err := rp.validator.ValidateRepoTopicStr(topicStr) 413 + if err != nil { 414 + l.Error("invalid topics", "err", err) 415 + rp.pages.Notice(w, noticeId, err.Error()) 416 + return 417 + } 418 + l.Debug("got", "topicsStr", topicStr, "topics", topics) 419 + 420 + newRepo := *f 421 + newRepo.Description = description 422 + newRepo.Website = website 423 + newRepo.Topics = topics 424 + record := newRepo.AsRecord() 425 + 426 + tx, err := rp.db.BeginTx(r.Context(), nil) 427 + if err != nil { 428 + l.Error("failed to begin transaction", "err", err) 429 + rp.pages.Notice(w, noticeId, "Failed to save repository information.") 430 + return 431 + } 432 + defer tx.Rollback() 433 + 434 + err = db.PutRepo(tx, newRepo) 435 + if err != nil { 436 + l.Error("failed to update repository", "err", err) 437 + rp.pages.Notice(w, noticeId, "Failed to save repository information.") 438 + return 439 + } 440 + 441 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 442 + if err != nil { 443 + // failed to get record 444 + l.Error("failed to get repo record", "err", err) 445 + rp.pages.Notice(w, noticeId, "Failed to save repository information, no record found on PDS.") 446 + return 447 + } 448 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 449 + Collection: tangled.RepoNSID, 450 + Repo: newRepo.Did, 451 + Rkey: newRepo.Rkey, 452 + SwapRecord: ex.Cid, 453 + Record: &lexutil.LexiconTypeDecoder{ 454 + Val: &record, 455 + }, 456 + }) 457 + 458 + if err != nil { 459 + l.Error("failed to perferom update-repo query", "err", err) 460 + // failed to get record 461 + rp.pages.Notice(w, noticeId, "Failed to save repository information, unable to save to PDS.") 462 + return 463 + } 464 + 465 + err = tx.Commit() 466 + if err != nil { 467 + l.Error("failed to commit", "err", err) 468 + } 469 + 470 + rp.pages.HxRefresh(w) 471 + }
+80
appview/repo/tags.go
··· 1 + package repo 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + 8 + "tangled.org/core/api/tangled" 9 + "tangled.org/core/appview/db" 10 + "tangled.org/core/appview/models" 11 + "tangled.org/core/appview/pages" 12 + xrpcclient "tangled.org/core/appview/xrpcclient" 13 + "tangled.org/core/orm" 14 + "tangled.org/core/types" 15 + 16 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 17 + "github.com/go-git/go-git/v5/plumbing" 18 + ) 19 + 20 + func (rp *Repo) Tags(w http.ResponseWriter, r *http.Request) { 21 + l := rp.logger.With("handler", "RepoTags") 22 + f, err := rp.repoResolver.Resolve(r) 23 + if err != nil { 24 + l.Error("failed to get repo and knot", "err", err) 25 + return 26 + } 27 + scheme := "http" 28 + if !rp.config.Core.Dev { 29 + scheme = "https" 30 + } 31 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 32 + xrpcc := &indigoxrpc.Client{ 33 + Host: host, 34 + } 35 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 36 + xrpcBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 37 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 38 + l.Error("failed to call XRPC repo.tags", "err", xrpcerr) 39 + rp.pages.Error503(w) 40 + return 41 + } 42 + var result types.RepoTagsResponse 43 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 44 + l.Error("failed to decode XRPC response", "err", err) 45 + rp.pages.Error503(w) 46 + return 47 + } 48 + artifacts, err := db.GetArtifact(rp.db, orm.FilterEq("repo_at", f.RepoAt())) 49 + if err != nil { 50 + l.Error("failed grab artifacts", "err", err) 51 + return 52 + } 53 + // convert artifacts to map for easy UI building 54 + artifactMap := make(map[plumbing.Hash][]models.Artifact) 55 + for _, a := range artifacts { 56 + artifactMap[a.Tag] = append(artifactMap[a.Tag], a) 57 + } 58 + var danglingArtifacts []models.Artifact 59 + for _, a := range artifacts { 60 + found := false 61 + for _, t := range result.Tags { 62 + if t.Tag != nil { 63 + if t.Tag.Hash == a.Tag { 64 + found = true 65 + } 66 + } 67 + } 68 + if !found { 69 + danglingArtifacts = append(danglingArtifacts, a) 70 + } 71 + } 72 + user := rp.oauth.GetUser(r) 73 + rp.pages.RepoTags(w, pages.RepoTagsParams{ 74 + LoggedInUser: user, 75 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 76 + RepoTagsResponse: result, 77 + ArtifactMap: artifactMap, 78 + DanglingArtifacts: danglingArtifacts, 79 + }) 80 + }
+108
appview/repo/tree.go
··· 1 + package repo 2 + 3 + import ( 4 + "fmt" 5 + "net/http" 6 + "net/url" 7 + "strings" 8 + "time" 9 + 10 + "tangled.org/core/api/tangled" 11 + "tangled.org/core/appview/pages" 12 + "tangled.org/core/appview/reporesolver" 13 + xrpcclient "tangled.org/core/appview/xrpcclient" 14 + "tangled.org/core/types" 15 + 16 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 17 + "github.com/go-chi/chi/v5" 18 + "github.com/go-git/go-git/v5/plumbing" 19 + ) 20 + 21 + func (rp *Repo) Tree(w http.ResponseWriter, r *http.Request) { 22 + l := rp.logger.With("handler", "RepoTree") 23 + f, err := rp.repoResolver.Resolve(r) 24 + if err != nil { 25 + l.Error("failed to fully resolve repo", "err", err) 26 + return 27 + } 28 + ref := chi.URLParam(r, "ref") 29 + ref, _ = url.PathUnescape(ref) 30 + // if the tree path has a trailing slash, let's strip it 31 + // so we don't 404 32 + treePath := chi.URLParam(r, "*") 33 + treePath, _ = url.PathUnescape(treePath) 34 + treePath = strings.TrimSuffix(treePath, "/") 35 + scheme := "http" 36 + if !rp.config.Core.Dev { 37 + scheme = "https" 38 + } 39 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 40 + xrpcc := &indigoxrpc.Client{ 41 + Host: host, 42 + } 43 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 44 + xrpcResp, err := tangled.RepoTree(r.Context(), xrpcc, treePath, ref, repo) 45 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 46 + l.Error("failed to call XRPC repo.tree", "err", xrpcerr) 47 + rp.pages.Error503(w) 48 + return 49 + } 50 + // Convert XRPC response to internal types.RepoTreeResponse 51 + files := make([]types.NiceTree, len(xrpcResp.Files)) 52 + for i, xrpcFile := range xrpcResp.Files { 53 + file := types.NiceTree{ 54 + Name: xrpcFile.Name, 55 + Mode: xrpcFile.Mode, 56 + Size: int64(xrpcFile.Size), 57 + } 58 + // Convert last commit info if present 59 + if xrpcFile.Last_commit != nil { 60 + commitWhen, _ := time.Parse(time.RFC3339, xrpcFile.Last_commit.When) 61 + file.LastCommit = &types.LastCommitInfo{ 62 + Hash: plumbing.NewHash(xrpcFile.Last_commit.Hash), 63 + Message: xrpcFile.Last_commit.Message, 64 + When: commitWhen, 65 + } 66 + } 67 + files[i] = file 68 + } 69 + result := types.RepoTreeResponse{ 70 + Ref: xrpcResp.Ref, 71 + Files: files, 72 + } 73 + if xrpcResp.Parent != nil { 74 + result.Parent = *xrpcResp.Parent 75 + } 76 + if xrpcResp.Dotdot != nil { 77 + result.DotDot = *xrpcResp.Dotdot 78 + } 79 + if xrpcResp.Readme != nil { 80 + result.ReadmeFileName = xrpcResp.Readme.Filename 81 + result.Readme = xrpcResp.Readme.Contents 82 + } 83 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 84 + // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated, 85 + // so we can safely redirect to the "parent" (which is the same file). 86 + if len(result.Files) == 0 && result.Parent == treePath { 87 + redirectTo := fmt.Sprintf("/%s/blob/%s/%s", ownerSlashRepo, url.PathEscape(ref), result.Parent) 88 + http.Redirect(w, r, redirectTo, http.StatusFound) 89 + return 90 + } 91 + user := rp.oauth.GetUser(r) 92 + var breadcrumbs [][]string 93 + breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", ownerSlashRepo, url.PathEscape(ref))}) 94 + if treePath != "" { 95 + for idx, elem := range strings.Split(treePath, "/") { 96 + breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))}) 97 + } 98 + } 99 + sortFiles(result.Files) 100 + 101 + rp.pages.RepoTree(w, pages.RepoTreeParams{ 102 + LoggedInUser: user, 103 + BreadCrumbs: breadcrumbs, 104 + TreePath: treePath, 105 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 106 + RepoTreeResponse: result, 107 + }) 108 + }
+98 -159
appview/reporesolver/resolver.go
··· 1 1 package reporesolver 2 2 3 3 import ( 4 - "context" 5 - "database/sql" 6 - "errors" 7 4 "fmt" 8 5 "log" 9 6 "net/http" ··· 12 9 "strings" 13 10 14 11 "github.com/bluesky-social/indigo/atproto/identity" 15 - securejoin "github.com/cyphar/filepath-securejoin" 16 12 "github.com/go-chi/chi/v5" 17 13 "tangled.org/core/appview/config" 18 14 "tangled.org/core/appview/db" 19 15 "tangled.org/core/appview/models" 20 16 "tangled.org/core/appview/oauth" 21 - "tangled.org/core/appview/pages" 22 17 "tangled.org/core/appview/pages/repoinfo" 23 - "tangled.org/core/idresolver" 24 18 "tangled.org/core/rbac" 25 19 ) 26 20 27 - type ResolvedRepo struct { 28 - models.Repo 29 - OwnerId identity.Identity 30 - CurrentDir string 31 - Ref string 32 - 33 - rr *RepoResolver 21 + type RepoResolver struct { 22 + config *config.Config 23 + enforcer *rbac.Enforcer 24 + execer db.Execer 34 25 } 35 26 36 - type RepoResolver struct { 37 - config *config.Config 38 - enforcer *rbac.Enforcer 39 - idResolver *idresolver.Resolver 40 - execer db.Execer 27 + func New(config *config.Config, enforcer *rbac.Enforcer, execer db.Execer) *RepoResolver { 28 + return &RepoResolver{config: config, enforcer: enforcer, execer: execer} 41 29 } 42 30 43 - func New(config *config.Config, enforcer *rbac.Enforcer, resolver *idresolver.Resolver, execer db.Execer) *RepoResolver { 44 - return &RepoResolver{config: config, enforcer: enforcer, idResolver: resolver, execer: execer} 31 + // NOTE: this... should not even be here. the entire package will be removed in future refactor 32 + func GetBaseRepoPath(r *http.Request, repo *models.Repo) string { 33 + var ( 34 + user = chi.URLParam(r, "user") 35 + name = chi.URLParam(r, "repo") 36 + ) 37 + if user == "" || name == "" { 38 + return repo.DidSlashRepo() 39 + } 40 + return path.Join(user, name) 45 41 } 46 42 47 - func (rr *RepoResolver) Resolve(r *http.Request) (*ResolvedRepo, error) { 43 + // TODO: move this out of `RepoResolver` struct 44 + func (rr *RepoResolver) Resolve(r *http.Request) (*models.Repo, error) { 48 45 repo, ok := r.Context().Value("repo").(*models.Repo) 49 46 if !ok { 50 47 log.Println("malformed middleware: `repo` not exist in context") 51 48 return nil, fmt.Errorf("malformed middleware") 52 49 } 53 - id, ok := r.Context().Value("resolvedId").(identity.Identity) 54 - if !ok { 55 - log.Println("malformed middleware") 56 - return nil, fmt.Errorf("malformed middleware") 57 - } 58 50 59 - currentDir := path.Dir(extractPathAfterRef(r.URL.EscapedPath())) 60 - ref := chi.URLParam(r, "ref") 61 - 62 - return &ResolvedRepo{ 63 - Repo: *repo, 64 - OwnerId: id, 65 - CurrentDir: currentDir, 66 - Ref: ref, 67 - 68 - rr: rr, 69 - }, nil 51 + return repo, nil 70 52 } 71 53 72 - func (f *ResolvedRepo) OwnerDid() string { 73 - return f.OwnerId.DID.String() 74 - } 75 - 76 - func (f *ResolvedRepo) OwnerHandle() string { 77 - return f.OwnerId.Handle.String() 78 - } 79 - 80 - func (f *ResolvedRepo) OwnerSlashRepo() string { 81 - handle := f.OwnerId.Handle 82 - 83 - var p string 84 - if handle != "" && !handle.IsInvalidHandle() { 85 - p, _ = securejoin.SecureJoin(fmt.Sprintf("@%s", handle), f.Name) 86 - } else { 87 - p, _ = securejoin.SecureJoin(f.OwnerDid(), f.Name) 54 + // 1. [x] replace `RepoInfo` to `reporesolver.GetRepoInfo(r *http.Request, repo, user)` 55 + // 2. [x] remove `rr`, `CurrentDir`, `Ref` fields from `ResolvedRepo` 56 + // 3. [x] remove `ResolvedRepo` 57 + // 4. [ ] replace reporesolver to reposervice 58 + func (rr *RepoResolver) GetRepoInfo(r *http.Request, user *oauth.User) repoinfo.RepoInfo { 59 + ownerId, ook := r.Context().Value("resolvedId").(identity.Identity) 60 + repo, rok := r.Context().Value("repo").(*models.Repo) 61 + if !ook || !rok { 62 + log.Println("malformed request, failed to get repo from context") 88 63 } 89 64 90 - return p 91 - } 65 + // get dir/ref 66 + currentDir := extractCurrentDir(r.URL.EscapedPath()) 67 + ref := chi.URLParam(r, "ref") 92 68 93 - func (f *ResolvedRepo) Collaborators(ctx context.Context) ([]pages.Collaborator, error) { 94 - repoCollaborators, err := f.rr.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot) 95 - if err != nil { 96 - return nil, err 69 + repoAt := repo.RepoAt() 70 + isStarred := false 71 + roles := repoinfo.RolesInRepo{} 72 + if user != nil { 73 + isStarred = db.GetStarStatus(rr.execer, user.Did, repoAt) 74 + roles.Roles = rr.enforcer.GetPermissionsInRepo(user.Did, repo.Knot, repo.DidSlashRepo()) 97 75 } 98 76 99 - var collaborators []pages.Collaborator 100 - for _, item := range repoCollaborators { 101 - // currently only two roles: owner and member 102 - var role string 103 - switch item[3] { 104 - case "repo:owner": 105 - role = "owner" 106 - case "repo:collaborator": 107 - role = "collaborator" 108 - default: 109 - continue 77 + stats := repo.RepoStats 78 + if stats == nil { 79 + starCount, err := db.GetStarCount(rr.execer, repoAt) 80 + if err != nil { 81 + log.Println("failed to get star count for ", repoAt) 110 82 } 111 - 112 - did := item[0] 113 - 114 - c := pages.Collaborator{ 115 - Did: did, 116 - Handle: "", 117 - Role: role, 83 + issueCount, err := db.GetIssueCount(rr.execer, repoAt) 84 + if err != nil { 85 + log.Println("failed to get issue count for ", repoAt) 118 86 } 119 - collaborators = append(collaborators, c) 120 - } 121 - 122 - // populate all collborators with handles 123 - identsToResolve := make([]string, len(collaborators)) 124 - for i, collab := range collaborators { 125 - identsToResolve[i] = collab.Did 126 - } 127 - 128 - resolvedIdents := f.rr.idResolver.ResolveIdents(ctx, identsToResolve) 129 - for i, resolved := range resolvedIdents { 130 - if resolved != nil { 131 - collaborators[i].Handle = resolved.Handle.String() 87 + pullCount, err := db.GetPullCount(rr.execer, repoAt) 88 + if err != nil { 89 + log.Println("failed to get pull count for ", repoAt) 90 + } 91 + stats = &models.RepoStats{ 92 + StarCount: starCount, 93 + IssueCount: issueCount, 94 + PullCount: pullCount, 132 95 } 133 96 } 134 97 135 - return collaborators, nil 136 - } 137 - 138 - // this function is a bit weird since it now returns RepoInfo from an entirely different 139 - // package. we should refactor this or get rid of RepoInfo entirely. 140 - func (f *ResolvedRepo) RepoInfo(user *oauth.User) repoinfo.RepoInfo { 141 - repoAt := f.RepoAt() 142 - isStarred := false 143 - if user != nil { 144 - isStarred = db.GetStarStatus(f.rr.execer, user.Did, repoAt) 145 - } 146 - 147 - starCount, err := db.GetStarCount(f.rr.execer, repoAt) 148 - if err != nil { 149 - log.Println("failed to get star count for ", repoAt) 150 - } 151 - issueCount, err := db.GetIssueCount(f.rr.execer, repoAt) 152 - if err != nil { 153 - log.Println("failed to get issue count for ", repoAt) 154 - } 155 - pullCount, err := db.GetPullCount(f.rr.execer, repoAt) 156 - if err != nil { 157 - log.Println("failed to get issue count for ", repoAt) 158 - } 159 - source, err := db.GetRepoSource(f.rr.execer, repoAt) 160 - if errors.Is(err, sql.ErrNoRows) { 161 - source = "" 162 - } else if err != nil { 163 - log.Println("failed to get repo source for ", repoAt, err) 164 - } 165 - 166 98 var sourceRepo *models.Repo 167 - if source != "" { 168 - sourceRepo, err = db.GetRepoByAtUri(f.rr.execer, source) 99 + var err error 100 + if repo.Source != "" { 101 + sourceRepo, err = db.GetRepoByAtUri(rr.execer, repo.Source) 169 102 if err != nil { 170 103 log.Println("failed to get repo by at uri", err) 171 104 } 172 105 } 173 106 174 - var sourceHandle *identity.Identity 175 - if sourceRepo != nil { 176 - sourceHandle, err = f.rr.idResolver.ResolveIdent(context.Background(), sourceRepo.Did) 177 - if err != nil { 178 - log.Println("failed to resolve source repo", err) 179 - } 180 - } 107 + repoInfo := repoinfo.RepoInfo{ 108 + // this is basically a models.Repo 109 + OwnerDid: ownerId.DID.String(), 110 + OwnerHandle: ownerId.Handle.String(), 111 + Name: repo.Name, 112 + Rkey: repo.Rkey, 113 + Description: repo.Description, 114 + Website: repo.Website, 115 + Topics: repo.Topics, 116 + Knot: repo.Knot, 117 + Spindle: repo.Spindle, 118 + Stats: *stats, 181 119 182 - knot := f.Knot 120 + // fork repo upstream 121 + Source: sourceRepo, 183 122 184 - repoInfo := repoinfo.RepoInfo{ 185 - OwnerDid: f.OwnerDid(), 186 - OwnerHandle: f.OwnerHandle(), 187 - Name: f.Name, 188 - Rkey: f.Repo.Rkey, 189 - RepoAt: repoAt, 190 - Description: f.Description, 191 - IsStarred: isStarred, 192 - Knot: knot, 193 - Spindle: f.Spindle, 194 - Roles: f.RolesInRepo(user), 195 - Stats: models.RepoStats{ 196 - StarCount: starCount, 197 - IssueCount: issueCount, 198 - PullCount: pullCount, 199 - }, 200 - CurrentDir: f.CurrentDir, 201 - Ref: f.Ref, 202 - } 123 + // page context 124 + CurrentDir: currentDir, 125 + Ref: ref, 203 126 204 - if sourceRepo != nil { 205 - repoInfo.Source = sourceRepo 206 - repoInfo.SourceHandle = sourceHandle.Handle.String() 127 + // info related to the session 128 + IsStarred: isStarred, 129 + Roles: roles, 207 130 } 208 131 209 132 return repoInfo 210 133 } 211 134 212 - func (f *ResolvedRepo) RolesInRepo(u *oauth.User) repoinfo.RolesInRepo { 213 - if u != nil { 214 - r := f.rr.enforcer.GetPermissionsInRepo(u.Did, f.Knot, f.DidSlashRepo()) 215 - return repoinfo.RolesInRepo{Roles: r} 216 - } else { 217 - return repoinfo.RolesInRepo{} 135 + // extractCurrentDir gets the current directory for markdown link resolution. 136 + // for blob paths, returns the parent dir. for tree paths, returns the path itself. 137 + // 138 + // /@user/repo/blob/main/docs/README.md => docs 139 + // /@user/repo/tree/main/docs => docs 140 + func extractCurrentDir(fullPath string) string { 141 + fullPath = strings.TrimPrefix(fullPath, "/") 142 + 143 + blobPattern := regexp.MustCompile(`blob/[^/]+/(.*)$`) 144 + if matches := blobPattern.FindStringSubmatch(fullPath); len(matches) > 1 { 145 + return path.Dir(matches[1]) 218 146 } 147 + 148 + treePattern := regexp.MustCompile(`tree/[^/]+/(.*)$`) 149 + if matches := treePattern.FindStringSubmatch(fullPath); len(matches) > 1 { 150 + dir := strings.TrimSuffix(matches[1], "/") 151 + if dir == "" { 152 + return "." 153 + } 154 + return dir 155 + } 156 + 157 + return "." 219 158 } 220 159 221 160 // extractPathAfterRef gets the actual repository path
+22
appview/reporesolver/resolver_test.go
··· 1 + package reporesolver 2 + 3 + import "testing" 4 + 5 + func TestExtractCurrentDir(t *testing.T) { 6 + tests := []struct { 7 + path string 8 + want string 9 + }{ 10 + {"/@user/repo/blob/main/docs/README.md", "docs"}, 11 + {"/@user/repo/blob/main/README.md", "."}, 12 + {"/@user/repo/tree/main/docs", "docs"}, 13 + {"/@user/repo/tree/main/docs/", "docs"}, 14 + {"/@user/repo/tree/main", "."}, 15 + } 16 + 17 + for _, tt := range tests { 18 + if got := extractCurrentDir(tt.path); got != tt.want { 19 + t.Errorf("extractCurrentDir(%q) = %q, want %q", tt.path, got, tt.want) 20 + } 21 + } 22 + }
+5 -4
appview/serververify/verify.go
··· 9 9 "tangled.org/core/api/tangled" 10 10 "tangled.org/core/appview/db" 11 11 "tangled.org/core/appview/xrpcclient" 12 + "tangled.org/core/orm" 12 13 "tangled.org/core/rbac" 13 14 ) 14 15 ··· 76 77 // mark this spindle as verified in the db 77 78 rowId, err := db.VerifySpindle( 78 79 tx, 79 - db.FilterEq("owner", owner), 80 - db.FilterEq("instance", instance), 80 + orm.FilterEq("owner", owner), 81 + orm.FilterEq("instance", instance), 81 82 ) 82 83 if err != nil { 83 84 return 0, fmt.Errorf("failed to write to DB: %w", err) ··· 115 116 // mark as registered 116 117 err = db.MarkRegistered( 117 118 tx, 118 - db.FilterEq("did", owner), 119 - db.FilterEq("domain", domain), 119 + orm.FilterEq("did", owner), 120 + orm.FilterEq("domain", domain), 120 121 ) 121 122 if err != nil { 122 123 return fmt.Errorf("failed to register domain: %w", err)
+3
appview/settings/settings.go
··· 43 43 {"Name": "keys", "Icon": "key"}, 44 44 {"Name": "emails", "Icon": "mail"}, 45 45 {"Name": "notifications", "Icon": "bell"}, 46 + {"Name": "knots", "Icon": "volleyball"}, 47 + {"Name": "spindles", "Icon": "spool"}, 46 48 } 47 49 ) 48 50 ··· 120 122 PullCommented: r.FormValue("pull_commented") == "on", 121 123 PullMerged: r.FormValue("pull_merged") == "on", 122 124 Followed: r.FormValue("followed") == "on", 125 + UserMentioned: r.FormValue("user_mentioned") == "on", 123 126 EmailNotifications: r.FormValue("email_notifications") == "on", 124 127 } 125 128
+53 -31
appview/spindles/spindles.go
··· 6 6 "log/slog" 7 7 "net/http" 8 8 "slices" 9 + "strings" 9 10 "time" 10 11 11 12 "github.com/go-chi/chi/v5" ··· 19 20 "tangled.org/core/appview/serververify" 20 21 "tangled.org/core/appview/xrpcclient" 21 22 "tangled.org/core/idresolver" 23 + "tangled.org/core/orm" 22 24 "tangled.org/core/rbac" 23 25 "tangled.org/core/tid" 24 26 ··· 37 39 Logger *slog.Logger 38 40 } 39 41 42 + type tab = map[string]any 43 + 44 + var ( 45 + spindlesTabs []tab = []tab{ 46 + {"Name": "profile", "Icon": "user"}, 47 + {"Name": "keys", "Icon": "key"}, 48 + {"Name": "emails", "Icon": "mail"}, 49 + {"Name": "notifications", "Icon": "bell"}, 50 + {"Name": "knots", "Icon": "volleyball"}, 51 + {"Name": "spindles", "Icon": "spool"}, 52 + } 53 + ) 54 + 40 55 func (s *Spindles) Router() http.Handler { 41 56 r := chi.NewRouter() 42 57 ··· 57 72 user := s.OAuth.GetUser(r) 58 73 all, err := db.GetSpindles( 59 74 s.Db, 60 - db.FilterEq("owner", user.Did), 75 + orm.FilterEq("owner", user.Did), 61 76 ) 62 77 if err != nil { 63 78 s.Logger.Error("failed to fetch spindles", "err", err) ··· 68 83 s.Pages.Spindles(w, pages.SpindlesParams{ 69 84 LoggedInUser: user, 70 85 Spindles: all, 86 + Tabs: spindlesTabs, 87 + Tab: "spindles", 71 88 }) 72 89 } 73 90 ··· 85 102 86 103 spindles, err := db.GetSpindles( 87 104 s.Db, 88 - db.FilterEq("instance", instance), 89 - db.FilterEq("owner", user.Did), 90 - db.FilterIsNot("verified", "null"), 105 + orm.FilterEq("instance", instance), 106 + orm.FilterEq("owner", user.Did), 107 + orm.FilterIsNot("verified", "null"), 91 108 ) 92 109 if err != nil || len(spindles) != 1 { 93 110 l.Error("failed to get spindle", "err", err, "len(spindles)", len(spindles)) ··· 107 124 repos, err := db.GetRepos( 108 125 s.Db, 109 126 0, 110 - db.FilterEq("spindle", instance), 127 + orm.FilterEq("spindle", instance), 111 128 ) 112 129 if err != nil { 113 130 l.Error("failed to get spindle repos", "err", err) ··· 126 143 Spindle: spindle, 127 144 Members: members, 128 145 Repos: repoMap, 146 + Tabs: spindlesTabs, 147 + Tab: "spindles", 129 148 }) 130 149 } 131 150 ··· 146 165 } 147 166 148 167 instance := r.FormValue("instance") 168 + // Strip protocol, trailing slashes, and whitespace 169 + // Rkey cannot contain slashes 170 + instance = strings.TrimSpace(instance) 171 + instance = strings.TrimPrefix(instance, "https://") 172 + instance = strings.TrimPrefix(instance, "http://") 173 + instance = strings.TrimSuffix(instance, "/") 149 174 if instance == "" { 150 175 s.Pages.Notice(w, noticeId, "Incomplete form.") 151 176 return ··· 266 291 267 292 spindles, err := db.GetSpindles( 268 293 s.Db, 269 - db.FilterEq("owner", user.Did), 270 - db.FilterEq("instance", instance), 294 + orm.FilterEq("owner", user.Did), 295 + orm.FilterEq("instance", instance), 271 296 ) 272 297 if err != nil || len(spindles) != 1 { 273 298 l.Error("failed to retrieve instance", "err", err, "len(spindles)", len(spindles)) ··· 295 320 // remove spindle members first 296 321 err = db.RemoveSpindleMember( 297 322 tx, 298 - db.FilterEq("did", user.Did), 299 - db.FilterEq("instance", instance), 323 + orm.FilterEq("did", user.Did), 324 + orm.FilterEq("instance", instance), 300 325 ) 301 326 if err != nil { 302 327 l.Error("failed to remove spindle members", "err", err) ··· 306 331 307 332 err = db.DeleteSpindle( 308 333 tx, 309 - db.FilterEq("owner", user.Did), 310 - db.FilterEq("instance", instance), 334 + orm.FilterEq("owner", user.Did), 335 + orm.FilterEq("instance", instance), 311 336 ) 312 337 if err != nil { 313 338 l.Error("failed to delete spindle", "err", err) ··· 358 383 359 384 shouldRedirect := r.Header.Get("shouldRedirect") 360 385 if shouldRedirect == "true" { 361 - s.Pages.HxRedirect(w, "/spindles") 386 + s.Pages.HxRedirect(w, "/settings/spindles") 362 387 return 363 388 } 364 389 ··· 386 411 387 412 spindles, err := db.GetSpindles( 388 413 s.Db, 389 - db.FilterEq("owner", user.Did), 390 - db.FilterEq("instance", instance), 414 + orm.FilterEq("owner", user.Did), 415 + orm.FilterEq("instance", instance), 391 416 ) 392 417 if err != nil || len(spindles) != 1 { 393 418 l.Error("failed to retrieve instance", "err", err, "len(spindles)", len(spindles)) ··· 429 454 430 455 verifiedSpindle, err := db.GetSpindles( 431 456 s.Db, 432 - db.FilterEq("id", rowId), 457 + orm.FilterEq("id", rowId), 433 458 ) 434 459 if err != nil || len(verifiedSpindle) != 1 { 435 460 l.Error("failed get new spindle", "err", err) ··· 462 487 463 488 spindles, err := db.GetSpindles( 464 489 s.Db, 465 - db.FilterEq("owner", user.Did), 466 - db.FilterEq("instance", instance), 490 + orm.FilterEq("owner", user.Did), 491 + orm.FilterEq("instance", instance), 467 492 ) 468 493 if err != nil || len(spindles) != 1 { 469 494 l.Error("failed to retrieve instance", "err", err, "len(spindles)", len(spindles)) ··· 484 509 } 485 510 486 511 member := r.FormValue("member") 512 + member = strings.TrimPrefix(member, "@") 487 513 if member == "" { 488 514 l.Error("empty member") 489 515 s.Pages.Notice(w, noticeId, "Failed to add member, empty form.") ··· 573 599 } 574 600 575 601 // success 576 - s.Pages.HxRedirect(w, fmt.Sprintf("/spindles/%s", instance)) 602 + s.Pages.HxRedirect(w, fmt.Sprintf("/settings/spindles/%s", instance)) 577 603 } 578 604 579 605 func (s *Spindles) removeMember(w http.ResponseWriter, r *http.Request) { ··· 597 623 598 624 spindles, err := db.GetSpindles( 599 625 s.Db, 600 - db.FilterEq("owner", user.Did), 601 - db.FilterEq("instance", instance), 626 + orm.FilterEq("owner", user.Did), 627 + orm.FilterEq("instance", instance), 602 628 ) 603 629 if err != nil || len(spindles) != 1 { 604 630 l.Error("failed to retrieve instance", "err", err, "len(spindles)", len(spindles)) ··· 613 639 } 614 640 615 641 member := r.FormValue("member") 642 + member = strings.TrimPrefix(member, "@") 616 643 if member == "" { 617 644 l.Error("empty member") 618 645 s.Pages.Notice(w, noticeId, "Failed to remove member, empty form.") ··· 626 653 s.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 627 654 return 628 655 } 629 - if memberId.Handle.IsInvalidHandle() { 630 - l.Error("failed to resolve member identity to handle") 631 - s.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 632 - return 633 - } 634 656 635 657 tx, err := s.Db.Begin() 636 658 if err != nil { ··· 646 668 // get the record from the DB first: 647 669 members, err := db.GetSpindleMembers( 648 670 s.Db, 649 - db.FilterEq("did", user.Did), 650 - db.FilterEq("instance", instance), 651 - db.FilterEq("subject", memberId.DID), 671 + orm.FilterEq("did", user.Did), 672 + orm.FilterEq("instance", instance), 673 + orm.FilterEq("subject", memberId.DID), 652 674 ) 653 675 if err != nil || len(members) != 1 { 654 676 l.Error("failed to get member", "err", err) ··· 659 681 // remove from db 660 682 if err = db.RemoveSpindleMember( 661 683 tx, 662 - db.FilterEq("did", user.Did), 663 - db.FilterEq("instance", instance), 664 - db.FilterEq("subject", memberId.DID), 684 + orm.FilterEq("did", user.Did), 685 + orm.FilterEq("instance", instance), 686 + orm.FilterEq("subject", memberId.DID), 665 687 ); err != nil { 666 688 l.Error("failed to remove spindle member", "err", err) 667 689 fail()
+15 -8
appview/state/gfi.go
··· 1 1 package state 2 2 3 3 import ( 4 - "fmt" 5 4 "log" 6 5 "net/http" 7 6 "sort" 8 7 9 8 "github.com/bluesky-social/indigo/atproto/syntax" 10 - "tangled.org/core/api/tangled" 11 9 "tangled.org/core/appview/db" 12 10 "tangled.org/core/appview/models" 13 11 "tangled.org/core/appview/pages" 14 12 "tangled.org/core/appview/pagination" 15 13 "tangled.org/core/consts" 14 + "tangled.org/core/orm" 16 15 ) 17 16 18 17 func (s *State) GoodFirstIssues(w http.ResponseWriter, r *http.Request) { ··· 20 19 21 20 page := pagination.FromContext(r.Context()) 22 21 23 - goodFirstIssueLabel := fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "good-first-issue") 22 + goodFirstIssueLabel := s.config.Label.GoodFirstIssue 23 + 24 + gfiLabelDef, err := db.GetLabelDefinition(s.db, orm.FilterEq("at_uri", goodFirstIssueLabel)) 25 + if err != nil { 26 + log.Println("failed to get gfi label def", err) 27 + s.pages.Error500(w) 28 + return 29 + } 24 30 25 - repoLabels, err := db.GetRepoLabels(s.db, db.FilterEq("label_at", goodFirstIssueLabel)) 31 + repoLabels, err := db.GetRepoLabels(s.db, orm.FilterEq("label_at", goodFirstIssueLabel)) 26 32 if err != nil { 27 33 log.Println("failed to get repo labels", err) 28 34 s.pages.Error503(w) ··· 35 41 RepoGroups: []*models.RepoGroup{}, 36 42 LabelDefs: make(map[string]*models.LabelDefinition), 37 43 Page: page, 44 + GfiLabel: gfiLabelDef, 38 45 }) 39 46 return 40 47 } ··· 49 56 pagination.Page{ 50 57 Limit: 500, 51 58 }, 52 - db.FilterIn("repo_at", repoUris), 53 - db.FilterEq("open", 1), 59 + orm.FilterIn("repo_at", repoUris), 60 + orm.FilterEq("open", 1), 54 61 ) 55 62 if err != nil { 56 63 log.Println("failed to get issues", err) ··· 126 133 } 127 134 128 135 if len(uriList) > 0 { 129 - allLabelDefs, err = db.GetLabelDefinitions(s.db, db.FilterIn("at_uri", uriList)) 136 + allLabelDefs, err = db.GetLabelDefinitions(s.db, orm.FilterIn("at_uri", uriList)) 130 137 if err != nil { 131 138 log.Println("failed to fetch labels", err) 132 139 } ··· 143 150 RepoGroups: paginatedGroups, 144 151 LabelDefs: labelDefsMap, 145 152 Page: page, 146 - GfiLabel: labelDefsMap[goodFirstIssueLabel], 153 + GfiLabel: gfiLabelDef, 147 154 }) 148 155 }
+17
appview/state/git_http.go
··· 25 25 26 26 } 27 27 28 + func (s *State) UploadArchive(w http.ResponseWriter, r *http.Request) { 29 + user, ok := r.Context().Value("resolvedId").(identity.Identity) 30 + if !ok { 31 + http.Error(w, "failed to resolve user", http.StatusInternalServerError) 32 + return 33 + } 34 + repo := r.Context().Value("repo").(*models.Repo) 35 + 36 + scheme := "https" 37 + if s.config.Core.Dev { 38 + scheme = "http" 39 + } 40 + 41 + targetURL := fmt.Sprintf("%s://%s/%s/%s/git-upload-archive?%s", scheme, repo.Knot, user.DID, repo.Name, r.URL.RawQuery) 42 + s.proxyRequest(w, r, targetURL) 43 + } 44 + 28 45 func (s *State) UploadPack(w http.ResponseWriter, r *http.Request) { 29 46 user, ok := r.Context().Value("resolvedId").(identity.Identity) 30 47 if !ok {
+6 -5
appview/state/knotstream.go
··· 16 16 ec "tangled.org/core/eventconsumer" 17 17 "tangled.org/core/eventconsumer/cursor" 18 18 "tangled.org/core/log" 19 + "tangled.org/core/orm" 19 20 "tangled.org/core/rbac" 20 21 "tangled.org/core/workflow" 21 22 ··· 30 31 31 32 knots, err := db.GetRegistrations( 32 33 d, 33 - db.FilterIsNot("registered", "null"), 34 + orm.FilterIsNot("registered", "null"), 34 35 ) 35 36 if err != nil { 36 37 return nil, err ··· 143 144 repos, err := db.GetRepos( 144 145 d, 145 146 0, 146 - db.FilterEq("did", record.RepoDid), 147 - db.FilterEq("name", record.RepoName), 147 + orm.FilterEq("did", record.RepoDid), 148 + orm.FilterEq("name", record.RepoName), 148 149 ) 149 150 if err != nil { 150 151 return fmt.Errorf("failed to look for repo in DB (%s/%s): %w", record.RepoDid, record.RepoName, err) ··· 209 210 repos, err := db.GetRepos( 210 211 d, 211 212 0, 212 - db.FilterEq("did", record.TriggerMetadata.Repo.Did), 213 - db.FilterEq("name", record.TriggerMetadata.Repo.Repo), 213 + orm.FilterEq("did", record.TriggerMetadata.Repo.Did), 214 + orm.FilterEq("name", record.TriggerMetadata.Repo.Repo), 214 215 ) 215 216 if err != nil { 216 217 return fmt.Errorf("failed to look for repo in DB: nsid %s, rkey %s, %w", msg.Nsid, msg.Rkey, err)
+1
appview/state/login.go
··· 46 46 47 47 redirectURL, err := s.oauth.ClientApp.StartAuthFlow(r.Context(), handle) 48 48 if err != nil { 49 + l.Error("failed to start auth", "err", err) 49 50 http.Error(w, err.Error(), http.StatusInternalServerError) 50 51 return 51 52 }
+29
appview/state/manifest.go
··· 1 + package state 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + ) 7 + 8 + // https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest 9 + // https://www.w3.org/TR/appmanifest/ 10 + var manifestData = map[string]any{ 11 + "name": "tangled", 12 + "description": "tightly-knit social coding.", 13 + "icons": []map[string]string{ 14 + { 15 + "src": "/static/logos/dolly.svg", 16 + "sizes": "144x144", 17 + }, 18 + }, 19 + "start_url": "/", 20 + "id": "https://tangled.org", 21 + "display": "standalone", 22 + "background_color": "#111827", 23 + "theme_color": "#111827", 24 + } 25 + 26 + func (p *State) WebAppManifest(w http.ResponseWriter, r *http.Request) { 27 + w.Header().Set("Content-Type", "application/manifest+json") 28 + json.NewEncoder(w).Encode(manifestData) 29 + }
+32 -21
appview/state/profile.go
··· 19 19 "tangled.org/core/appview/db" 20 20 "tangled.org/core/appview/models" 21 21 "tangled.org/core/appview/pages" 22 + "tangled.org/core/orm" 22 23 ) 23 24 24 25 func (s *State) Profile(w http.ResponseWriter, r *http.Request) { ··· 56 57 return nil, fmt.Errorf("failed to get profile: %w", err) 57 58 } 58 59 59 - repoCount, err := db.CountRepos(s.db, db.FilterEq("did", did)) 60 + repoCount, err := db.CountRepos(s.db, orm.FilterEq("did", did)) 60 61 if err != nil { 61 62 return nil, fmt.Errorf("failed to get repo count: %w", err) 62 63 } 63 64 64 - stringCount, err := db.CountStrings(s.db, db.FilterEq("did", did)) 65 + stringCount, err := db.CountStrings(s.db, orm.FilterEq("did", did)) 65 66 if err != nil { 66 67 return nil, fmt.Errorf("failed to get string count: %w", err) 67 68 } 68 69 69 - starredCount, err := db.CountStars(s.db, db.FilterEq("starred_by_did", did)) 70 + starredCount, err := db.CountStars(s.db, orm.FilterEq("did", did)) 70 71 if err != nil { 71 72 return nil, fmt.Errorf("failed to get starred repo count: %w", err) 72 73 } ··· 86 87 startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC) 87 88 punchcard, err := db.MakePunchcard( 88 89 s.db, 89 - db.FilterEq("did", did), 90 - db.FilterGte("date", startOfYear.Format(time.DateOnly)), 91 - db.FilterLte("date", now.Format(time.DateOnly)), 90 + orm.FilterEq("did", did), 91 + orm.FilterGte("date", startOfYear.Format(time.DateOnly)), 92 + orm.FilterLte("date", now.Format(time.DateOnly)), 92 93 ) 93 94 if err != nil { 94 95 return nil, fmt.Errorf("failed to get punchcard for %s: %w", did, err) ··· 96 97 97 98 return &pages.ProfileCard{ 98 99 UserDid: did, 99 - UserHandle: ident.Handle.String(), 100 100 Profile: profile, 101 101 FollowStatus: followStatus, 102 102 Stats: pages.ProfileStats{ ··· 119 119 s.pages.Error500(w) 120 120 return 121 121 } 122 - l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 122 + l = l.With("profileDid", profile.UserDid) 123 123 124 124 repos, err := db.GetRepos( 125 125 s.db, 126 126 0, 127 - db.FilterEq("did", profile.UserDid), 127 + orm.FilterEq("did", profile.UserDid), 128 128 ) 129 129 if err != nil { 130 130 l.Error("failed to fetch repos", "err", err) ··· 162 162 l.Error("failed to create timeline", "err", err) 163 163 } 164 164 165 + // populate commit counts in the timeline, using the punchcard 166 + now := time.Now() 167 + for _, p := range profile.Punchcard.Punches { 168 + years := now.Year() - p.Date.Year() 169 + months := int(now.Month() - p.Date.Month()) 170 + monthsAgo := years*12 + months 171 + if monthsAgo >= 0 && monthsAgo < len(timeline.ByMonth) { 172 + timeline.ByMonth[monthsAgo].Commits += p.Count 173 + } 174 + } 175 + 165 176 s.pages.ProfileOverview(w, pages.ProfileOverviewParams{ 166 177 LoggedInUser: s.oauth.GetUser(r), 167 178 Card: profile, ··· 180 191 s.pages.Error500(w) 181 192 return 182 193 } 183 - l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 194 + l = l.With("profileDid", profile.UserDid) 184 195 185 196 repos, err := db.GetRepos( 186 197 s.db, 187 198 0, 188 - db.FilterEq("did", profile.UserDid), 199 + orm.FilterEq("did", profile.UserDid), 189 200 ) 190 201 if err != nil { 191 202 l.Error("failed to get repos", "err", err) ··· 209 220 s.pages.Error500(w) 210 221 return 211 222 } 212 - l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 223 + l = l.With("profileDid", profile.UserDid) 213 224 214 - stars, err := db.GetStars(s.db, 0, db.FilterEq("starred_by_did", profile.UserDid)) 225 + stars, err := db.GetRepoStars(s.db, 0, orm.FilterEq("did", profile.UserDid)) 215 226 if err != nil { 216 227 l.Error("failed to get stars", "err", err) 217 228 s.pages.Error500(w) ··· 219 230 } 220 231 var repos []models.Repo 221 232 for _, s := range stars { 222 - if s.Repo != nil { 223 - repos = append(repos, *s.Repo) 224 - } 233 + repos = append(repos, *s.Repo) 225 234 } 226 235 227 236 err = s.pages.ProfileStarred(w, pages.ProfileStarredParams{ ··· 240 249 s.pages.Error500(w) 241 250 return 242 251 } 243 - l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 252 + l = l.With("profileDid", profile.UserDid) 244 253 245 - strings, err := db.GetStrings(s.db, 0, db.FilterEq("did", profile.UserDid)) 254 + strings, err := db.GetStrings(s.db, 0, orm.FilterEq("did", profile.UserDid)) 246 255 if err != nil { 247 256 l.Error("failed to get strings", "err", err) 248 257 s.pages.Error500(w) ··· 272 281 if err != nil { 273 282 return nil, err 274 283 } 275 - l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 284 + l = l.With("profileDid", profile.UserDid) 276 285 277 286 loggedInUser := s.oauth.GetUser(r) 278 287 params := FollowsPageParams{ ··· 294 303 followDids = append(followDids, extractDid(follow)) 295 304 } 296 305 297 - profiles, err := db.GetProfiles(s.db, db.FilterIn("did", followDids)) 306 + profiles, err := db.GetProfiles(s.db, orm.FilterIn("did", followDids)) 298 307 if err != nil { 299 308 l.Error("failed to get profiles", "followDids", followDids, "err", err) 300 309 return &params, err ··· 538 547 profile.Description = r.FormValue("description") 539 548 profile.IncludeBluesky = r.FormValue("includeBluesky") == "on" 540 549 profile.Location = r.FormValue("location") 550 + profile.Pronouns = r.FormValue("pronouns") 541 551 542 552 var links [5]string 543 553 for i := range 5 { ··· 652 662 Location: &profile.Location, 653 663 PinnedRepositories: pinnedRepoStrings, 654 664 Stats: vanityStats[:], 665 + Pronouns: &profile.Pronouns, 655 666 }}, 656 667 SwapRecord: cid, 657 668 }) ··· 695 706 log.Printf("getting profile data for %s: %s", user.Did, err) 696 707 } 697 708 698 - repos, err := db.GetRepos(s.db, 0, db.FilterEq("did", user.Did)) 709 + repos, err := db.GetRepos(s.db, 0, orm.FilterEq("did", user.Did)) 699 710 if err != nil { 700 711 log.Printf("getting repos for %s: %s", user.Did, err) 701 712 }
+54 -42
appview/state/router.go
··· 32 32 s.pages, 33 33 ) 34 34 35 - router.Get("/favicon.svg", s.Favicon) 36 - router.Get("/favicon.ico", s.Favicon) 37 - router.Get("/pwa-manifest.json", s.PWAManifest) 35 + router.Get("/pwa-manifest.json", s.WebAppManifest) 38 36 router.Get("/robots.txt", s.RobotsTxt) 39 37 40 38 userRouter := s.UserRouter(&middleware) ··· 42 40 43 41 router.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) { 44 42 pat := chi.URLParam(r, "*") 45 - if strings.HasPrefix(pat, "did:") || strings.HasPrefix(pat, "@") { 46 - userRouter.ServeHTTP(w, r) 47 - } else { 48 - // Check if the first path element is a valid handle without '@' or a flattened DID 49 - pathParts := strings.SplitN(pat, "/", 2) 50 - if len(pathParts) > 0 { 51 - if userutil.IsHandleNoAt(pathParts[0]) { 52 - // Redirect to the same path but with '@' prefixed to the handle 53 - redirectPath := "@" + pat 54 - http.Redirect(w, r, "/"+redirectPath, http.StatusFound) 55 - return 56 - } else if userutil.IsFlattenedDid(pathParts[0]) { 57 - // Redirect to the unflattened DID version 58 - unflattenedDid := userutil.UnflattenDid(pathParts[0]) 59 - var redirectPath string 60 - if len(pathParts) > 1 { 61 - redirectPath = unflattenedDid + "/" + pathParts[1] 62 - } else { 63 - redirectPath = unflattenedDid 64 - } 65 - http.Redirect(w, r, "/"+redirectPath, http.StatusFound) 66 - return 67 - } 43 + pathParts := strings.SplitN(pat, "/", 2) 44 + 45 + if len(pathParts) > 0 { 46 + firstPart := pathParts[0] 47 + 48 + // if using a DID or handle, just continue as per usual 49 + if userutil.IsDid(firstPart) || userutil.IsHandle(firstPart) { 50 + userRouter.ServeHTTP(w, r) 51 + return 68 52 } 69 - standardRouter.ServeHTTP(w, r) 53 + 54 + // if using a flattened DID (like you would in go modules), unflatten 55 + if userutil.IsFlattenedDid(firstPart) { 56 + unflattenedDid := userutil.UnflattenDid(firstPart) 57 + redirectPath := strings.Join(append([]string{unflattenedDid}, pathParts[1:]...), "/") 58 + 59 + redirectURL := *r.URL 60 + redirectURL.Path = "/" + redirectPath 61 + 62 + http.Redirect(w, r, redirectURL.String(), http.StatusFound) 63 + return 64 + } 65 + 66 + // if using a handle with @, rewrite to work without @ 67 + if normalized := strings.TrimPrefix(firstPart, "@"); userutil.IsHandle(normalized) { 68 + redirectPath := strings.Join(append([]string{normalized}, pathParts[1:]...), "/") 69 + 70 + redirectURL := *r.URL 71 + redirectURL.Path = "/" + redirectPath 72 + 73 + http.Redirect(w, r, redirectURL.String(), http.StatusFound) 74 + return 75 + } 76 + 70 77 } 78 + 79 + standardRouter.ServeHTTP(w, r) 71 80 }) 72 81 73 82 return router ··· 80 89 r.Get("/", s.Profile) 81 90 r.Get("/feed.atom", s.AtomFeedPage) 82 91 83 - // redirect /@handle/repo.git -> /@handle/repo 84 - r.Get("/{repo}.git", func(w http.ResponseWriter, r *http.Request) { 85 - nonDotGitPath := strings.TrimSuffix(r.URL.Path, ".git") 86 - http.Redirect(w, r, nonDotGitPath, http.StatusMovedPermanently) 87 - }) 88 - 89 92 r.With(mw.ResolveRepo()).Route("/{repo}", func(r chi.Router) { 90 93 r.Use(mw.GoImport()) 91 94 r.Mount("/", s.RepoRouter(mw)) 92 95 r.Mount("/issues", s.IssuesRouter(mw)) 93 96 r.Mount("/pulls", s.PullsRouter(mw)) 94 - r.Mount("/pipelines", s.PipelinesRouter(mw)) 95 - r.Mount("/labels", s.LabelsRouter(mw)) 97 + r.Mount("/pipelines", s.PipelinesRouter()) 98 + r.Mount("/labels", s.LabelsRouter()) 96 99 97 100 // These routes get proxied to the knot 98 101 r.Get("/info/refs", s.InfoRefs) 102 + r.Post("/git-upload-archive", s.UploadArchive) 99 103 r.Post("/git-upload-pack", s.UploadPack) 100 104 r.Post("/git-receive-pack", s.ReceivePack) 101 105 ··· 103 107 }) 104 108 105 109 r.NotFound(func(w http.ResponseWriter, r *http.Request) { 110 + w.WriteHeader(http.StatusNotFound) 106 111 s.pages.Error404(w) 107 112 }) 108 113 ··· 134 139 // r.Post("/import", s.ImportRepo) 135 140 }) 136 141 137 - r.Get("/goodfirstissues", s.GoodFirstIssues) 142 + r.With(middleware.Paginate).Get("/goodfirstissues", s.GoodFirstIssues) 138 143 139 144 r.With(middleware.AuthMiddleware(s.oauth)).Route("/follow", func(r chi.Router) { 140 145 r.Post("/", s.Follow) ··· 161 166 162 167 r.Mount("/settings", s.SettingsRouter()) 163 168 r.Mount("/strings", s.StringsRouter(mw)) 164 - r.Mount("/knots", s.KnotsRouter()) 165 - r.Mount("/spindles", s.SpindlesRouter()) 169 + 170 + r.Mount("/settings/knots", s.KnotsRouter()) 171 + r.Mount("/settings/spindles", s.SpindlesRouter()) 172 + 166 173 r.Mount("/notifications", s.NotificationsRouter(mw)) 167 174 168 175 r.Mount("/signup", s.SignupRouter()) ··· 174 181 r.Get("/brand", s.Brand) 175 182 176 183 r.NotFound(func(w http.ResponseWriter, r *http.Request) { 184 + w.WriteHeader(http.StatusNotFound) 177 185 s.pages.Error404(w) 178 186 }) 179 187 return r ··· 256 264 issues := issues.New( 257 265 s.oauth, 258 266 s.repoResolver, 267 + s.enforcer, 259 268 s.pages, 260 269 s.idResolver, 270 + s.mentionsResolver, 261 271 s.db, 262 272 s.config, 263 273 s.notifier, ··· 274 284 s.repoResolver, 275 285 s.pages, 276 286 s.idResolver, 287 + s.mentionsResolver, 277 288 s.db, 278 289 s.config, 279 290 s.notifier, 280 291 s.enforcer, 281 292 s.validator, 293 + s.indexer.Pulls, 282 294 log.SubLogger(s.logger, "pulls"), 283 295 ) 284 296 return pulls.Router(mw) ··· 301 313 return repo.Router(mw) 302 314 } 303 315 304 - func (s *State) PipelinesRouter(mw *middleware.Middleware) http.Handler { 316 + func (s *State) PipelinesRouter() http.Handler { 305 317 pipes := pipelines.New( 306 318 s.oauth, 307 319 s.repoResolver, ··· 313 325 s.enforcer, 314 326 log.SubLogger(s.logger, "pipelines"), 315 327 ) 316 - return pipes.Router(mw) 328 + return pipes.Router() 317 329 } 318 330 319 - func (s *State) LabelsRouter(mw *middleware.Middleware) http.Handler { 331 + func (s *State) LabelsRouter() http.Handler { 320 332 ls := labels.New( 321 333 s.oauth, 322 334 s.pages, ··· 325 337 s.enforcer, 326 338 log.SubLogger(s.logger, "labels"), 327 339 ) 328 - return ls.Router(mw) 340 + return ls.Router() 329 341 } 330 342 331 343 func (s *State) NotificationsRouter(mw *middleware.Middleware) http.Handler {
+2 -1
appview/state/spindlestream.go
··· 17 17 ec "tangled.org/core/eventconsumer" 18 18 "tangled.org/core/eventconsumer/cursor" 19 19 "tangled.org/core/log" 20 + "tangled.org/core/orm" 20 21 "tangled.org/core/rbac" 21 22 spindle "tangled.org/core/spindle/models" 22 23 ) ··· 27 28 28 29 spindles, err := db.GetSpindles( 29 30 d, 30 - db.FilterIsNot("verified", "null"), 31 + orm.FilterIsNot("verified", "null"), 31 32 ) 32 33 if err != nil { 33 34 return nil, err
+9 -13
appview/state/star.go
··· 57 57 log.Println("created atproto record: ", resp.Uri) 58 58 59 59 star := &models.Star{ 60 - StarredByDid: currentUser.Did, 61 - RepoAt: subjectUri, 62 - Rkey: rkey, 60 + Did: currentUser.Did, 61 + RepoAt: subjectUri, 62 + Rkey: rkey, 63 63 } 64 64 65 65 err = db.AddStar(s.db, star) ··· 75 75 76 76 s.notifier.NewStar(r.Context(), star) 77 77 78 - s.pages.RepoStarFragment(w, pages.RepoStarFragmentParams{ 78 + s.pages.StarBtnFragment(w, pages.StarBtnFragmentParams{ 79 79 IsStarred: true, 80 - RepoAt: subjectUri, 81 - Stats: models.RepoStats{ 82 - StarCount: starCount, 83 - }, 80 + SubjectAt: subjectUri, 81 + StarCount: starCount, 84 82 }) 85 83 86 84 return ··· 117 115 118 116 s.notifier.DeleteStar(r.Context(), star) 119 117 120 - s.pages.RepoStarFragment(w, pages.RepoStarFragmentParams{ 118 + s.pages.StarBtnFragment(w, pages.StarBtnFragmentParams{ 121 119 IsStarred: false, 122 - RepoAt: subjectUri, 123 - Stats: models.RepoStats{ 124 - StarCount: starCount, 125 - }, 120 + SubjectAt: subjectUri, 121 + StarCount: starCount, 126 122 }) 127 123 128 124 return
+41 -71
appview/state/state.go
··· 15 15 "tangled.org/core/appview/config" 16 16 "tangled.org/core/appview/db" 17 17 "tangled.org/core/appview/indexer" 18 + "tangled.org/core/appview/mentions" 18 19 "tangled.org/core/appview/models" 19 20 "tangled.org/core/appview/notify" 20 21 dbnotify "tangled.org/core/appview/notify/db" ··· 29 30 "tangled.org/core/jetstream" 30 31 "tangled.org/core/log" 31 32 tlog "tangled.org/core/log" 33 + "tangled.org/core/orm" 32 34 "tangled.org/core/rbac" 33 35 "tangled.org/core/tid" 34 36 ··· 42 44 ) 43 45 44 46 type State struct { 45 - db *db.DB 46 - notifier notify.Notifier 47 - indexer *indexer.Indexer 48 - oauth *oauth.OAuth 49 - enforcer *rbac.Enforcer 50 - pages *pages.Pages 51 - idResolver *idresolver.Resolver 52 - posthog posthog.Client 53 - jc *jetstream.JetstreamClient 54 - config *config.Config 55 - repoResolver *reporesolver.RepoResolver 56 - knotstream *eventconsumer.Consumer 57 - spindlestream *eventconsumer.Consumer 58 - logger *slog.Logger 59 - validator *validator.Validator 47 + db *db.DB 48 + notifier notify.Notifier 49 + indexer *indexer.Indexer 50 + oauth *oauth.OAuth 51 + enforcer *rbac.Enforcer 52 + pages *pages.Pages 53 + idResolver *idresolver.Resolver 54 + mentionsResolver *mentions.Resolver 55 + posthog posthog.Client 56 + jc *jetstream.JetstreamClient 57 + config *config.Config 58 + repoResolver *reporesolver.RepoResolver 59 + knotstream *eventconsumer.Consumer 60 + spindlestream *eventconsumer.Consumer 61 + logger *slog.Logger 62 + validator *validator.Validator 60 63 } 61 64 62 65 func Make(ctx context.Context, config *config.Config) (*State, error) { ··· 78 81 return nil, fmt.Errorf("failed to create enforcer: %w", err) 79 82 } 80 83 81 - res, err := idresolver.RedisResolver(config.Redis.ToURL()) 84 + res, err := idresolver.RedisResolver(config.Redis.ToURL(), config.Plc.PLCURL) 82 85 if err != nil { 83 86 logger.Error("failed to create redis resolver", "err", err) 84 - res = idresolver.DefaultResolver() 87 + res = idresolver.DefaultResolver(config.Plc.PLCURL) 85 88 } 86 89 87 90 posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint}) ··· 96 99 } 97 100 validator := validator.New(d, res, enforcer) 98 101 99 - repoResolver := reporesolver.New(config, enforcer, res, d) 102 + repoResolver := reporesolver.New(config, enforcer, d) 103 + 104 + mentionsResolver := mentions.New(config, res, d, log.SubLogger(logger, "mentionsResolver")) 100 105 101 106 wrapper := db.DbWrapper{Execer: d} 102 107 jc, err := jetstream.NewJetstreamClient( ··· 129 134 return nil, fmt.Errorf("failed to create jetstream client: %w", err) 130 135 } 131 136 132 - if err := BackfillDefaultDefs(d, res); err != nil { 137 + if err := BackfillDefaultDefs(d, res, config.Label.DefaultLabelDefs); err != nil { 133 138 return nil, fmt.Errorf("failed to backfill default label defs: %w", err) 134 139 } 135 140 ··· 168 173 notifiers = append(notifiers, phnotify.NewPosthogNotifier(posthog)) 169 174 } 170 175 notifiers = append(notifiers, indexer) 171 - notifier := notify.NewMergedNotifier(notifiers...) 176 + notifier := notify.NewMergedNotifier(notifiers, tlog.SubLogger(logger, "notify")) 172 177 173 178 state := &State{ 174 179 d, ··· 178 183 enforcer, 179 184 pages, 180 185 res, 186 + mentionsResolver, 181 187 posthog, 182 188 jc, 183 189 config, ··· 196 202 return s.db.Close() 197 203 } 198 204 199 - func (s *State) Favicon(w http.ResponseWriter, r *http.Request) { 200 - w.Header().Set("Content-Type", "image/svg+xml") 201 - w.Header().Set("Cache-Control", "public, max-age=31536000") // one year 202 - w.Header().Set("ETag", `"favicon-svg-v1"`) 203 - 204 - if match := r.Header.Get("If-None-Match"); match == `"favicon-svg-v1"` { 205 - w.WriteHeader(http.StatusNotModified) 206 - return 207 - } 208 - 209 - s.pages.Favicon(w) 210 - } 211 - 212 205 func (s *State) RobotsTxt(w http.ResponseWriter, r *http.Request) { 213 206 w.Header().Set("Content-Type", "text/plain") 214 207 w.Header().Set("Cache-Control", "public, max-age=86400") // one day ··· 217 210 Allow: / 218 211 ` 219 212 w.Write([]byte(robotsTxt)) 220 - } 221 - 222 - // https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest 223 - const manifestJson = `{ 224 - "name": "tangled", 225 - "description": "tightly-knit social coding.", 226 - "icons": [ 227 - { 228 - "src": "/favicon.svg", 229 - "sizes": "144x144" 230 - } 231 - ], 232 - "start_url": "/", 233 - "id": "org.tangled", 234 - 235 - "display": "standalone", 236 - "background_color": "#111827", 237 - "theme_color": "#111827" 238 - }` 239 - 240 - func (p *State) PWAManifest(w http.ResponseWriter, r *http.Request) { 241 - w.Header().Set("Content-Type", "application/json") 242 - w.Write([]byte(manifestJson)) 243 213 } 244 214 245 215 func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) { ··· 294 264 return 295 265 } 296 266 297 - gfiLabel, err := db.GetLabelDefinition(s.db, db.FilterEq("at_uri", models.LabelGoodFirstIssue)) 267 + gfiLabel, err := db.GetLabelDefinition(s.db, orm.FilterEq("at_uri", s.config.Label.GoodFirstIssue)) 298 268 if err != nil { 299 269 // non-fatal 300 270 } ··· 318 288 319 289 regs, err := db.GetRegistrations( 320 290 s.db, 321 - db.FilterEq("did", user.Did), 322 - db.FilterEq("needs_upgrade", 1), 291 + orm.FilterEq("did", user.Did), 292 + orm.FilterEq("needs_upgrade", 1), 323 293 ) 324 294 if err != nil { 325 295 l.Error("non-fatal: failed to get registrations", "err", err) ··· 327 297 328 298 spindles, err := db.GetSpindles( 329 299 s.db, 330 - db.FilterEq("owner", user.Did), 331 - db.FilterEq("needs_upgrade", 1), 300 + orm.FilterEq("owner", user.Did), 301 + orm.FilterEq("needs_upgrade", 1), 332 302 ) 333 303 if err != nil { 334 304 l.Error("non-fatal: failed to get spindles", "err", err) ··· 386 356 387 357 pubKeys, err := db.GetPublicKeysForDid(s.db, id.DID.String()) 388 358 if err != nil { 389 - w.WriteHeader(http.StatusNotFound) 359 + s.logger.Error("failed to get public keys", "err", err) 360 + http.Error(w, "failed to get public keys", http.StatusInternalServerError) 390 361 return 391 362 } 392 363 393 364 if len(pubKeys) == 0 { 394 - w.WriteHeader(http.StatusNotFound) 365 + w.WriteHeader(http.StatusNoContent) 395 366 return 396 367 } 397 368 ··· 498 469 // Check for existing repos 499 470 existingRepo, err := db.GetRepo( 500 471 s.db, 501 - db.FilterEq("did", user.Did), 502 - db.FilterEq("name", repoName), 472 + orm.FilterEq("did", user.Did), 473 + orm.FilterEq("name", repoName), 503 474 ) 504 475 if err == nil && existingRepo != nil { 505 476 l.Info("repo exists") ··· 516 487 Rkey: rkey, 517 488 Description: description, 518 489 Created: time.Now(), 519 - Labels: models.DefaultLabelDefs(), 490 + Labels: s.config.Label.DefaultLabelDefs, 520 491 } 521 492 record := repo.AsRecord() 522 493 ··· 632 603 aturi = "" 633 604 634 605 s.notifier.NewRepo(r.Context(), repo) 635 - s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Did, repoName)) 606 + s.pages.HxLocation(w, fmt.Sprintf("/%s/%s", user.Did, repoName)) 636 607 } 637 608 } 638 609 ··· 658 629 return err 659 630 } 660 631 661 - func BackfillDefaultDefs(e db.Execer, r *idresolver.Resolver) error { 662 - defaults := models.DefaultLabelDefs() 663 - defaultLabels, err := db.GetLabelDefinitions(e, db.FilterIn("at_uri", defaults)) 632 + func BackfillDefaultDefs(e db.Execer, r *idresolver.Resolver, defaults []string) error { 633 + defaultLabels, err := db.GetLabelDefinitions(e, orm.FilterIn("at_uri", defaults)) 664 634 if err != nil { 665 635 return err 666 636 } ··· 669 639 return nil 670 640 } 671 641 672 - labelDefs, err := models.FetchDefaultDefs(r) 642 + labelDefs, err := models.FetchLabelDefs(r, defaults) 673 643 if err != nil { 674 644 return err 675 645 }
+6 -6
appview/state/userutil/userutil.go
··· 10 10 didRegex = regexp.MustCompile(`^did:[a-z]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]$`) 11 11 ) 12 12 13 - func IsHandleNoAt(s string) bool { 13 + func IsHandle(s string) bool { 14 14 // ref: https://atproto.com/specs/handle 15 15 return handleRegex.MatchString(s) 16 + } 17 + 18 + // IsDid checks if the given string is a standard DID. 19 + func IsDid(s string) bool { 20 + return didRegex.MatchString(s) 16 21 } 17 22 18 23 func UnflattenDid(s string) string { ··· 45 50 return strings.Replace(s, ":", "-", 2) 46 51 } 47 52 return s 48 - } 49 - 50 - // IsDid checks if the given string is a standard DID. 51 - func IsDid(s string) bool { 52 - return didRegex.MatchString(s) 53 53 } 54 54 55 55 var subdomainRegex = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{2,61}[a-z0-9])?$`)
+21 -8
appview/strings/strings.go
··· 17 17 "tangled.org/core/appview/pages" 18 18 "tangled.org/core/appview/pages/markup" 19 19 "tangled.org/core/idresolver" 20 + "tangled.org/core/orm" 20 21 "tangled.org/core/tid" 21 22 22 23 "github.com/bluesky-social/indigo/api/atproto" ··· 108 109 strings, err := db.GetStrings( 109 110 s.Db, 110 111 0, 111 - db.FilterEq("did", id.DID), 112 - db.FilterEq("rkey", rkey), 112 + orm.FilterEq("did", id.DID), 113 + orm.FilterEq("rkey", rkey), 113 114 ) 114 115 if err != nil { 115 116 l.Error("failed to fetch string", "err", err) ··· 148 149 showRendered = r.URL.Query().Get("code") != "true" 149 150 } 150 151 152 + starCount, err := db.GetStarCount(s.Db, string.AtUri()) 153 + if err != nil { 154 + l.Error("failed to get star count", "err", err) 155 + } 156 + user := s.OAuth.GetUser(r) 157 + isStarred := false 158 + if user != nil { 159 + isStarred = db.GetStarStatus(s.Db, user.Did, string.AtUri()) 160 + } 161 + 151 162 s.Pages.SingleString(w, pages.SingleStringParams{ 152 - LoggedInUser: s.OAuth.GetUser(r), 163 + LoggedInUser: user, 153 164 RenderToggle: renderToggle, 154 165 ShowRendered: showRendered, 155 - String: string, 166 + String: &string, 156 167 Stats: string.Stats(), 168 + IsStarred: isStarred, 169 + StarCount: starCount, 157 170 Owner: id, 158 171 }) 159 172 } ··· 187 200 all, err := db.GetStrings( 188 201 s.Db, 189 202 0, 190 - db.FilterEq("did", id.DID), 191 - db.FilterEq("rkey", rkey), 203 + orm.FilterEq("did", id.DID), 204 + orm.FilterEq("rkey", rkey), 192 205 ) 193 206 if err != nil { 194 207 l.Error("failed to fetch string", "err", err) ··· 396 409 397 410 if err := db.DeleteString( 398 411 s.Db, 399 - db.FilterEq("did", user.Did), 400 - db.FilterEq("rkey", rkey), 412 + orm.FilterEq("did", user.Did), 413 + orm.FilterEq("rkey", rkey), 401 414 ); err != nil { 402 415 fail("Failed to delete string.", err) 403 416 return
+2 -1
appview/validator/issue.go
··· 6 6 7 7 "tangled.org/core/appview/db" 8 8 "tangled.org/core/appview/models" 9 + "tangled.org/core/orm" 9 10 ) 10 11 11 12 func (v *Validator) ValidateIssueComment(comment *models.IssueComment) error { 12 13 // if comments have parents, only ingest ones that are 1 level deep 13 14 if comment.ReplyTo != nil { 14 - parents, err := db.GetIssueComments(v.db, db.FilterEq("at_uri", *comment.ReplyTo)) 15 + parents, err := db.GetIssueComments(v.db, orm.FilterEq("at_uri", *comment.ReplyTo)) 15 16 if err != nil { 16 17 return fmt.Errorf("failed to fetch parent comment: %w", err) 17 18 }
+53
appview/validator/repo_topics.go
··· 1 + package validator 2 + 3 + import ( 4 + "fmt" 5 + "maps" 6 + "regexp" 7 + "slices" 8 + "strings" 9 + ) 10 + 11 + const ( 12 + maxTopicLen = 50 13 + maxTopics = 20 14 + ) 15 + 16 + var ( 17 + topicRE = regexp.MustCompile(`\A[a-z0-9-]+\z`) 18 + ) 19 + 20 + // ValidateRepoTopicStr parses and validates whitespace-separated topic string. 21 + // 22 + // Rules: 23 + // - topics are separated by whitespace 24 + // - each topic may contain lowercase letters, digits, and hyphens only 25 + // - each topic must be <= 50 characters long 26 + // - no more than 20 topics allowed 27 + // - duplicates are removed 28 + func (v *Validator) ValidateRepoTopicStr(topicsStr string) ([]string, error) { 29 + topicsStr = strings.TrimSpace(topicsStr) 30 + if topicsStr == "" { 31 + return nil, nil 32 + } 33 + parts := strings.Fields(topicsStr) 34 + if len(parts) > maxTopics { 35 + return nil, fmt.Errorf("too many topics: %d (maximum %d)", len(parts), maxTopics) 36 + } 37 + 38 + topicSet := make(map[string]struct{}) 39 + 40 + for _, t := range parts { 41 + if _, exists := topicSet[t]; exists { 42 + continue 43 + } 44 + if len(t) > maxTopicLen { 45 + return nil, fmt.Errorf("topic '%s' is too long (maximum %d characters)", t, maxTopics) 46 + } 47 + if !topicRE.MatchString(t) { 48 + return nil, fmt.Errorf("topic '%s' contains invalid characters (allowed: lowercase letters, digits, hyphens)", t) 49 + } 50 + topicSet[t] = struct{}{} 51 + } 52 + return slices.Collect(maps.Keys(topicSet)), nil 53 + }
+17
appview/validator/uri.go
··· 1 + package validator 2 + 3 + import ( 4 + "fmt" 5 + "net/url" 6 + ) 7 + 8 + func (v *Validator) ValidateURI(uri string) error { 9 + parsed, err := url.Parse(uri) 10 + if err != nil { 11 + return fmt.Errorf("invalid uri format") 12 + } 13 + if parsed.Scheme == "" { 14 + return fmt.Errorf("uri scheme missing") 15 + } 16 + return nil 17 + }
+182
cmd/dolly/main.go
··· 1 + package main 2 + 3 + import ( 4 + "bytes" 5 + "flag" 6 + "fmt" 7 + "image" 8 + "image/color" 9 + "image/png" 10 + "os" 11 + "path/filepath" 12 + "strconv" 13 + "strings" 14 + "text/template" 15 + 16 + "github.com/srwiley/oksvg" 17 + "github.com/srwiley/rasterx" 18 + "golang.org/x/image/draw" 19 + "tangled.org/core/appview/pages" 20 + "tangled.org/core/ico" 21 + ) 22 + 23 + func main() { 24 + var ( 25 + size string 26 + fillColor string 27 + output string 28 + ) 29 + 30 + flag.StringVar(&size, "size", "512x512", "Output size in format WIDTHxHEIGHT (e.g., 512x512)") 31 + flag.StringVar(&fillColor, "color", "#000000", "Fill color in hex format (e.g., #FF5733)") 32 + flag.StringVar(&output, "output", "dolly.svg", "Output file path (format detected from extension: .svg, .png, or .ico)") 33 + flag.Parse() 34 + 35 + width, height, err := parseSize(size) 36 + if err != nil { 37 + fmt.Fprintf(os.Stderr, "Error parsing size: %v\n", err) 38 + os.Exit(1) 39 + } 40 + 41 + // Detect format from file extension 42 + ext := strings.ToLower(filepath.Ext(output)) 43 + format := strings.TrimPrefix(ext, ".") 44 + 45 + if format != "svg" && format != "png" && format != "ico" { 46 + fmt.Fprintf(os.Stderr, "Invalid file extension: %s. Must be .svg, .png, or .ico\n", ext) 47 + os.Exit(1) 48 + } 49 + 50 + if fillColor != "currentColor" && !isValidHexColor(fillColor) { 51 + fmt.Fprintf(os.Stderr, "Invalid color format: %s. Use hex format like #FF5733\n", fillColor) 52 + os.Exit(1) 53 + } 54 + 55 + svgData, err := dolly(fillColor) 56 + if err != nil { 57 + fmt.Fprintf(os.Stderr, "Error generating SVG: %v\n", err) 58 + os.Exit(1) 59 + } 60 + 61 + // Create output directory if it doesn't exist 62 + dir := filepath.Dir(output) 63 + if dir != "" && dir != "." { 64 + if err := os.MkdirAll(dir, 0755); err != nil { 65 + fmt.Fprintf(os.Stderr, "Error creating output directory: %v\n", err) 66 + os.Exit(1) 67 + } 68 + } 69 + 70 + switch format { 71 + case "svg": 72 + err = saveSVG(svgData, output, width, height) 73 + case "png": 74 + err = savePNG(svgData, output, width, height) 75 + case "ico": 76 + err = saveICO(svgData, output, width, height) 77 + } 78 + 79 + if err != nil { 80 + fmt.Fprintf(os.Stderr, "Error saving file: %v\n", err) 81 + os.Exit(1) 82 + } 83 + 84 + fmt.Printf("Successfully generated %s (%dx%d)\n", output, width, height) 85 + } 86 + 87 + func dolly(hexColor string) ([]byte, error) { 88 + tpl, err := template.New("dolly"). 89 + ParseFS(pages.Files, "templates/fragments/dolly/logo.html") 90 + if err != nil { 91 + return nil, err 92 + } 93 + 94 + var svgData bytes.Buffer 95 + if err := tpl.ExecuteTemplate(&svgData, "fragments/dolly/logo", pages.DollyParams{ 96 + FillColor: hexColor, 97 + }); err != nil { 98 + return nil, err 99 + } 100 + 101 + return svgData.Bytes(), nil 102 + } 103 + 104 + func svgToImage(svgData []byte, w, h int) (image.Image, error) { 105 + icon, err := oksvg.ReadIconStream(bytes.NewReader(svgData)) 106 + if err != nil { 107 + return nil, fmt.Errorf("error parsing SVG: %v", err) 108 + } 109 + 110 + icon.SetTarget(0, 0, float64(w), float64(h)) 111 + rgba := image.NewRGBA(image.Rect(0, 0, w, h)) 112 + draw.Draw(rgba, rgba.Bounds(), &image.Uniform{color.Transparent}, image.Point{}, draw.Src) 113 + scanner := rasterx.NewScannerGV(w, h, rgba, rgba.Bounds()) 114 + raster := rasterx.NewDasher(w, h, scanner) 115 + icon.Draw(raster, 1.0) 116 + 117 + return rgba, nil 118 + } 119 + 120 + func parseSize(size string) (int, int, error) { 121 + parts := strings.Split(size, "x") 122 + if len(parts) != 2 { 123 + return 0, 0, fmt.Errorf("invalid size format, use WIDTHxHEIGHT") 124 + } 125 + 126 + width, err := strconv.Atoi(parts[0]) 127 + if err != nil { 128 + return 0, 0, fmt.Errorf("invalid width: %v", err) 129 + } 130 + 131 + height, err := strconv.Atoi(parts[1]) 132 + if err != nil { 133 + return 0, 0, fmt.Errorf("invalid height: %v", err) 134 + } 135 + 136 + if width <= 0 || height <= 0 { 137 + return 0, 0, fmt.Errorf("width and height must be positive") 138 + } 139 + 140 + return width, height, nil 141 + } 142 + 143 + func isValidHexColor(hex string) bool { 144 + if len(hex) != 7 || hex[0] != '#' { 145 + return false 146 + } 147 + _, err := strconv.ParseUint(hex[1:], 16, 32) 148 + return err == nil 149 + } 150 + 151 + func saveSVG(svgData []byte, filepath string, _, _ int) error { 152 + return os.WriteFile(filepath, svgData, 0644) 153 + } 154 + 155 + func savePNG(svgData []byte, filepath string, width, height int) error { 156 + img, err := svgToImage(svgData, width, height) 157 + if err != nil { 158 + return err 159 + } 160 + 161 + f, err := os.Create(filepath) 162 + if err != nil { 163 + return err 164 + } 165 + defer f.Close() 166 + 167 + return png.Encode(f, img) 168 + } 169 + 170 + func saveICO(svgData []byte, filepath string, width, height int) error { 171 + img, err := svgToImage(svgData, width, height) 172 + if err != nil { 173 + return err 174 + } 175 + 176 + icoData, err := ico.ImageToIco(img) 177 + if err != nil { 178 + return err 179 + } 180 + 181 + return os.WriteFile(filepath, icoData, 0644) 182 + }
+1 -34
crypto/verify.go
··· 5 5 "crypto/sha256" 6 6 "encoding/base64" 7 7 "fmt" 8 - "strings" 9 8 10 9 "github.com/hiddeco/sshsig" 11 10 "golang.org/x/crypto/ssh" 12 - "tangled.org/core/types" 13 11 ) 14 12 15 13 func VerifySignature(pubKey, signature, payload []byte) (error, bool) { ··· 28 26 // multiple algorithms but sha-512 is most secure, and git's ssh signing defaults 29 27 // to sha-512 for all key types anyway. 30 28 err = sshsig.Verify(buf, sig, pub, sshsig.HashSHA512, "git") 31 - return err, err == nil 32 - } 33 29 34 - // VerifyCommitSignature reconstructs the payload used to sign a commit. This is 35 - // essentially the git cat-file output but without the gpgsig header. 36 - // 37 - // Caveats: signature verification will fail on commits with more than one parent, 38 - // i.e. merge commits, because types.NiceDiff doesn't carry more than one Parent field 39 - // and we are unable to reconstruct the payload correctly. 40 - // 41 - // Ideally this should directly operate on an *object.Commit. 42 - func VerifyCommitSignature(pubKey string, commit types.NiceDiff) (error, bool) { 43 - signature := commit.Commit.PGPSignature 44 - 45 - author := bytes.NewBuffer([]byte{}) 46 - committer := bytes.NewBuffer([]byte{}) 47 - commit.Commit.Author.Encode(author) 48 - commit.Commit.Committer.Encode(committer) 49 - 50 - payload := strings.Builder{} 51 - 52 - fmt.Fprintf(&payload, "tree %s\n", commit.Commit.Tree) 53 - if commit.Commit.Parent != "" { 54 - fmt.Fprintf(&payload, "parent %s\n", commit.Commit.Parent) 55 - } 56 - fmt.Fprintf(&payload, "author %s\n", author.String()) 57 - fmt.Fprintf(&payload, "committer %s\n", committer.String()) 58 - if commit.Commit.ChangedId != "" { 59 - fmt.Fprintf(&payload, "change-id %s\n", commit.Commit.ChangedId) 60 - } 61 - fmt.Fprintf(&payload, "\n%s", commit.Commit.Message) 62 - 63 - return VerifySignature([]byte(pubKey), []byte(signature), []byte(payload.String())) 30 + return err, err == nil 64 31 } 65 32 66 33 // SSHFingerprint computes the fingerprint of the supplied ssh pubkey.
+1527
docs/DOCS.md
··· 1 + --- 2 + title: Tangled docs 3 + author: The Tangled Contributors 4 + date: 21 Sun, Dec 2025 5 + abstract: | 6 + Tangled is a decentralized code hosting and collaboration 7 + platform. Every component of Tangled is open-source and 8 + self-hostable. [tangled.org](https://tangled.org) also 9 + provides hosting and CI services that are free to use. 10 + 11 + There are several models for decentralized code 12 + collaboration platforms, ranging from ActivityPubโ€™s 13 + (Forgejo) federated model, to Radicleโ€™s entirely P2P model. 14 + Our approach attempts to be the best of both worlds by 15 + adopting the AT Protocolโ€”a protocol for building decentralized 16 + social applications with a central identity 17 + 18 + Our approach to this is the idea of โ€œknotsโ€. Knots are 19 + lightweight, headless servers that enable users to host Git 20 + repositories with ease. Knots are designed for either single 21 + or multi-tenant use which is perfect for self-hosting on a 22 + Raspberry Pi at home, or larger โ€œcommunityโ€ servers. By 23 + default, Tangled provides managed knots where you can host 24 + your repositories for free. 25 + 26 + The appview at tangled.org acts as a consolidated "view" 27 + into the whole network, allowing users to access, clone and 28 + contribute to repositories hosted across different knots 29 + seamlessly. 30 + --- 31 + 32 + # Quick start guide 33 + 34 + ## Login or sign up 35 + 36 + You can [login](https://tangled.org) by using your AT Protocol 37 + account. If you are unclear on what that means, simply head 38 + to the [signup](https://tangled.org/signup) page and create 39 + an account. By doing so, you will be choosing Tangled as 40 + your account provider (you will be granted a handle of the 41 + form `user.tngl.sh`). 42 + 43 + In the AT Protocol network, users are free to choose their account 44 + provider (known as a "Personal Data Service", or PDS), and 45 + login to applications that support AT accounts. 46 + 47 + You can think of it as "one account for all of the atmosphere"! 48 + 49 + If you already have an AT account (you may have one if you 50 + signed up to Bluesky, for example), you can login with the 51 + same handle on Tangled (so just use `user.bsky.social` on 52 + the login page). 53 + 54 + ## Add an SSH key 55 + 56 + Once you are logged in, you can start creating repositories 57 + and pushing code. Tangled supports pushing git repositories 58 + over SSH. 59 + 60 + First, you'll need to generate an SSH key if you don't 61 + already have one: 62 + 63 + ```bash 64 + ssh-keygen -t ed25519 -C "foo@bar.com" 65 + ``` 66 + 67 + When prompted, save the key to the default location 68 + (`~/.ssh/id_ed25519`) and optionally set a passphrase. 69 + 70 + Copy your public key to your clipboard: 71 + 72 + ```bash 73 + # on X11 74 + cat ~/.ssh/id_ed25519.pub | xclip -sel c 75 + 76 + # on wayland 77 + cat ~/.ssh/id_ed25519.pub | wl-copy 78 + 79 + # on macos 80 + cat ~/.ssh/id_ed25519.pub | pbcopy 81 + ``` 82 + 83 + Now, navigate to 'Settings' -> 'Keys' and hit 'Add Key', 84 + paste your public key, give it a descriptive name, and hit 85 + save. 86 + 87 + ## Create a repository 88 + 89 + Once your SSH key is added, create your first repository: 90 + 91 + 1. Hit the green `+` icon on the topbar, and select 92 + repository 93 + 2. Enter a repository name 94 + 3. Add a description 95 + 4. Choose a knotserver to host this repository on 96 + 5. Hit create 97 + 98 + Knots are self-hostable, lightweight Git servers that can 99 + host your repository. Unlike traditional code forges, your 100 + code can live on any server. Read the [Knots](TODO) section 101 + for more. 102 + 103 + ## Configure SSH 104 + 105 + To ensure Git uses the correct SSH key and connects smoothly 106 + to Tangled, add this configuration to your `~/.ssh/config` 107 + file: 108 + 109 + ``` 110 + Host tangled.org 111 + Hostname tangled.org 112 + User git 113 + IdentityFile ~/.ssh/id_ed25519 114 + AddressFamily inet 115 + ``` 116 + 117 + This tells SSH to use your specific key when connecting to 118 + Tangled and prevents authentication issues if you have 119 + multiple SSH keys. 120 + 121 + Note that this configuration only works for knotservers that 122 + are hosted by tangled.org. If you use a custom knot, refer 123 + to the [Knots](TODO) section. 124 + 125 + ## Push your first repository 126 + 127 + Initialize a new Git repository: 128 + 129 + ```bash 130 + mkdir my-project 131 + cd my-project 132 + 133 + git init 134 + echo "# My Project" > README.md 135 + ``` 136 + 137 + Add some content and push! 138 + 139 + ```bash 140 + git add README.md 141 + git commit -m "Initial commit" 142 + git remote add origin git@tangled.org:user.tngl.sh/my-project 143 + git push -u origin main 144 + ``` 145 + 146 + That's it! Your code is now hosted on Tangled. 147 + 148 + ## Migrating an existing repository 149 + 150 + Moving your repositories from GitHub, GitLab, Bitbucket, or 151 + any other Git forge to Tangled is straightforward. You'll 152 + simply change your repository's remote URL. At the moment, 153 + Tangled does not have any tooling to migrate data such as 154 + GitHub issues or pull requests. 155 + 156 + First, create a new repository on tangled.org as described 157 + in the [Quick Start Guide](#create-a-repository). 158 + 159 + Navigate to your existing local repository: 160 + 161 + ```bash 162 + cd /path/to/your/existing/repo 163 + ``` 164 + 165 + You can inspect your existing Git remote like so: 166 + 167 + ```bash 168 + git remote -v 169 + ``` 170 + 171 + You'll see something like: 172 + 173 + ``` 174 + origin git@github.com:username/my-project (fetch) 175 + origin git@github.com:username/my-project (push) 176 + ``` 177 + 178 + Update the remote URL to point to tangled: 179 + 180 + ```bash 181 + git remote set-url origin git@tangled.org:user.tngl.sh/my-project 182 + ``` 183 + 184 + Verify the change: 185 + 186 + ```bash 187 + git remote -v 188 + ``` 189 + 190 + You should now see: 191 + 192 + ``` 193 + origin git@tangled.org:user.tngl.sh/my-project (fetch) 194 + origin git@tangled.org:user.tngl.sh/my-project (push) 195 + ``` 196 + 197 + Push all your branches and tags to Tangled: 198 + 199 + ```bash 200 + git push -u origin --all 201 + git push -u origin --tags 202 + ``` 203 + 204 + Your repository is now migrated to Tangled! All commit 205 + history, branches, and tags have been preserved. 206 + 207 + ## Mirroring a repository to Tangled 208 + 209 + If you want to maintain your repository on multiple forges 210 + simultaneously, for example, keeping your primary repository 211 + on GitHub while mirroring to Tangled for backup or 212 + redundancy, you can do so by adding multiple remotes. 213 + 214 + You can configure your local repository to push to both 215 + Tangled and, say, GitHub. You may already have the following 216 + setup: 217 + 218 + ``` 219 + $ git remote -v 220 + origin git@github.com:username/my-project (fetch) 221 + origin git@github.com:username/my-project (push) 222 + ``` 223 + 224 + Now add Tangled as an additional push URL to the same 225 + remote: 226 + 227 + ```bash 228 + git remote set-url --add --push origin git@tangled.org:user.tngl.sh/my-project 229 + ``` 230 + 231 + You also need to re-add the original URL as a push 232 + destination (Git replaces the push URL when you use `--add` 233 + the first time): 234 + 235 + ```bash 236 + git remote set-url --add --push origin git@github.com:username/my-project 237 + ``` 238 + 239 + Verify your configuration: 240 + 241 + ``` 242 + $ git remote -v 243 + origin git@github.com:username/repo (fetch) 244 + origin git@tangled.org:username/my-project (push) 245 + origin git@github.com:username/repo (push) 246 + ``` 247 + 248 + Notice that there's one fetch URL (the primary remote) and 249 + two push URLs. Now, whenever you push, Git will 250 + automatically push to both remotes: 251 + 252 + ```bash 253 + git push origin main 254 + ``` 255 + 256 + This single command pushes your `main` branch to both GitHub 257 + and Tangled simultaneously. 258 + 259 + To push all branches and tags: 260 + 261 + ```bash 262 + git push origin --all 263 + git push origin --tags 264 + ``` 265 + 266 + If you prefer more control over which remote you push to, 267 + you can maintain separate remotes: 268 + 269 + ```bash 270 + git remote add github git@github.com:username/my-project 271 + git remote add tangled git@tangled.org:username/my-project 272 + ``` 273 + 274 + Then push to each explicitly: 275 + 276 + ```bash 277 + git push github main 278 + git push tangled main 279 + ``` 280 + 281 + # Knot self-hosting guide 282 + 283 + So you want to run your own knot server? Great! Here are a few prerequisites: 284 + 285 + 1. A server of some kind (a VPS, a Raspberry Pi, etc.). Preferably running a Linux distribution of some kind. 286 + 2. A (sub)domain name. People generally use `knot.example.com`. 287 + 3. A valid SSL certificate for your domain. 288 + 289 + ## NixOS 290 + 291 + Refer to the [knot 292 + module](https://tangled.org/tangled.org/core/blob/master/nix/modules/knot.nix) 293 + for a full list of options. Sample configurations: 294 + 295 + - [The test VM](https://tangled.org/tangled.org/core/blob/master/nix/vm.nix#L85) 296 + - [@pyrox.dev/nix](https://tangled.org/pyrox.dev/nix/blob/d19571cc1b5fe01035e1e6951ec8cf8a476b4dee/hosts/marvin/services/tangled.nix#L15-25) 297 + 298 + ## Docker 299 + 300 + Refer to 301 + [@tangled.org/knot-docker](https://tangled.org/@tangled.org/knot-docker). 302 + Note that this is community maintained. 303 + 304 + ## Manual setup 305 + 306 + First, clone this repository: 307 + 308 + ``` 309 + git clone https://tangled.org/@tangled.org/core 310 + ``` 311 + 312 + Then, build the `knot` CLI. This is the knot administration 313 + and operation tool. For the purpose of this guide, we're 314 + only concerned with these subcommands: 315 + 316 + * `knot server`: the main knot server process, typically 317 + run as a supervised service 318 + * `knot guard`: handles role-based access control for git 319 + over SSH (you'll never have to run this yourself) 320 + * `knot keys`: fetches SSH keys associated with your knot; 321 + we'll use this to generate the SSH 322 + `AuthorizedKeysCommand` 323 + 324 + ``` 325 + cd core 326 + export CGO_ENABLED=1 327 + go build -o knot ./cmd/knot 328 + ``` 329 + 330 + Next, move the `knot` binary to a location owned by `root` -- 331 + `/usr/local/bin/` is a good choice. Make sure the binary itself is also owned by `root`: 332 + 333 + ``` 334 + sudo mv knot /usr/local/bin/knot 335 + sudo chown root:root /usr/local/bin/knot 336 + ``` 337 + 338 + This is necessary because SSH `AuthorizedKeysCommand` requires [really 339 + specific permissions](https://stackoverflow.com/a/27638306). The 340 + `AuthorizedKeysCommand` specifies a command that is run by `sshd` to 341 + retrieve a user's public SSH keys dynamically for authentication. Let's 342 + set that up. 343 + 344 + ``` 345 + sudo tee /etc/ssh/sshd_config.d/authorized_keys_command.conf <<EOF 346 + Match User git 347 + AuthorizedKeysCommand /usr/local/bin/knot keys -o authorized-keys 348 + AuthorizedKeysCommandUser nobody 349 + EOF 350 + ``` 351 + 352 + Then, reload `sshd`: 353 + 354 + ``` 355 + sudo systemctl reload ssh 356 + ``` 357 + 358 + Next, create the `git` user. We'll use the `git` user's home directory 359 + to store repositories: 360 + 361 + ``` 362 + sudo adduser git 363 + ``` 364 + 365 + Create `/home/git/.knot.env` with the following, updating the values as 366 + necessary. The `KNOT_SERVER_OWNER` should be set to your 367 + DID, you can find your DID in the [Settings](https://tangled.sh/settings) page. 368 + 369 + ``` 370 + KNOT_REPO_SCAN_PATH=/home/git 371 + KNOT_SERVER_HOSTNAME=knot.example.com 372 + APPVIEW_ENDPOINT=https://tangled.org 373 + KNOT_SERVER_OWNER=did:plc:foobar 374 + KNOT_SERVER_INTERNAL_LISTEN_ADDR=127.0.0.1:5444 375 + KNOT_SERVER_LISTEN_ADDR=127.0.0.1:5555 376 + ``` 377 + 378 + If you run a Linux distribution that uses systemd, you can use the provided 379 + service file to run the server. Copy 380 + [`knotserver.service`](/systemd/knotserver.service) 381 + to `/etc/systemd/system/`. Then, run: 382 + 383 + ``` 384 + systemctl enable knotserver 385 + systemctl start knotserver 386 + ``` 387 + 388 + The last step is to configure a reverse proxy like Nginx or Caddy to front your 389 + knot. Here's an example configuration for Nginx: 390 + 391 + ``` 392 + server { 393 + listen 80; 394 + listen [::]:80; 395 + server_name knot.example.com; 396 + 397 + location / { 398 + proxy_pass http://localhost:5555; 399 + proxy_set_header Host $host; 400 + proxy_set_header X-Real-IP $remote_addr; 401 + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 402 + proxy_set_header X-Forwarded-Proto $scheme; 403 + } 404 + 405 + # wss endpoint for git events 406 + location /events { 407 + proxy_set_header X-Forwarded-For $remote_addr; 408 + proxy_set_header Host $http_host; 409 + proxy_set_header Upgrade websocket; 410 + proxy_set_header Connection Upgrade; 411 + proxy_pass http://localhost:5555; 412 + } 413 + # additional config for SSL/TLS go here. 414 + } 415 + 416 + ``` 417 + 418 + Remember to use Let's Encrypt or similar to procure a certificate for your 419 + knot domain. 420 + 421 + You should now have a running knot server! You can finalize 422 + your registration by hitting the `verify` button on the 423 + [/settings/knots](https://tangled.org/settings/knots) page. This simply creates 424 + a record on your PDS to announce the existence of the knot. 425 + 426 + ### Custom paths 427 + 428 + (This section applies to manual setup only. Docker users should edit the mounts 429 + in `docker-compose.yml` instead.) 430 + 431 + Right now, the database and repositories of your knot lives in `/home/git`. You 432 + can move these paths if you'd like to store them in another folder. Be careful 433 + when adjusting these paths: 434 + 435 + * Stop your knot when moving data (e.g. `systemctl stop knotserver`) to prevent 436 + any possible side effects. Remember to restart it once you're done. 437 + * Make backups before moving in case something goes wrong. 438 + * Make sure the `git` user can read and write from the new paths. 439 + 440 + #### Database 441 + 442 + As an example, let's say the current database is at `/home/git/knotserver.db`, 443 + and we want to move it to `/home/git/database/knotserver.db`. 444 + 445 + Copy the current database to the new location. Make sure to copy the `.db-shm` 446 + and `.db-wal` files if they exist. 447 + 448 + ``` 449 + mkdir /home/git/database 450 + cp /home/git/knotserver.db* /home/git/database 451 + ``` 452 + 453 + In the environment (e.g. `/home/git/.knot.env`), set `KNOT_SERVER_DB_PATH` to 454 + the new file path (_not_ the directory): 455 + 456 + ``` 457 + KNOT_SERVER_DB_PATH=/home/git/database/knotserver.db 458 + ``` 459 + 460 + #### Repositories 461 + 462 + As an example, let's say the repositories are currently in `/home/git`, and we 463 + want to move them into `/home/git/repositories`. 464 + 465 + Create the new folder, then move the existing repositories (if there are any): 466 + 467 + ``` 468 + mkdir /home/git/repositories 469 + # move all DIDs into the new folder; these will vary for you! 470 + mv /home/git/did:plc:wshs7t2adsemcrrd4snkeqli /home/git/repositories 471 + ``` 472 + 473 + In the environment (e.g. `/home/git/.knot.env`), update `KNOT_REPO_SCAN_PATH` 474 + to the new directory: 475 + 476 + ``` 477 + KNOT_REPO_SCAN_PATH=/home/git/repositories 478 + ``` 479 + 480 + Similarly, update your `sshd` `AuthorizedKeysCommand` to use the updated 481 + repository path: 482 + 483 + ``` 484 + sudo tee /etc/ssh/sshd_config.d/authorized_keys_command.conf <<EOF 485 + Match User git 486 + AuthorizedKeysCommand /usr/local/bin/knot keys -o authorized-keys -git-dir /home/git/repositories 487 + AuthorizedKeysCommandUser nobody 488 + EOF 489 + ``` 490 + 491 + Make sure to restart your SSH server! 492 + 493 + #### MOTD (message of the day) 494 + 495 + To configure the MOTD used ("Welcome to this knot!" by default), edit the 496 + `/home/git/motd` file: 497 + 498 + ``` 499 + printf "Hi from this knot!\n" > /home/git/motd 500 + ``` 501 + 502 + Note that you should add a newline at the end if setting a non-empty message 503 + since the knot won't do this for you. 504 + 505 + # Spindles 506 + 507 + ## Pipelines 508 + 509 + Spindle workflows allow you to write CI/CD pipelines in a 510 + simple format. They're located in the `.tangled/workflows` 511 + directory at the root of your repository, and are defined 512 + using YAML. 513 + 514 + The fields are: 515 + 516 + - [Trigger](#trigger): A **required** field that defines 517 + when a workflow should be triggered. 518 + - [Engine](#engine): A **required** field that defines which 519 + engine a workflow should run on. 520 + - [Clone options](#clone-options): An **optional** field 521 + that defines how the repository should be cloned. 522 + - [Dependencies](#dependencies): An **optional** field that 523 + allows you to list dependencies you may need. 524 + - [Environment](#environment): An **optional** field that 525 + allows you to define environment variables. 526 + - [Steps](#steps): An **optional** field that allows you to 527 + define what steps should run in the workflow. 528 + 529 + ### Trigger 530 + 531 + The first thing to add to a workflow is the trigger, which 532 + defines when a workflow runs. This is defined using a `when` 533 + field, which takes in a list of conditions. Each condition 534 + has the following fields: 535 + 536 + - `event`: This is a **required** field that defines when 537 + your workflow should run. It's a list that can take one or 538 + more of the following values: 539 + - `push`: The workflow should run every time a commit is 540 + pushed to the repository. 541 + - `pull_request`: The workflow should run every time a 542 + pull request is made or updated. 543 + - `manual`: The workflow can be triggered manually. 544 + - `branch`: Defines which branches the workflow should run 545 + for. If used with the `push` event, commits to the 546 + branch(es) listed here will trigger the workflow. If used 547 + with the `pull_request` event, updates to pull requests 548 + targeting the branch(es) listed here will trigger the 549 + workflow. This field has no effect with the `manual` 550 + event. Supports glob patterns using `*` and `**` (e.g., 551 + `main`, `develop`, `release-*`). Either `branch` or `tag` 552 + (or both) must be specified for `push` events. 553 + - `tag`: Defines which tags the workflow should run for. 554 + Only used with the `push` event - when tags matching the 555 + pattern(s) listed here are pushed, the workflow will 556 + trigger. This field has no effect with `pull_request` or 557 + `manual` events. Supports glob patterns using `*` and `**` 558 + (e.g., `v*`, `v1.*`, `release-**`). Either `branch` or 559 + `tag` (or both) must be specified for `push` events. 560 + 561 + For example, if you'd like to define a workflow that runs 562 + when commits are pushed to the `main` and `develop` 563 + branches, or when pull requests that target the `main` 564 + branch are updated, or manually, you can do so with: 565 + 566 + ```yaml 567 + when: 568 + - event: ["push", "manual"] 569 + branch: ["main", "develop"] 570 + - event: ["pull_request"] 571 + branch: ["main"] 572 + ``` 573 + 574 + You can also trigger workflows on tag pushes. For instance, 575 + to run a deployment workflow when tags matching `v*` are 576 + pushed: 577 + 578 + ```yaml 579 + when: 580 + - event: ["push"] 581 + tag: ["v*"] 582 + ``` 583 + 584 + You can even combine branch and tag patterns in a single 585 + constraint (the workflow triggers if either matches): 586 + 587 + ```yaml 588 + when: 589 + - event: ["push"] 590 + branch: ["main", "release-*"] 591 + tag: ["v*", "stable"] 592 + ``` 593 + 594 + ### Engine 595 + 596 + Next is the engine on which the workflow should run, defined 597 + using the **required** `engine` field. The currently 598 + supported engines are: 599 + 600 + - `nixery`: This uses an instance of 601 + [Nixery](https://nixery.dev) to run steps, which allows 602 + you to add [dependencies](#dependencies) from 603 + Nixpkgs (https://github.com/NixOS/nixpkgs). You can 604 + search for packages on https://search.nixos.org, and 605 + there's a pretty good chance the package(s) you're looking 606 + for will be there. 607 + 608 + Example: 609 + 610 + ```yaml 611 + engine: "nixery" 612 + ``` 613 + 614 + ### Clone options 615 + 616 + When a workflow starts, the first step is to clone the 617 + repository. You can customize this behavior using the 618 + **optional** `clone` field. It has the following fields: 619 + 620 + - `skip`: Setting this to `true` will skip cloning the 621 + repository. This can be useful if your workflow is doing 622 + something that doesn't require anything from the 623 + repository itself. This is `false` by default. 624 + - `depth`: This sets the number of commits, or the "clone 625 + depth", to fetch from the repository. For example, if you 626 + set this to 2, the last 2 commits will be fetched. By 627 + default, the depth is set to 1, meaning only the most 628 + recent commit will be fetched, which is the commit that 629 + triggered the workflow. 630 + - `submodules`: If you use Git submodules 631 + (https://git-scm.com/book/en/v2/Git-Tools-Submodules) 632 + in your repository, setting this field to `true` will 633 + recursively fetch all submodules. This is `false` by 634 + default. 635 + 636 + The default settings are: 637 + 638 + ```yaml 639 + clone: 640 + skip: false 641 + depth: 1 642 + submodules: false 643 + ``` 644 + 645 + ### Dependencies 646 + 647 + Usually when you're running a workflow, you'll need 648 + additional dependencies. The `dependencies` field lets you 649 + define which dependencies to get, and from where. It's a 650 + key-value map, with the key being the registry to fetch 651 + dependencies from, and the value being the list of 652 + dependencies to fetch. 653 + 654 + Say you want to fetch Node.js and Go from `nixpkgs`, and a 655 + package called `my_pkg` you've made from your own registry 656 + at your repository at 657 + `https://tangled.org/@example.com/my_pkg`. You can define 658 + those dependencies like so: 659 + 660 + ```yaml 661 + dependencies: 662 + # nixpkgs 663 + nixpkgs: 664 + - nodejs 665 + - go 666 + # custom registry 667 + git+https://tangled.org/@example.com/my_pkg: 668 + - my_pkg 669 + ``` 670 + 671 + Now these dependencies are available to use in your 672 + workflow! 673 + 674 + ### Environment 675 + 676 + The `environment` field allows you define environment 677 + variables that will be available throughout the entire 678 + workflow. **Do not put secrets here, these environment 679 + variables are visible to anyone viewing the repository. You 680 + can add secrets for pipelines in your repository's 681 + settings.** 682 + 683 + Example: 684 + 685 + ```yaml 686 + environment: 687 + GOOS: "linux" 688 + GOARCH: "arm64" 689 + NODE_ENV: "production" 690 + MY_ENV_VAR: "MY_ENV_VALUE" 691 + ``` 692 + 693 + ### Steps 694 + 695 + The `steps` field allows you to define what steps should run 696 + in the workflow. It's a list of step objects, each with the 697 + following fields: 698 + 699 + - `name`: This field allows you to give your step a name. 700 + This name is visible in your workflow runs, and is used to 701 + describe what the step is doing. 702 + - `command`: This field allows you to define a command to 703 + run in that step. The step is run in a Bash shell, and the 704 + logs from the command will be visible in the pipelines 705 + page on the Tangled website. The 706 + [dependencies](#dependencies) you added will be available 707 + to use here. 708 + - `environment`: Similar to the global 709 + [environment](#environment) config, this **optional** 710 + field is a key-value map that allows you to set 711 + environment variables for the step. **Do not put secrets 712 + here, these environment variables are visible to anyone 713 + viewing the repository. You can add secrets for pipelines 714 + in your repository's settings.** 715 + 716 + Example: 717 + 718 + ```yaml 719 + steps: 720 + - name: "Build backend" 721 + command: "go build" 722 + environment: 723 + GOOS: "darwin" 724 + GOARCH: "arm64" 725 + - name: "Build frontend" 726 + command: "npm run build" 727 + environment: 728 + NODE_ENV: "production" 729 + ``` 730 + 731 + ### Complete workflow 732 + 733 + ```yaml 734 + # .tangled/workflows/build.yml 735 + 736 + when: 737 + - event: ["push", "manual"] 738 + branch: ["main", "develop"] 739 + - event: ["pull_request"] 740 + branch: ["main"] 741 + 742 + engine: "nixery" 743 + 744 + # using the default values 745 + clone: 746 + skip: false 747 + depth: 1 748 + submodules: false 749 + 750 + dependencies: 751 + # nixpkgs 752 + nixpkgs: 753 + - nodejs 754 + - go 755 + # custom registry 756 + git+https://tangled.org/@example.com/my_pkg: 757 + - my_pkg 758 + 759 + environment: 760 + GOOS: "linux" 761 + GOARCH: "arm64" 762 + NODE_ENV: "production" 763 + MY_ENV_VAR: "MY_ENV_VALUE" 764 + 765 + steps: 766 + - name: "Build backend" 767 + command: "go build" 768 + environment: 769 + GOOS: "darwin" 770 + GOARCH: "arm64" 771 + - name: "Build frontend" 772 + command: "npm run build" 773 + environment: 774 + NODE_ENV: "production" 775 + ``` 776 + 777 + If you want another example of a workflow, you can look at 778 + the one [Tangled uses to build the 779 + project](https://tangled.org/@tangled.org/core/blob/master/.tangled/workflows/build.yml). 780 + 781 + ## Self-hosting guide 782 + 783 + ### Prerequisites 784 + 785 + * Go 786 + * Docker (the only supported backend currently) 787 + 788 + ### Configuration 789 + 790 + Spindle is configured using environment variables. The following environment variables are available: 791 + 792 + * `SPINDLE_SERVER_LISTEN_ADDR`: The address the server listens on (default: `"0.0.0.0:6555"`). 793 + * `SPINDLE_SERVER_DB_PATH`: The path to the SQLite database file (default: `"spindle.db"`). 794 + * `SPINDLE_SERVER_HOSTNAME`: The hostname of the server (required). 795 + * `SPINDLE_SERVER_JETSTREAM_ENDPOINT`: The endpoint of the Jetstream server (default: `"wss://jetstream1.us-west.bsky.network/subscribe"`). 796 + * `SPINDLE_SERVER_DEV`: A boolean indicating whether the server is running in development mode (default: `false`). 797 + * `SPINDLE_SERVER_OWNER`: The DID of the owner (required). 798 + * `SPINDLE_PIPELINES_NIXERY`: The Nixery URL (default: `"nixery.tangled.sh"`). 799 + * `SPINDLE_PIPELINES_WORKFLOW_TIMEOUT`: The default workflow timeout (default: `"5m"`). 800 + * `SPINDLE_PIPELINES_LOG_DIR`: The directory to store workflow logs (default: `"/var/log/spindle"`). 801 + 802 + ### Running spindle 803 + 804 + 1. **Set the environment variables.** For example: 805 + 806 + ```shell 807 + export SPINDLE_SERVER_HOSTNAME="your-hostname" 808 + export SPINDLE_SERVER_OWNER="your-did" 809 + ``` 810 + 811 + 2. **Build the Spindle binary.** 812 + 813 + ```shell 814 + cd core 815 + go mod download 816 + go build -o cmd/spindle/spindle cmd/spindle/main.go 817 + ``` 818 + 819 + 3. **Create the log directory.** 820 + 821 + ```shell 822 + sudo mkdir -p /var/log/spindle 823 + sudo chown $USER:$USER -R /var/log/spindle 824 + ``` 825 + 826 + 4. **Run the Spindle binary.** 827 + 828 + ```shell 829 + ./cmd/spindle/spindle 830 + ``` 831 + 832 + Spindle will now start, connect to the Jetstream server, and begin processing pipelines. 833 + 834 + ## Architecture 835 + 836 + Spindle is a small CI runner service. Here's a high-level overview of how it operates: 837 + 838 + * Listens for [`sh.tangled.spindle.member`](/lexicons/spindle/member.json) and 839 + [`sh.tangled.repo`](/lexicons/repo.json) records on the Jetstream. 840 + * When a new repo record comes through (typically when you add a spindle to a 841 + repo from the settings), spindle then resolves the underlying knot and 842 + subscribes to repo events (see: 843 + [`sh.tangled.pipeline`](/lexicons/pipeline.json)). 844 + * The spindle engine then handles execution of the pipeline, with results and 845 + logs beamed on the spindle event stream over WebSocket 846 + 847 + ### The engine 848 + 849 + At present, the only supported backend is Docker (and Podman, if Docker 850 + compatibility is enabled, so that `/run/docker.sock` is created). spindle 851 + executes each step in the pipeline in a fresh container, with state persisted 852 + across steps within the `/tangled/workspace` directory. 853 + 854 + The base image for the container is constructed on the fly using 855 + [Nixery](https://nixery.dev), which is handy for caching layers for frequently 856 + used packages. 857 + 858 + The pipeline manifest is [specified here](https://docs.tangled.org/spindles.html#pipelines). 859 + 860 + ## Secrets with openbao 861 + 862 + This document covers setting up spindle to use OpenBao for secrets 863 + management via OpenBao Proxy instead of the default SQLite backend. 864 + 865 + ### Overview 866 + 867 + Spindle now uses OpenBao Proxy for secrets management. The proxy handles 868 + authentication automatically using AppRole credentials, while spindle 869 + connects to the local proxy instead of directly to the OpenBao server. 870 + 871 + This approach provides better security, automatic token renewal, and 872 + simplified application code. 873 + 874 + ### Installation 875 + 876 + Install OpenBao from Nixpkgs: 877 + 878 + ```bash 879 + nix shell nixpkgs#openbao # for a local server 880 + ``` 881 + 882 + ### Setup 883 + 884 + The setup process can is documented for both local development and production. 885 + 886 + #### Local development 887 + 888 + Start OpenBao in dev mode: 889 + 890 + ```bash 891 + bao server -dev -dev-root-token-id="root" -dev-listen-address=127.0.0.1:8201 892 + ``` 893 + 894 + This starts OpenBao on `http://localhost:8201` with a root token. 895 + 896 + Set up environment for bao CLI: 897 + 898 + ```bash 899 + export BAO_ADDR=http://localhost:8200 900 + export BAO_TOKEN=root 901 + ``` 902 + 903 + #### Production 904 + 905 + You would typically use a systemd service with a 906 + configuration file. Refer to 907 + [@tangled.org/infra](https://tangled.org/@tangled.org/infra) 908 + for how this can be achieved using Nix. 909 + 910 + Then, initialize the bao server: 911 + 912 + ```bash 913 + bao operator init -key-shares=1 -key-threshold=1 914 + ``` 915 + 916 + This will print out an unseal key and a root key. Save them 917 + somewhere (like a password manager). Then unseal the vault 918 + to begin setting it up: 919 + 920 + ```bash 921 + bao operator unseal <unseal_key> 922 + ``` 923 + 924 + All steps below remain the same across both dev and 925 + production setups. 926 + 927 + #### Configure openbao server 928 + 929 + Create the spindle KV mount: 930 + 931 + ```bash 932 + bao secrets enable -path=spindle -version=2 kv 933 + ``` 934 + 935 + Set up AppRole authentication and policy: 936 + 937 + Create a policy file `spindle-policy.hcl`: 938 + 939 + ```hcl 940 + # Full access to spindle KV v2 data 941 + path "spindle/data/*" { 942 + capabilities = ["create", "read", "update", "delete"] 943 + } 944 + 945 + # Access to metadata for listing and management 946 + path "spindle/metadata/*" { 947 + capabilities = ["list", "read", "delete", "update"] 948 + } 949 + 950 + # Allow listing at root level 951 + path "spindle/" { 952 + capabilities = ["list"] 953 + } 954 + 955 + # Required for connection testing and health checks 956 + path "auth/token/lookup-self" { 957 + capabilities = ["read"] 958 + } 959 + ``` 960 + 961 + Apply the policy and create an AppRole: 962 + 963 + ```bash 964 + bao policy write spindle-policy spindle-policy.hcl 965 + bao auth enable approle 966 + bao write auth/approle/role/spindle \ 967 + token_policies="spindle-policy" \ 968 + token_ttl=1h \ 969 + token_max_ttl=4h \ 970 + bind_secret_id=true \ 971 + secret_id_ttl=0 \ 972 + secret_id_num_uses=0 973 + ``` 974 + 975 + Get the credentials: 976 + 977 + ```bash 978 + # Get role ID (static) 979 + ROLE_ID=$(bao read -field=role_id auth/approle/role/spindle/role-id) 980 + 981 + # Generate secret ID 982 + SECRET_ID=$(bao write -f -field=secret_id auth/approle/role/spindle/secret-id) 983 + 984 + echo "Role ID: $ROLE_ID" 985 + echo "Secret ID: $SECRET_ID" 986 + ``` 987 + 988 + #### Create proxy configuration 989 + 990 + Create the credential files: 991 + 992 + ```bash 993 + # Create directory for OpenBao files 994 + mkdir -p /tmp/openbao 995 + 996 + # Save credentials 997 + echo "$ROLE_ID" > /tmp/openbao/role-id 998 + echo "$SECRET_ID" > /tmp/openbao/secret-id 999 + chmod 600 /tmp/openbao/role-id /tmp/openbao/secret-id 1000 + ``` 1001 + 1002 + Create a proxy configuration file `/tmp/openbao/proxy.hcl`: 1003 + 1004 + ```hcl 1005 + # OpenBao server connection 1006 + vault { 1007 + address = "http://localhost:8200" 1008 + } 1009 + 1010 + # Auto-Auth using AppRole 1011 + auto_auth { 1012 + method "approle" { 1013 + mount_path = "auth/approle" 1014 + config = { 1015 + role_id_file_path = "/tmp/openbao/role-id" 1016 + secret_id_file_path = "/tmp/openbao/secret-id" 1017 + } 1018 + } 1019 + 1020 + # Optional: write token to file for debugging 1021 + sink "file" { 1022 + config = { 1023 + path = "/tmp/openbao/token" 1024 + mode = 0640 1025 + } 1026 + } 1027 + } 1028 + 1029 + # Proxy listener for spindle 1030 + listener "tcp" { 1031 + address = "127.0.0.1:8201" 1032 + tls_disable = true 1033 + } 1034 + 1035 + # Enable API proxy with auto-auth token 1036 + api_proxy { 1037 + use_auto_auth_token = true 1038 + } 1039 + 1040 + # Enable response caching 1041 + cache { 1042 + use_auto_auth_token = true 1043 + } 1044 + 1045 + # Logging 1046 + log_level = "info" 1047 + ``` 1048 + 1049 + #### Start the proxy 1050 + 1051 + Start OpenBao Proxy: 1052 + 1053 + ```bash 1054 + bao proxy -config=/tmp/openbao/proxy.hcl 1055 + ``` 1056 + 1057 + The proxy will authenticate with OpenBao and start listening on 1058 + `127.0.0.1:8201`. 1059 + 1060 + #### Configure spindle 1061 + 1062 + Set these environment variables for spindle: 1063 + 1064 + ```bash 1065 + export SPINDLE_SERVER_SECRETS_PROVIDER=openbao 1066 + export SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=http://127.0.0.1:8201 1067 + export SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=spindle 1068 + ``` 1069 + 1070 + On startup, spindle will now connect to the local proxy, 1071 + which handles all authentication automatically. 1072 + 1073 + ### Production setup for proxy 1074 + 1075 + For production, you'll want to run the proxy as a service: 1076 + 1077 + Place your production configuration in 1078 + `/etc/openbao/proxy.hcl` with proper TLS settings for the 1079 + vault connection. 1080 + 1081 + ### Verifying setup 1082 + 1083 + Test the proxy directly: 1084 + 1085 + ```bash 1086 + # Check proxy health 1087 + curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/sys/health 1088 + 1089 + # Test token lookup through proxy 1090 + curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/auth/token/lookup-self 1091 + ``` 1092 + 1093 + Test OpenBao operations through the server: 1094 + 1095 + ```bash 1096 + # List all secrets 1097 + bao kv list spindle/ 1098 + 1099 + # Add a test secret via the spindle API, then check it exists 1100 + bao kv list spindle/repos/ 1101 + 1102 + # Get a specific secret 1103 + bao kv get spindle/repos/your_repo_path/SECRET_NAME 1104 + ``` 1105 + 1106 + ### How it works 1107 + 1108 + - Spindle connects to OpenBao Proxy on localhost (typically 1109 + port 8200 or 8201) 1110 + - The proxy authenticates with OpenBao using AppRole 1111 + credentials 1112 + - All spindle requests go through the proxy, which injects 1113 + authentication tokens 1114 + - Secrets are stored at 1115 + `spindle/repos/{sanitized_repo_path}/{secret_key}` 1116 + - Repository paths like `did:plc:alice/myrepo` become 1117 + `did_plc_alice_myrepo` 1118 + - The proxy handles all token renewal automatically 1119 + - Spindle no longer manages tokens or authentication 1120 + directly 1121 + 1122 + ### Troubleshooting 1123 + 1124 + **Connection refused**: Check that the OpenBao Proxy is 1125 + running and listening on the configured address. 1126 + 1127 + **403 errors**: Verify the AppRole credentials are correct 1128 + and the policy has the necessary permissions. 1129 + 1130 + **404 route errors**: The spindle KV mount probably doesn't 1131 + existโ€”run the mount creation step again. 1132 + 1133 + **Proxy authentication failures**: Check the proxy logs and 1134 + verify the role-id and secret-id files are readable and 1135 + contain valid credentials. 1136 + 1137 + **Secret not found after writing**: This can indicate policy 1138 + permission issues. Verify the policy includes both 1139 + `spindle/data/*` and `spindle/metadata/*` paths with 1140 + appropriate capabilities. 1141 + 1142 + Check proxy logs: 1143 + 1144 + ```bash 1145 + # If running as systemd service 1146 + journalctl -u openbao-proxy -f 1147 + 1148 + # If running directly, check the console output 1149 + ``` 1150 + 1151 + Test AppRole authentication manually: 1152 + 1153 + ```bash 1154 + bao write auth/approle/login \ 1155 + role_id="$(cat /tmp/openbao/role-id)" \ 1156 + secret_id="$(cat /tmp/openbao/secret-id)" 1157 + ``` 1158 + 1159 + # Migrating knots and spindles 1160 + 1161 + Sometimes, non-backwards compatible changes are made to the 1162 + knot/spindle XRPC APIs. If you host a knot or a spindle, you 1163 + will need to follow this guide to upgrade. Typically, this 1164 + only requires you to deploy the newest version. 1165 + 1166 + This document is laid out in reverse-chronological order. 1167 + Newer migration guides are listed first, and older guides 1168 + are further down the page. 1169 + 1170 + ## Upgrading from v1.8.x 1171 + 1172 + After v1.8.2, the HTTP API for knots and spindles has been 1173 + deprecated and replaced with XRPC. Repositories on outdated 1174 + knots will not be viewable from the appview. Upgrading is 1175 + straightforward however. 1176 + 1177 + For knots: 1178 + 1179 + - Upgrade to the latest tag (v1.9.0 or above) 1180 + - Head to the [knot dashboard](https://tangled.org/settings/knots) and 1181 + hit the "retry" button to verify your knot 1182 + 1183 + For spindles: 1184 + 1185 + - Upgrade to the latest tag (v1.9.0 or above) 1186 + - Head to the [spindle 1187 + dashboard](https://tangled.org/settings/spindles) and hit the 1188 + "retry" button to verify your spindle 1189 + 1190 + ## Upgrading from v1.7.x 1191 + 1192 + After v1.7.0, knot secrets have been deprecated. You no 1193 + longer need a secret from the appview to run a knot. All 1194 + authorized commands to knots are managed via [Inter-Service 1195 + Authentication](https://atproto.com/specs/xrpc#inter-service-authentication-jwt). 1196 + Knots will be read-only until upgraded. 1197 + 1198 + Upgrading is quite easy, in essence: 1199 + 1200 + - `KNOT_SERVER_SECRET` is no more, you can remove this 1201 + environment variable entirely 1202 + - `KNOT_SERVER_OWNER` is now required on boot, set this to 1203 + your DID. You can find your DID in the 1204 + [settings](https://tangled.org/settings) page. 1205 + - Restart your knot once you have replaced the environment 1206 + variable 1207 + - Head to the [knot dashboard](https://tangled.org/settings/knots) and 1208 + hit the "retry" button to verify your knot. This simply 1209 + writes a `sh.tangled.knot` record to your PDS. 1210 + 1211 + If you use the nix module, simply bump the flake to the 1212 + latest revision, and change your config block like so: 1213 + 1214 + ```diff 1215 + services.tangled.knot = { 1216 + enable = true; 1217 + server = { 1218 + - secretFile = /path/to/secret; 1219 + + owner = "did:plc:foo"; 1220 + }; 1221 + }; 1222 + ``` 1223 + 1224 + # Hacking on Tangled 1225 + 1226 + We highly recommend [installing 1227 + Nix](https://nixos.org/download/) (the package manager) 1228 + before working on the codebase. The Nix flake provides a lot 1229 + of helpers to get started and most importantly, builds and 1230 + dev shells are entirely deterministic. 1231 + 1232 + To set up your dev environment: 1233 + 1234 + ```bash 1235 + nix develop 1236 + ``` 1237 + 1238 + Non-Nix users can look at the `devShell` attribute in the 1239 + `flake.nix` file to determine necessary dependencies. 1240 + 1241 + ## Running the appview 1242 + 1243 + The Nix flake also exposes a few `app` attributes (run `nix 1244 + flake show` to see a full list of what the flake provides), 1245 + one of the apps runs the appview with the `air` 1246 + live-reloader: 1247 + 1248 + ```bash 1249 + TANGLED_DEV=true nix run .#watch-appview 1250 + 1251 + # TANGLED_DB_PATH might be of interest to point to 1252 + # different sqlite DBs 1253 + 1254 + # in a separate shell, you can live-reload tailwind 1255 + nix run .#watch-tailwind 1256 + ``` 1257 + 1258 + To authenticate with the appview, you will need Redis and 1259 + OAuth JWKs to be set up: 1260 + 1261 + ``` 1262 + # OAuth JWKs should already be set up by the Nix devshell: 1263 + echo $TANGLED_OAUTH_CLIENT_SECRET 1264 + z42ty4RT1ovnTopY8B8ekz9NuziF2CuMkZ7rbRFpAR9jBqMc 1265 + 1266 + echo $TANGLED_OAUTH_CLIENT_KID 1267 + 1761667908 1268 + 1269 + # if not, you can set it up yourself: 1270 + goat key generate -t P-256 1271 + Key Type: P-256 / secp256r1 / ES256 private key 1272 + Secret Key (Multibase Syntax): save this securely (eg, add to password manager) 1273 + z42tuPDKRfM2mz2Kv953ARen2jmrPA8S9LX9tRq4RVcUMwwL 1274 + Public Key (DID Key Syntax): share or publish this (eg, in DID document) 1275 + did:key:zDnaeUBxtG6Xuv3ATJE4GaWeyXM3jyamJsZw3bSPpxx4bNXDR 1276 + 1277 + # the secret key from above 1278 + export TANGLED_OAUTH_CLIENT_SECRET="z42tuP..." 1279 + 1280 + # Run Redis in a new shell to store OAuth sessions 1281 + redis-server 1282 + ``` 1283 + 1284 + ## Running knots and spindles 1285 + 1286 + An end-to-end knot setup requires setting up a machine with 1287 + `sshd`, `AuthorizedKeysCommand`, and a Git user, which is 1288 + quite cumbersome. So the Nix flake provides a 1289 + `nixosConfiguration` to do so. 1290 + 1291 + <details> 1292 + <summary><strong>macOS users will have to set up a Nix Builder first</strong></summary> 1293 + 1294 + In order to build Tangled's dev VM on macOS, you will 1295 + first need to set up a Linux Nix builder. The recommended 1296 + way to do so is to run a [`darwin.linux-builder` 1297 + VM](https://nixos.org/manual/nixpkgs/unstable/#sec-darwin-builder) 1298 + and to register it in `nix.conf` as a builder for Linux 1299 + with the same architecture as your Mac (`linux-aarch64` if 1300 + you are using Apple Silicon). 1301 + 1302 + > IMPORTANT: You must build `darwin.linux-builder` somewhere other than inside 1303 + > the Tangled repo so that it doesn't conflict with the other VM. For example, 1304 + > you can do 1305 + > 1306 + > ```shell 1307 + > cd $(mktemp -d buildervm.XXXXX) && nix run nixpkgs#darwin.linux-builder 1308 + > ``` 1309 + > 1310 + > to store the builder VM in a temporary dir. 1311 + > 1312 + > You should read and follow [all the other intructions][darwin builder vm] to 1313 + > avoid subtle problems. 1314 + 1315 + Alternatively, you can use any other method to set up a 1316 + Linux machine with Nix installed that you can `sudo ssh` 1317 + into (in other words, root user on your Mac has to be able 1318 + to ssh into the Linux machine without entering a password) 1319 + and that has the same architecture as your Mac. See 1320 + [remote builder 1321 + instructions](https://nix.dev/manual/nix/2.28/advanced-topics/distributed-builds.html#requirements) 1322 + for how to register such a builder in `nix.conf`. 1323 + 1324 + > WARNING: If you'd like to use 1325 + > [`nixos-lima`](https://github.com/nixos-lima/nixos-lima) or 1326 + > [Orbstack](https://orbstack.dev/), note that setting them up so that `sudo 1327 + > ssh` works can be tricky. It seems to be [possible with 1328 + > Orbstack](https://github.com/orgs/orbstack/discussions/1669). 1329 + 1330 + </details> 1331 + 1332 + To begin, grab your DID from http://localhost:3000/settings. 1333 + Then, set `TANGLED_VM_KNOT_OWNER` and 1334 + `TANGLED_VM_SPINDLE_OWNER` to your DID. You can now start a 1335 + lightweight NixOS VM like so: 1336 + 1337 + ```bash 1338 + nix run --impure .#vm 1339 + 1340 + # type `poweroff` at the shell to exit the VM 1341 + ``` 1342 + 1343 + This starts a knot on port 6444, a spindle on port 6555 1344 + with `ssh` exposed on port 2222. 1345 + 1346 + Once the services are running, head to 1347 + http://localhost:3000/settings/knots and hit "Verify". It should 1348 + verify the ownership of the services instantly if everything 1349 + went smoothly. 1350 + 1351 + You can push repositories to this VM with this ssh config 1352 + block on your main machine: 1353 + 1354 + ```bash 1355 + Host nixos-shell 1356 + Hostname localhost 1357 + Port 2222 1358 + User git 1359 + IdentityFile ~/.ssh/my_tangled_key 1360 + ``` 1361 + 1362 + Set up a remote called `local-dev` on a git repo: 1363 + 1364 + ```bash 1365 + git remote add local-dev git@nixos-shell:user/repo 1366 + git push local-dev main 1367 + ``` 1368 + 1369 + The above VM should already be running a spindle on 1370 + `localhost:6555`. Head to http://localhost:3000/settings/spindles and 1371 + hit "Verify". You can then configure each repository to use 1372 + this spindle and run CI jobs. 1373 + 1374 + Of interest when debugging spindles: 1375 + 1376 + ``` 1377 + # Service logs from journald: 1378 + journalctl -xeu spindle 1379 + 1380 + # CI job logs from disk: 1381 + ls /var/log/spindle 1382 + 1383 + # Debugging spindle database: 1384 + sqlite3 /var/lib/spindle/spindle.db 1385 + 1386 + # litecli has a nicer REPL interface: 1387 + litecli /var/lib/spindle/spindle.db 1388 + ``` 1389 + 1390 + If for any reason you wish to disable either one of the 1391 + services in the VM, modify [nix/vm.nix](/nix/vm.nix) and set 1392 + `services.tangled.spindle.enable` (or 1393 + `services.tangled.knot.enable`) to `false`. 1394 + 1395 + # Contribution guide 1396 + 1397 + ## Commit guidelines 1398 + 1399 + We follow a commit style similar to the Go project. Please keep commits: 1400 + 1401 + * **atomic**: each commit should represent one logical change 1402 + * **descriptive**: the commit message should clearly describe what the 1403 + change does and why it's needed 1404 + 1405 + ### Message format 1406 + 1407 + ``` 1408 + <service/top-level directory>/<affected package/directory>: <short summary of change> 1409 + 1410 + Optional longer description can go here, if necessary. Explain what the 1411 + change does and why, especially if not obvious. Reference relevant 1412 + issues or PRs when applicable. These can be links for now since we don't 1413 + auto-link issues/PRs yet. 1414 + ``` 1415 + 1416 + Here are some examples: 1417 + 1418 + ``` 1419 + appview/state: fix token expiry check in middleware 1420 + 1421 + The previous check did not account for clock drift, leading to premature 1422 + token invalidation. 1423 + ``` 1424 + 1425 + ``` 1426 + knotserver/git/service: improve error checking in upload-pack 1427 + ``` 1428 + 1429 + 1430 + ### General notes 1431 + 1432 + - PRs get merged "as-is" (fast-forward)โ€”like applying a patch-series 1433 + using `git am`. At present, there is no squashingโ€”so please author 1434 + your commits as they would appear on `master`, following the above 1435 + guidelines. 1436 + - If there is a lot of nesting, for example "appview: 1437 + pages/templates/repo/fragments: ...", these can be truncated down to 1438 + just "appview: repo/fragments: ...". If the change affects a lot of 1439 + subdirectories, you may abbreviate to just the top-level names, e.g. 1440 + "appview: ..." or "knotserver: ...". 1441 + - Keep commits lowercased with no trailing period. 1442 + - Use the imperative mood in the summary line (e.g., "fix bug" not 1443 + "fixed bug" or "fixes bug"). 1444 + - Try to keep the summary line under 72 characters, but we aren't too 1445 + fussed about this. 1446 + - Follow the same formatting for PR titles if filled manually. 1447 + - Don't include unrelated changes in the same commit. 1448 + - Avoid noisy commit messages like "wip" or "final fix"โ€”rewrite history 1449 + before submitting if necessary. 1450 + 1451 + ## Code formatting 1452 + 1453 + We use a variety of tools to format our code, and multiplex them with 1454 + [`treefmt`](https://treefmt.com). All you need to do to format your changes 1455 + is run `nix run .#fmt` (or just `treefmt` if you're in the devshell). 1456 + 1457 + ## Proposals for bigger changes 1458 + 1459 + Small fixes like typos, minor bugs, or trivial refactors can be 1460 + submitted directly as PRs. 1461 + 1462 + For larger changesโ€”especially those introducing new features, significant 1463 + refactoring, or altering system behaviorโ€”please open a proposal first. This 1464 + helps us evaluate the scope, design, and potential impact before implementation. 1465 + 1466 + Create a new issue titled: 1467 + 1468 + ``` 1469 + proposal: <affected scope>: <summary of change> 1470 + ``` 1471 + 1472 + In the description, explain: 1473 + 1474 + - What the change is 1475 + - Why it's needed 1476 + - How you plan to implement it (roughly) 1477 + - Any open questions or tradeoffs 1478 + 1479 + We'll use the issue thread to discuss and refine the idea before moving 1480 + forward. 1481 + 1482 + ## Developer Certificate of Origin (DCO) 1483 + 1484 + We require all contributors to certify that they have the right to 1485 + submit the code they're contributing. To do this, we follow the 1486 + [Developer Certificate of Origin 1487 + (DCO)](https://developercertificate.org/). 1488 + 1489 + By signing your commits, you're stating that the contribution is your 1490 + own work, or that you have the right to submit it under the project's 1491 + license. This helps us keep things clean and legally sound. 1492 + 1493 + To sign your commit, just add the `-s` flag when committing: 1494 + 1495 + ```sh 1496 + git commit -s -m "your commit message" 1497 + ``` 1498 + 1499 + This appends a line like: 1500 + 1501 + ``` 1502 + Signed-off-by: Your Name <your.email@example.com> 1503 + ``` 1504 + 1505 + We won't merge commits if they aren't signed off. If you forget, you can 1506 + amend the last commit like this: 1507 + 1508 + ```sh 1509 + git commit --amend -s 1510 + ``` 1511 + 1512 + If you're submitting a PR with multiple commits, make sure each one is 1513 + signed. 1514 + 1515 + For [jj](https://jj-vcs.github.io/jj/latest/) users, you can run the following command 1516 + to make it sign off commits in the tangled repo: 1517 + 1518 + ```shell 1519 + # Safety check, should say "No matching config key..." 1520 + jj config list templates.commit_trailers 1521 + # The command below may need to be adjusted if the command above returned something. 1522 + jj config set --repo templates.commit_trailers "format_signed_off_by_trailer(self)" 1523 + ``` 1524 + 1525 + Refer to the [jujutsu 1526 + documentation](https://jj-vcs.github.io/jj/latest/config/#commit-trailers) 1527 + for more information.
-136
docs/contributing.md
··· 1 - # tangled contributing guide 2 - 3 - ## commit guidelines 4 - 5 - We follow a commit style similar to the Go project. Please keep commits: 6 - 7 - * **atomic**: each commit should represent one logical change 8 - * **descriptive**: the commit message should clearly describe what the 9 - change does and why it's needed 10 - 11 - ### message format 12 - 13 - ``` 14 - <service/top-level directory>/<affected package/directory>: <short summary of change> 15 - 16 - 17 - Optional longer description can go here, if necessary. Explain what the 18 - change does and why, especially if not obvious. Reference relevant 19 - issues or PRs when applicable. These can be links for now since we don't 20 - auto-link issues/PRs yet. 21 - ``` 22 - 23 - Here are some examples: 24 - 25 - ``` 26 - appview/state: fix token expiry check in middleware 27 - 28 - The previous check did not account for clock drift, leading to premature 29 - token invalidation. 30 - ``` 31 - 32 - ``` 33 - knotserver/git/service: improve error checking in upload-pack 34 - ``` 35 - 36 - 37 - ### general notes 38 - 39 - - PRs get merged "as-is" (fast-forward) -- like applying a patch-series 40 - using `git am`. At present, there is no squashing -- so please author 41 - your commits as they would appear on `master`, following the above 42 - guidelines. 43 - - If there is a lot of nesting, for example "appview: 44 - pages/templates/repo/fragments: ...", these can be truncated down to 45 - just "appview: repo/fragments: ...". If the change affects a lot of 46 - subdirectories, you may abbreviate to just the top-level names, e.g. 47 - "appview: ..." or "knotserver: ...". 48 - - Keep commits lowercased with no trailing period. 49 - - Use the imperative mood in the summary line (e.g., "fix bug" not 50 - "fixed bug" or "fixes bug"). 51 - - Try to keep the summary line under 72 characters, but we aren't too 52 - fussed about this. 53 - - Follow the same formatting for PR titles if filled manually. 54 - - Don't include unrelated changes in the same commit. 55 - - Avoid noisy commit messages like "wip" or "final fix"โ€”rewrite history 56 - before submitting if necessary. 57 - 58 - ## code formatting 59 - 60 - We use a variety of tools to format our code, and multiplex them with 61 - [`treefmt`](https://treefmt.com): all you need to do to format your changes 62 - is run `nix run .#fmt` (or just `treefmt` if you're in the devshell). 63 - 64 - ## proposals for bigger changes 65 - 66 - Small fixes like typos, minor bugs, or trivial refactors can be 67 - submitted directly as PRs. 68 - 69 - For larger changesโ€”especially those introducing new features, significant 70 - refactoring, or altering system behaviorโ€”please open a proposal first. This 71 - helps us evaluate the scope, design, and potential impact before implementation. 72 - 73 - ### proposal format 74 - 75 - Create a new issue titled: 76 - 77 - ``` 78 - proposal: <affected scope>: <summary of change> 79 - ``` 80 - 81 - In the description, explain: 82 - 83 - - What the change is 84 - - Why it's needed 85 - - How you plan to implement it (roughly) 86 - - Any open questions or tradeoffs 87 - 88 - We'll use the issue thread to discuss and refine the idea before moving 89 - forward. 90 - 91 - ## developer certificate of origin (DCO) 92 - 93 - We require all contributors to certify that they have the right to 94 - submit the code they're contributing. To do this, we follow the 95 - [Developer Certificate of Origin 96 - (DCO)](https://developercertificate.org/). 97 - 98 - By signing your commits, you're stating that the contribution is your 99 - own work, or that you have the right to submit it under the project's 100 - license. This helps us keep things clean and legally sound. 101 - 102 - To sign your commit, just add the `-s` flag when committing: 103 - 104 - ```sh 105 - git commit -s -m "your commit message" 106 - ``` 107 - 108 - This appends a line like: 109 - 110 - ``` 111 - Signed-off-by: Your Name <your.email@example.com> 112 - ``` 113 - 114 - We won't merge commits if they aren't signed off. If you forget, you can 115 - amend the last commit like this: 116 - 117 - ```sh 118 - git commit --amend -s 119 - ``` 120 - 121 - If you're submitting a PR with multiple commits, make sure each one is 122 - signed. 123 - 124 - For [jj](https://jj-vcs.github.io/jj/latest/) users, you can run the following command 125 - to make it sign off commits in the tangled repo: 126 - 127 - ```shell 128 - # Safety check, should say "No matching config key..." 129 - jj config list templates.commit_trailers 130 - # The command below may need to be adjusted if the command above returned something. 131 - jj config set --repo templates.commit_trailers "format_signed_off_by_trailer(self)" 132 - ``` 133 - 134 - Refer to the [jj 135 - documentation](https://jj-vcs.github.io/jj/latest/config/#commit-trailers) 136 - for more information.
-172
docs/hacking.md
··· 1 - # hacking on tangled 2 - 3 - We highly recommend [installing 4 - nix](https://nixos.org/download/) (the package manager) 5 - before working on the codebase. The nix flake provides a lot 6 - of helpers to get started and most importantly, builds and 7 - dev shells are entirely deterministic. 8 - 9 - To set up your dev environment: 10 - 11 - ```bash 12 - nix develop 13 - ``` 14 - 15 - Non-nix users can look at the `devShell` attribute in the 16 - `flake.nix` file to determine necessary dependencies. 17 - 18 - ## running the appview 19 - 20 - The nix flake also exposes a few `app` attributes (run `nix 21 - flake show` to see a full list of what the flake provides), 22 - one of the apps runs the appview with the `air` 23 - live-reloader: 24 - 25 - ```bash 26 - TANGLED_DEV=true nix run .#watch-appview 27 - 28 - # TANGLED_DB_PATH might be of interest to point to 29 - # different sqlite DBs 30 - 31 - # in a separate shell, you can live-reload tailwind 32 - nix run .#watch-tailwind 33 - ``` 34 - 35 - To authenticate with the appview, you will need redis and 36 - OAUTH JWKs to be setup: 37 - 38 - ``` 39 - # oauth jwks should already be setup by the nix devshell: 40 - echo $TANGLED_OAUTH_CLIENT_SECRET 41 - z42ty4RT1ovnTopY8B8ekz9NuziF2CuMkZ7rbRFpAR9jBqMc 42 - 43 - echo $TANGLED_OAUTH_CLIENT_KID 44 - 1761667908 45 - 46 - # if not, you can set it up yourself: 47 - goat key generate -t P-256 48 - Key Type: P-256 / secp256r1 / ES256 private key 49 - Secret Key (Multibase Syntax): save this securely (eg, add to password manager) 50 - z42tuPDKRfM2mz2Kv953ARen2jmrPA8S9LX9tRq4RVcUMwwL 51 - Public Key (DID Key Syntax): share or publish this (eg, in DID document) 52 - did:key:zDnaeUBxtG6Xuv3ATJE4GaWeyXM3jyamJsZw3bSPpxx4bNXDR 53 - 54 - # the secret key from above 55 - export TANGLED_OAUTH_CLIENT_SECRET="z42tuP..." 56 - 57 - # run redis in at a new shell to store oauth sessions 58 - redis-server 59 - ``` 60 - 61 - ## running knots and spindles 62 - 63 - An end-to-end knot setup requires setting up a machine with 64 - `sshd`, `AuthorizedKeysCommand`, and git user, which is 65 - quite cumbersome. So the nix flake provides a 66 - `nixosConfiguration` to do so. 67 - 68 - <details> 69 - <summary><strong>MacOS users will have to setup a Nix Builder first</strong></summary> 70 - 71 - In order to build Tangled's dev VM on macOS, you will 72 - first need to set up a Linux Nix builder. The recommended 73 - way to do so is to run a [`darwin.linux-builder` 74 - VM](https://nixos.org/manual/nixpkgs/unstable/#sec-darwin-builder) 75 - and to register it in `nix.conf` as a builder for Linux 76 - with the same architecture as your Mac (`linux-aarch64` if 77 - you are using Apple Silicon). 78 - 79 - > IMPORTANT: You must build `darwin.linux-builder` somewhere other than inside 80 - > the tangled repo so that it doesn't conflict with the other VM. For example, 81 - > you can do 82 - > 83 - > ```shell 84 - > cd $(mktemp -d buildervm.XXXXX) && nix run nixpkgs#darwin.linux-builder 85 - > ``` 86 - > 87 - > to store the builder VM in a temporary dir. 88 - > 89 - > You should read and follow [all the other intructions][darwin builder vm] to 90 - > avoid subtle problems. 91 - 92 - Alternatively, you can use any other method to set up a 93 - Linux machine with `nix` installed that you can `sudo ssh` 94 - into (in other words, root user on your Mac has to be able 95 - to ssh into the Linux machine without entering a password) 96 - and that has the same architecture as your Mac. See 97 - [remote builder 98 - instructions](https://nix.dev/manual/nix/2.28/advanced-topics/distributed-builds.html#requirements) 99 - for how to register such a builder in `nix.conf`. 100 - 101 - > WARNING: If you'd like to use 102 - > [`nixos-lima`](https://github.com/nixos-lima/nixos-lima) or 103 - > [Orbstack](https://orbstack.dev/), note that setting them up so that `sudo 104 - > ssh` works can be tricky. It seems to be [possible with 105 - > Orbstack](https://github.com/orgs/orbstack/discussions/1669). 106 - 107 - </details> 108 - 109 - To begin, grab your DID from http://localhost:3000/settings. 110 - Then, set `TANGLED_VM_KNOT_OWNER` and 111 - `TANGLED_VM_SPINDLE_OWNER` to your DID. You can now start a 112 - lightweight NixOS VM like so: 113 - 114 - ```bash 115 - nix run --impure .#vm 116 - 117 - # type `poweroff` at the shell to exit the VM 118 - ``` 119 - 120 - This starts a knot on port 6000, a spindle on port 6555 121 - with `ssh` exposed on port 2222. 122 - 123 - Once the services are running, head to 124 - http://localhost:3000/knots and hit verify. It should 125 - verify the ownership of the services instantly if everything 126 - went smoothly. 127 - 128 - You can push repositories to this VM with this ssh config 129 - block on your main machine: 130 - 131 - ```bash 132 - Host nixos-shell 133 - Hostname localhost 134 - Port 2222 135 - User git 136 - IdentityFile ~/.ssh/my_tangled_key 137 - ``` 138 - 139 - Set up a remote called `local-dev` on a git repo: 140 - 141 - ```bash 142 - git remote add local-dev git@nixos-shell:user/repo 143 - git push local-dev main 144 - ``` 145 - 146 - ### running a spindle 147 - 148 - The above VM should already be running a spindle on 149 - `localhost:6555`. Head to http://localhost:3000/spindles and 150 - hit verify. You can then configure each repository to use 151 - this spindle and run CI jobs. 152 - 153 - Of interest when debugging spindles: 154 - 155 - ``` 156 - # service logs from journald: 157 - journalctl -xeu spindle 158 - 159 - # CI job logs from disk: 160 - ls /var/log/spindle 161 - 162 - # debugging spindle db: 163 - sqlite3 /var/lib/spindle/spindle.db 164 - 165 - # litecli has a nicer REPL interface: 166 - litecli /var/lib/spindle/spindle.db 167 - ``` 168 - 169 - If for any reason you wish to disable either one of the 170 - services in the VM, modify [nix/vm.nix](/nix/vm.nix) and set 171 - `services.tangled-spindle.enable` (or 172 - `services.tangled-knot.enable`) to `false`.
+93
docs/highlight.theme
··· 1 + { 2 + "text-color": null, 3 + "background-color": null, 4 + "line-number-color": null, 5 + "line-number-background-color": null, 6 + "text-styles": { 7 + "Annotation": { 8 + "text-color": null, 9 + "background-color": null, 10 + "bold": false, 11 + "italic": true, 12 + "underline": false 13 + }, 14 + "ControlFlow": { 15 + "text-color": null, 16 + "background-color": null, 17 + "bold": true, 18 + "italic": false, 19 + "underline": false 20 + }, 21 + "Error": { 22 + "text-color": null, 23 + "background-color": null, 24 + "bold": true, 25 + "italic": false, 26 + "underline": false 27 + }, 28 + "Alert": { 29 + "text-color": null, 30 + "background-color": null, 31 + "bold": true, 32 + "italic": false, 33 + "underline": false 34 + }, 35 + "Preprocessor": { 36 + "text-color": null, 37 + "background-color": null, 38 + "bold": true, 39 + "italic": false, 40 + "underline": false 41 + }, 42 + "Information": { 43 + "text-color": null, 44 + "background-color": null, 45 + "bold": false, 46 + "italic": true, 47 + "underline": false 48 + }, 49 + "Warning": { 50 + "text-color": null, 51 + "background-color": null, 52 + "bold": false, 53 + "italic": true, 54 + "underline": false 55 + }, 56 + "Documentation": { 57 + "text-color": null, 58 + "background-color": null, 59 + "bold": false, 60 + "italic": true, 61 + "underline": false 62 + }, 63 + "DataType": { 64 + "text-color": "#8f4e8b", 65 + "background-color": null, 66 + "bold": false, 67 + "italic": false, 68 + "underline": false 69 + }, 70 + "Comment": { 71 + "text-color": null, 72 + "background-color": null, 73 + "bold": false, 74 + "italic": true, 75 + "underline": false 76 + }, 77 + "CommentVar": { 78 + "text-color": null, 79 + "background-color": null, 80 + "bold": false, 81 + "italic": true, 82 + "underline": false 83 + }, 84 + "Keyword": { 85 + "text-color": null, 86 + "background-color": null, 87 + "bold": true, 88 + "italic": false, 89 + "underline": false 90 + } 91 + } 92 + } 93 +
-214
docs/knot-hosting.md
··· 1 - # knot self-hosting guide 2 - 3 - So you want to run your own knot server? Great! Here are a few prerequisites: 4 - 5 - 1. A server of some kind (a VPS, a Raspberry Pi, etc.). Preferably running a Linux distribution of some kind. 6 - 2. A (sub)domain name. People generally use `knot.example.com`. 7 - 3. A valid SSL certificate for your domain. 8 - 9 - There's a couple of ways to get started: 10 - * NixOS: refer to 11 - [flake.nix](https://tangled.sh/@tangled.sh/core/blob/master/flake.nix) 12 - * Docker: Documented at 13 - [@tangled.sh/knot-docker](https://tangled.sh/@tangled.sh/knot-docker) 14 - (community maintained: support is not guaranteed!) 15 - * Manual: Documented below. 16 - 17 - ## manual setup 18 - 19 - First, clone this repository: 20 - 21 - ``` 22 - git clone https://tangled.org/@tangled.org/core 23 - ``` 24 - 25 - Then, build the `knot` CLI. This is the knot administration and operation tool. 26 - For the purpose of this guide, we're only concerned with these subcommands: 27 - 28 - * `knot server`: the main knot server process, typically run as a 29 - supervised service 30 - * `knot guard`: handles role-based access control for git over SSH 31 - (you'll never have to run this yourself) 32 - * `knot keys`: fetches SSH keys associated with your knot; we'll use 33 - this to generate the SSH `AuthorizedKeysCommand` 34 - 35 - ``` 36 - cd core 37 - export CGO_ENABLED=1 38 - go build -o knot ./cmd/knot 39 - ``` 40 - 41 - Next, move the `knot` binary to a location owned by `root` -- 42 - `/usr/local/bin/` is a good choice. Make sure the binary itself is also owned by `root`: 43 - 44 - ``` 45 - sudo mv knot /usr/local/bin/knot 46 - sudo chown root:root /usr/local/bin/knot 47 - ``` 48 - 49 - This is necessary because SSH `AuthorizedKeysCommand` requires [really 50 - specific permissions](https://stackoverflow.com/a/27638306). The 51 - `AuthorizedKeysCommand` specifies a command that is run by `sshd` to 52 - retrieve a user's public SSH keys dynamically for authentication. Let's 53 - set that up. 54 - 55 - ``` 56 - sudo tee /etc/ssh/sshd_config.d/authorized_keys_command.conf <<EOF 57 - Match User git 58 - AuthorizedKeysCommand /usr/local/bin/knot keys -o authorized-keys 59 - AuthorizedKeysCommandUser nobody 60 - EOF 61 - ``` 62 - 63 - Then, reload `sshd`: 64 - 65 - ``` 66 - sudo systemctl reload ssh 67 - ``` 68 - 69 - Next, create the `git` user. We'll use the `git` user's home directory 70 - to store repositories: 71 - 72 - ``` 73 - sudo adduser git 74 - ``` 75 - 76 - Create `/home/git/.knot.env` with the following, updating the values as 77 - necessary. The `KNOT_SERVER_OWNER` should be set to your 78 - DID, you can find your DID in the [Settings](https://tangled.sh/settings) page. 79 - 80 - ``` 81 - KNOT_REPO_SCAN_PATH=/home/git 82 - KNOT_SERVER_HOSTNAME=knot.example.com 83 - APPVIEW_ENDPOINT=https://tangled.sh 84 - KNOT_SERVER_OWNER=did:plc:foobar 85 - KNOT_SERVER_INTERNAL_LISTEN_ADDR=127.0.0.1:5444 86 - KNOT_SERVER_LISTEN_ADDR=127.0.0.1:5555 87 - ``` 88 - 89 - If you run a Linux distribution that uses systemd, you can use the provided 90 - service file to run the server. Copy 91 - [`knotserver.service`](/systemd/knotserver.service) 92 - to `/etc/systemd/system/`. Then, run: 93 - 94 - ``` 95 - systemctl enable knotserver 96 - systemctl start knotserver 97 - ``` 98 - 99 - The last step is to configure a reverse proxy like Nginx or Caddy to front your 100 - knot. Here's an example configuration for Nginx: 101 - 102 - ``` 103 - server { 104 - listen 80; 105 - listen [::]:80; 106 - server_name knot.example.com; 107 - 108 - location / { 109 - proxy_pass http://localhost:5555; 110 - proxy_set_header Host $host; 111 - proxy_set_header X-Real-IP $remote_addr; 112 - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 113 - proxy_set_header X-Forwarded-Proto $scheme; 114 - } 115 - 116 - # wss endpoint for git events 117 - location /events { 118 - proxy_set_header X-Forwarded-For $remote_addr; 119 - proxy_set_header Host $http_host; 120 - proxy_set_header Upgrade websocket; 121 - proxy_set_header Connection Upgrade; 122 - proxy_pass http://localhost:5555; 123 - } 124 - # additional config for SSL/TLS go here. 125 - } 126 - 127 - ``` 128 - 129 - Remember to use Let's Encrypt or similar to procure a certificate for your 130 - knot domain. 131 - 132 - You should now have a running knot server! You can finalize 133 - your registration by hitting the `verify` button on the 134 - [/knots](https://tangled.org/knots) page. This simply creates 135 - a record on your PDS to announce the existence of the knot. 136 - 137 - ### custom paths 138 - 139 - (This section applies to manual setup only. Docker users should edit the mounts 140 - in `docker-compose.yml` instead.) 141 - 142 - Right now, the database and repositories of your knot lives in `/home/git`. You 143 - can move these paths if you'd like to store them in another folder. Be careful 144 - when adjusting these paths: 145 - 146 - * Stop your knot when moving data (e.g. `systemctl stop knotserver`) to prevent 147 - any possible side effects. Remember to restart it once you're done. 148 - * Make backups before moving in case something goes wrong. 149 - * Make sure the `git` user can read and write from the new paths. 150 - 151 - #### database 152 - 153 - As an example, let's say the current database is at `/home/git/knotserver.db`, 154 - and we want to move it to `/home/git/database/knotserver.db`. 155 - 156 - Copy the current database to the new location. Make sure to copy the `.db-shm` 157 - and `.db-wal` files if they exist. 158 - 159 - ``` 160 - mkdir /home/git/database 161 - cp /home/git/knotserver.db* /home/git/database 162 - ``` 163 - 164 - In the environment (e.g. `/home/git/.knot.env`), set `KNOT_SERVER_DB_PATH` to 165 - the new file path (_not_ the directory): 166 - 167 - ``` 168 - KNOT_SERVER_DB_PATH=/home/git/database/knotserver.db 169 - ``` 170 - 171 - #### repositories 172 - 173 - As an example, let's say the repositories are currently in `/home/git`, and we 174 - want to move them into `/home/git/repositories`. 175 - 176 - Create the new folder, then move the existing repositories (if there are any): 177 - 178 - ``` 179 - mkdir /home/git/repositories 180 - # move all DIDs into the new folder; these will vary for you! 181 - mv /home/git/did:plc:wshs7t2adsemcrrd4snkeqli /home/git/repositories 182 - ``` 183 - 184 - In the environment (e.g. `/home/git/.knot.env`), update `KNOT_REPO_SCAN_PATH` 185 - to the new directory: 186 - 187 - ``` 188 - KNOT_REPO_SCAN_PATH=/home/git/repositories 189 - ``` 190 - 191 - Similarly, update your `sshd` `AuthorizedKeysCommand` to use the updated 192 - repository path: 193 - 194 - ``` 195 - sudo tee /etc/ssh/sshd_config.d/authorized_keys_command.conf <<EOF 196 - Match User git 197 - AuthorizedKeysCommand /usr/local/bin/knot keys -o authorized-keys -git-dir /home/git/repositories 198 - AuthorizedKeysCommandUser nobody 199 - EOF 200 - ``` 201 - 202 - Make sure to restart your SSH server! 203 - 204 - #### MOTD (message of the day) 205 - 206 - To configure the MOTD used ("Welcome to this knot!" by default), edit the 207 - `/home/git/motd` file: 208 - 209 - ``` 210 - printf "Hi from this knot!\n" > /home/git/motd 211 - ``` 212 - 213 - Note that you should add a newline at the end if setting a non-empty message 214 - since the knot won't do this for you.
+6
docs/logo.html
··· 1 + <div class="flex items-center gap-2 w-fit mx-auto"> 2 + <span class="w-16 h-16 [&>svg]:w-full [&>svg]:h-full text-black dark:text-white"> 3 + ${ dolly.svg() } 4 + </span> 5 + <span class="font-bold text-4xl not-italic text-black dark:text-white">tangled</span> 6 + </div>
-59
docs/migrations.md
··· 1 - # Migrations 2 - 3 - This document is laid out in reverse-chronological order. 4 - Newer migration guides are listed first, and older guides 5 - are further down the page. 6 - 7 - ## Upgrading from v1.8.x 8 - 9 - After v1.8.2, the HTTP API for knot and spindles have been 10 - deprecated and replaced with XRPC. Repositories on outdated 11 - knots will not be viewable from the appview. Upgrading is 12 - straightforward however. 13 - 14 - For knots: 15 - 16 - - Upgrade to latest tag (v1.9.0 or above) 17 - - Head to the [knot dashboard](https://tangled.org/knots) and 18 - hit the "retry" button to verify your knot 19 - 20 - For spindles: 21 - 22 - - Upgrade to latest tag (v1.9.0 or above) 23 - - Head to the [spindle 24 - dashboard](https://tangled.org/spindles) and hit the 25 - "retry" button to verify your spindle 26 - 27 - ## Upgrading from v1.7.x 28 - 29 - After v1.7.0, knot secrets have been deprecated. You no 30 - longer need a secret from the appview to run a knot. All 31 - authorized commands to knots are managed via [Inter-Service 32 - Authentication](https://atproto.com/specs/xrpc#inter-service-authentication-jwt). 33 - Knots will be read-only until upgraded. 34 - 35 - Upgrading is quite easy, in essence: 36 - 37 - - `KNOT_SERVER_SECRET` is no more, you can remove this 38 - environment variable entirely 39 - - `KNOT_SERVER_OWNER` is now required on boot, set this to 40 - your DID. You can find your DID in the 41 - [settings](https://tangled.org/settings) page. 42 - - Restart your knot once you have replaced the environment 43 - variable 44 - - Head to the [knot dashboard](https://tangled.org/knots) and 45 - hit the "retry" button to verify your knot. This simply 46 - writes a `sh.tangled.knot` record to your PDS. 47 - 48 - If you use the nix module, simply bump the flake to the 49 - latest revision, and change your config block like so: 50 - 51 - ```diff 52 - services.tangled-knot = { 53 - enable = true; 54 - server = { 55 - - secretFile = /path/to/secret; 56 - + owner = "did:plc:foo"; 57 - }; 58 - }; 59 - ```
+3
docs/mode.html
··· 1 + <a class="px-4 py-2 mt-8 block text-center w-full rounded-sm shadow-sm border border-gray-200 dark:border-gray-700 no-underline hover:no-underline" href="$if(single-page)$/$else$/single-page.html$endif$"> 2 + $if(single-page)$View as multi-page$else$View as single-page$endif$ 3 + </a>
+7
docs/search.html
··· 1 + <form action="https://google.com/search" role="search" aria-label="Sitewide" class="w-full"> 2 + <input type="hidden" name="q" value="+[inurl:https://docs.tangled.org]"> 3 + <label> 4 + <span style="display:none;">Search</span> 5 + <input type="text" name="q" placeholder="Search docs ..." class="w-full font-normal"> 6 + </label> 7 + </form>
-25
docs/spindle/architecture.md
··· 1 - # spindle architecture 2 - 3 - Spindle is a small CI runner service. Here's a high level overview of how it operates: 4 - 5 - * listens for [`sh.tangled.spindle.member`](/lexicons/spindle/member.json) and 6 - [`sh.tangled.repo`](/lexicons/repo.json) records on the Jetstream. 7 - * when a new repo record comes through (typically when you add a spindle to a 8 - repo from the settings), spindle then resolves the underlying knot and 9 - subscribes to repo events (see: 10 - [`sh.tangled.pipeline`](/lexicons/pipeline.json)). 11 - * the spindle engine then handles execution of the pipeline, with results and 12 - logs beamed on the spindle event stream over wss 13 - 14 - ### the engine 15 - 16 - At present, the only supported backend is Docker (and Podman, if Docker 17 - compatibility is enabled, so that `/run/docker.sock` is created). Spindle 18 - executes each step in the pipeline in a fresh container, with state persisted 19 - across steps within the `/tangled/workspace` directory. 20 - 21 - The base image for the container is constructed on the fly using 22 - [Nixery](https://nixery.dev), which is handy for caching layers for frequently 23 - used packages. 24 - 25 - The pipeline manifest is [specified here](/docs/spindle/pipeline.md).
-52
docs/spindle/hosting.md
··· 1 - # spindle self-hosting guide 2 - 3 - ## prerequisites 4 - 5 - * Go 6 - * Docker (the only supported backend currently) 7 - 8 - ## configuration 9 - 10 - Spindle is configured using environment variables. The following environment variables are available: 11 - 12 - * `SPINDLE_SERVER_LISTEN_ADDR`: The address the server listens on (default: `"0.0.0.0:6555"`). 13 - * `SPINDLE_SERVER_DB_PATH`: The path to the SQLite database file (default: `"spindle.db"`). 14 - * `SPINDLE_SERVER_HOSTNAME`: The hostname of the server (required). 15 - * `SPINDLE_SERVER_JETSTREAM_ENDPOINT`: The endpoint of the Jetstream server (default: `"wss://jetstream1.us-west.bsky.network/subscribe"`). 16 - * `SPINDLE_SERVER_DEV`: A boolean indicating whether the server is running in development mode (default: `false`). 17 - * `SPINDLE_SERVER_OWNER`: The DID of the owner (required). 18 - * `SPINDLE_PIPELINES_NIXERY`: The Nixery URL (default: `"nixery.tangled.sh"`). 19 - * `SPINDLE_PIPELINES_WORKFLOW_TIMEOUT`: The default workflow timeout (default: `"5m"`). 20 - * `SPINDLE_PIPELINES_LOG_DIR`: The directory to store workflow logs (default: `"/var/log/spindle"`). 21 - 22 - ## running spindle 23 - 24 - 1. **Set the environment variables.** For example: 25 - 26 - ```shell 27 - export SPINDLE_SERVER_HOSTNAME="your-hostname" 28 - export SPINDLE_SERVER_OWNER="your-did" 29 - ``` 30 - 31 - 2. **Build the Spindle binary.** 32 - 33 - ```shell 34 - cd core 35 - go mod download 36 - go build -o cmd/spindle/spindle cmd/spindle/main.go 37 - ``` 38 - 39 - 3. **Create the log directory.** 40 - 41 - ```shell 42 - sudo mkdir -p /var/log/spindle 43 - sudo chown $USER:$USER -R /var/log/spindle 44 - ``` 45 - 46 - 4. **Run the Spindle binary.** 47 - 48 - ```shell 49 - ./cmd/spindle/spindle 50 - ``` 51 - 52 - Spindle will now start, connect to the Jetstream server, and begin processing pipelines.
-285
docs/spindle/openbao.md
··· 1 - # spindle secrets with openbao 2 - 3 - This document covers setting up Spindle to use OpenBao for secrets 4 - management via OpenBao Proxy instead of the default SQLite backend. 5 - 6 - ## overview 7 - 8 - Spindle now uses OpenBao Proxy for secrets management. The proxy handles 9 - authentication automatically using AppRole credentials, while Spindle 10 - connects to the local proxy instead of directly to the OpenBao server. 11 - 12 - This approach provides better security, automatic token renewal, and 13 - simplified application code. 14 - 15 - ## installation 16 - 17 - Install OpenBao from nixpkgs: 18 - 19 - ```bash 20 - nix shell nixpkgs#openbao # for a local server 21 - ``` 22 - 23 - ## setup 24 - 25 - The setup process can is documented for both local development and production. 26 - 27 - ### local development 28 - 29 - Start OpenBao in dev mode: 30 - 31 - ```bash 32 - bao server -dev -dev-root-token-id="root" -dev-listen-address=127.0.0.1:8201 33 - ``` 34 - 35 - This starts OpenBao on `http://localhost:8201` with a root token. 36 - 37 - Set up environment for bao CLI: 38 - 39 - ```bash 40 - export BAO_ADDR=http://localhost:8200 41 - export BAO_TOKEN=root 42 - ``` 43 - 44 - ### production 45 - 46 - You would typically use a systemd service with a configuration file. Refer to 47 - [@tangled.org/infra](https://tangled.org/@tangled.org/infra) for how this can be 48 - achieved using Nix. 49 - 50 - Then, initialize the bao server: 51 - ```bash 52 - bao operator init -key-shares=1 -key-threshold=1 53 - ``` 54 - 55 - This will print out an unseal key and a root key. Save them somewhere (like a password manager). Then unseal the vault to begin setting it up: 56 - ```bash 57 - bao operator unseal <unseal_key> 58 - ``` 59 - 60 - All steps below remain the same across both dev and production setups. 61 - 62 - ### configure openbao server 63 - 64 - Create the spindle KV mount: 65 - 66 - ```bash 67 - bao secrets enable -path=spindle -version=2 kv 68 - ``` 69 - 70 - Set up AppRole authentication and policy: 71 - 72 - Create a policy file `spindle-policy.hcl`: 73 - 74 - ```hcl 75 - # Full access to spindle KV v2 data 76 - path "spindle/data/*" { 77 - capabilities = ["create", "read", "update", "delete"] 78 - } 79 - 80 - # Access to metadata for listing and management 81 - path "spindle/metadata/*" { 82 - capabilities = ["list", "read", "delete", "update"] 83 - } 84 - 85 - # Allow listing at root level 86 - path "spindle/" { 87 - capabilities = ["list"] 88 - } 89 - 90 - # Required for connection testing and health checks 91 - path "auth/token/lookup-self" { 92 - capabilities = ["read"] 93 - } 94 - ``` 95 - 96 - Apply the policy and create an AppRole: 97 - 98 - ```bash 99 - bao policy write spindle-policy spindle-policy.hcl 100 - bao auth enable approle 101 - bao write auth/approle/role/spindle \ 102 - token_policies="spindle-policy" \ 103 - token_ttl=1h \ 104 - token_max_ttl=4h \ 105 - bind_secret_id=true \ 106 - secret_id_ttl=0 \ 107 - secret_id_num_uses=0 108 - ``` 109 - 110 - Get the credentials: 111 - 112 - ```bash 113 - # Get role ID (static) 114 - ROLE_ID=$(bao read -field=role_id auth/approle/role/spindle/role-id) 115 - 116 - # Generate secret ID 117 - SECRET_ID=$(bao write -f -field=secret_id auth/approle/role/spindle/secret-id) 118 - 119 - echo "Role ID: $ROLE_ID" 120 - echo "Secret ID: $SECRET_ID" 121 - ``` 122 - 123 - ### create proxy configuration 124 - 125 - Create the credential files: 126 - 127 - ```bash 128 - # Create directory for OpenBao files 129 - mkdir -p /tmp/openbao 130 - 131 - # Save credentials 132 - echo "$ROLE_ID" > /tmp/openbao/role-id 133 - echo "$SECRET_ID" > /tmp/openbao/secret-id 134 - chmod 600 /tmp/openbao/role-id /tmp/openbao/secret-id 135 - ``` 136 - 137 - Create a proxy configuration file `/tmp/openbao/proxy.hcl`: 138 - 139 - ```hcl 140 - # OpenBao server connection 141 - vault { 142 - address = "http://localhost:8200" 143 - } 144 - 145 - # Auto-Auth using AppRole 146 - auto_auth { 147 - method "approle" { 148 - mount_path = "auth/approle" 149 - config = { 150 - role_id_file_path = "/tmp/openbao/role-id" 151 - secret_id_file_path = "/tmp/openbao/secret-id" 152 - } 153 - } 154 - 155 - # Optional: write token to file for debugging 156 - sink "file" { 157 - config = { 158 - path = "/tmp/openbao/token" 159 - mode = 0640 160 - } 161 - } 162 - } 163 - 164 - # Proxy listener for Spindle 165 - listener "tcp" { 166 - address = "127.0.0.1:8201" 167 - tls_disable = true 168 - } 169 - 170 - # Enable API proxy with auto-auth token 171 - api_proxy { 172 - use_auto_auth_token = true 173 - } 174 - 175 - # Enable response caching 176 - cache { 177 - use_auto_auth_token = true 178 - } 179 - 180 - # Logging 181 - log_level = "info" 182 - ``` 183 - 184 - ### start the proxy 185 - 186 - Start OpenBao Proxy: 187 - 188 - ```bash 189 - bao proxy -config=/tmp/openbao/proxy.hcl 190 - ``` 191 - 192 - The proxy will authenticate with OpenBao and start listening on 193 - `127.0.0.1:8201`. 194 - 195 - ### configure spindle 196 - 197 - Set these environment variables for Spindle: 198 - 199 - ```bash 200 - export SPINDLE_SERVER_SECRETS_PROVIDER=openbao 201 - export SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=http://127.0.0.1:8201 202 - export SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=spindle 203 - ``` 204 - 205 - Start Spindle: 206 - 207 - Spindle will now connect to the local proxy, which handles all 208 - authentication automatically. 209 - 210 - ## production setup for proxy 211 - 212 - For production, you'll want to run the proxy as a service: 213 - 214 - Place your production configuration in `/etc/openbao/proxy.hcl` with 215 - proper TLS settings for the vault connection. 216 - 217 - ## verifying setup 218 - 219 - Test the proxy directly: 220 - 221 - ```bash 222 - # Check proxy health 223 - curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/sys/health 224 - 225 - # Test token lookup through proxy 226 - curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/auth/token/lookup-self 227 - ``` 228 - 229 - Test OpenBao operations through the server: 230 - 231 - ```bash 232 - # List all secrets 233 - bao kv list spindle/ 234 - 235 - # Add a test secret via Spindle API, then check it exists 236 - bao kv list spindle/repos/ 237 - 238 - # Get a specific secret 239 - bao kv get spindle/repos/your_repo_path/SECRET_NAME 240 - ``` 241 - 242 - ## how it works 243 - 244 - - Spindle connects to OpenBao Proxy on localhost (typically port 8200 or 8201) 245 - - The proxy authenticates with OpenBao using AppRole credentials 246 - - All Spindle requests go through the proxy, which injects authentication tokens 247 - - Secrets are stored at `spindle/repos/{sanitized_repo_path}/{secret_key}` 248 - - Repository paths like `did:plc:alice/myrepo` become `did_plc_alice_myrepo` 249 - - The proxy handles all token renewal automatically 250 - - Spindle no longer manages tokens or authentication directly 251 - 252 - ## troubleshooting 253 - 254 - **Connection refused**: Check that the OpenBao Proxy is running and 255 - listening on the configured address. 256 - 257 - **403 errors**: Verify the AppRole credentials are correct and the policy 258 - has the necessary permissions. 259 - 260 - **404 route errors**: The spindle KV mount probably doesn't exist - run 261 - the mount creation step again. 262 - 263 - **Proxy authentication failures**: Check the proxy logs and verify the 264 - role-id and secret-id files are readable and contain valid credentials. 265 - 266 - **Secret not found after writing**: This can indicate policy permission 267 - issues. Verify the policy includes both `spindle/data/*` and 268 - `spindle/metadata/*` paths with appropriate capabilities. 269 - 270 - Check proxy logs: 271 - 272 - ```bash 273 - # If running as systemd service 274 - journalctl -u openbao-proxy -f 275 - 276 - # If running directly, check the console output 277 - ``` 278 - 279 - Test AppRole authentication manually: 280 - 281 - ```bash 282 - bao write auth/approle/login \ 283 - role_id="$(cat /tmp/openbao/role-id)" \ 284 - secret_id="$(cat /tmp/openbao/secret-id)" 285 - ```
-165
docs/spindle/pipeline.md
··· 1 - # spindle pipelines 2 - 3 - Spindle workflows allow you to write CI/CD pipelines in a simple format. They're located in the `.tangled/workflows` directory at the root of your repository, and are defined using YAML. 4 - 5 - The fields are: 6 - 7 - - [Trigger](#trigger): A **required** field that defines when a workflow should be triggered. 8 - - [Engine](#engine): A **required** field that defines which engine a workflow should run on. 9 - - [Clone options](#clone-options): An **optional** field that defines how the repository should be cloned. 10 - - [Dependencies](#dependencies): An **optional** field that allows you to list dependencies you may need. 11 - - [Environment](#environment): An **optional** field that allows you to define environment variables. 12 - - [Steps](#steps): An **optional** field that allows you to define what steps should run in the workflow. 13 - 14 - ## Trigger 15 - 16 - The first thing to add to a workflow is the trigger, which defines when a workflow runs. This is defined using a `when` field, which takes in a list of conditions. Each condition has the following fields: 17 - 18 - - `event`: This is a **required** field that defines when your workflow should run. It's a list that can take one or more of the following values: 19 - - `push`: The workflow should run every time a commit is pushed to the repository. 20 - - `pull_request`: The workflow should run every time a pull request is made or updated. 21 - - `manual`: The workflow can be triggered manually. 22 - - `branch`: This is a **required** field that defines which branches the workflow should run for. If used with the `push` event, commits to the branch(es) listed here will trigger the workflow. If used with the `pull_request` event, updates to pull requests targeting the branch(es) listed here will trigger the workflow. This field has no effect with the `manual` event. 23 - 24 - For example, if you'd like to define a workflow that runs when commits are pushed to the `main` and `develop` branches, or when pull requests that target the `main` branch are updated, or manually, you can do so with: 25 - 26 - ```yaml 27 - when: 28 - - event: ["push", "manual"] 29 - branch: ["main", "develop"] 30 - - event: ["pull_request"] 31 - branch: ["main"] 32 - ``` 33 - 34 - ## Engine 35 - 36 - Next is the engine on which the workflow should run, defined using the **required** `engine` field. The currently supported engines are: 37 - 38 - - `nixery`: This uses an instance of [Nixery](https://nixery.dev) to run steps, which allows you to add [dependencies](#dependencies) from [Nixpkgs](https://github.com/NixOS/nixpkgs). You can search for packages on https://search.nixos.org, and there's a pretty good chance the package(s) you're looking for will be there. 39 - 40 - Example: 41 - 42 - ```yaml 43 - engine: "nixery" 44 - ``` 45 - 46 - ## Clone options 47 - 48 - When a workflow starts, the first step is to clone the repository. You can customize this behavior using the **optional** `clone` field. It has the following fields: 49 - 50 - - `skip`: Setting this to `true` will skip cloning the repository. This can be useful if your workflow is doing something that doesn't require anything from the repository itself. This is `false` by default. 51 - - `depth`: This sets the number of commits, or the "clone depth", to fetch from the repository. For example, if you set this to 2, the last 2 commits will be fetched. By default, the depth is set to 1, meaning only the most recent commit will be fetched, which is the commit that triggered the workflow. 52 - - `submodules`: If you use [git submodules](https://git-scm.com/book/en/v2/Git-Tools-Submodules) in your repository, setting this field to `true` will recursively fetch all submodules. This is `false` by default. 53 - 54 - The default settings are: 55 - 56 - ```yaml 57 - clone: 58 - skip: false 59 - depth: 1 60 - submodules: false 61 - ``` 62 - 63 - ## Dependencies 64 - 65 - Usually when you're running a workflow, you'll need additional dependencies. The `dependencies` field lets you define which dependencies to get, and from where. It's a key-value map, with the key being the registry to fetch dependencies from, and the value being the list of dependencies to fetch. 66 - 67 - Say you want to fetch Node.js and Go from `nixpkgs`, and a package called `my_pkg` you've made from your own registry at your repository at `https://tangled.sh/@example.com/my_pkg`. You can define those dependencies like so: 68 - 69 - ```yaml 70 - dependencies: 71 - # nixpkgs 72 - nixpkgs: 73 - - nodejs 74 - - go 75 - # custom registry 76 - git+https://tangled.org/@example.com/my_pkg: 77 - - my_pkg 78 - ``` 79 - 80 - Now these dependencies are available to use in your workflow! 81 - 82 - ## Environment 83 - 84 - The `environment` field allows you define environment variables that will be available throughout the entire workflow. **Do not put secrets here, these environment variables are visible to anyone viewing the repository. You can add secrets for pipelines in your repository's settings.** 85 - 86 - Example: 87 - 88 - ```yaml 89 - environment: 90 - GOOS: "linux" 91 - GOARCH: "arm64" 92 - NODE_ENV: "production" 93 - MY_ENV_VAR: "MY_ENV_VALUE" 94 - ``` 95 - 96 - ## Steps 97 - 98 - The `steps` field allows you to define what steps should run in the workflow. It's a list of step objects, each with the following fields: 99 - 100 - - `name`: This field allows you to give your step a name. This name is visible in your workflow runs, and is used to describe what the step is doing. 101 - - `command`: This field allows you to define a command to run in that step. The step is run in a Bash shell, and the logs from the command will be visible in the pipelines page on the Tangled website. The [dependencies](#dependencies) you added will be available to use here. 102 - - `environment`: Similar to the global [environment](#environment) config, this **optional** field is a key-value map that allows you to set environment variables for the step. **Do not put secrets here, these environment variables are visible to anyone viewing the repository. You can add secrets for pipelines in your repository's settings.** 103 - 104 - Example: 105 - 106 - ```yaml 107 - steps: 108 - - name: "Build backend" 109 - command: "go build" 110 - environment: 111 - GOOS: "darwin" 112 - GOARCH: "arm64" 113 - - name: "Build frontend" 114 - command: "npm run build" 115 - environment: 116 - NODE_ENV: "production" 117 - ``` 118 - 119 - ## Complete workflow 120 - 121 - ```yaml 122 - # .tangled/workflows/build.yml 123 - 124 - when: 125 - - event: ["push", "manual"] 126 - branch: ["main", "develop"] 127 - - event: ["pull_request"] 128 - branch: ["main"] 129 - 130 - engine: "nixery" 131 - 132 - # using the default values 133 - clone: 134 - skip: false 135 - depth: 1 136 - submodules: false 137 - 138 - dependencies: 139 - # nixpkgs 140 - nixpkgs: 141 - - nodejs 142 - - go 143 - # custom registry 144 - git+https://tangled.org/@example.com/my_pkg: 145 - - my_pkg 146 - 147 - environment: 148 - GOOS: "linux" 149 - GOARCH: "arm64" 150 - NODE_ENV: "production" 151 - MY_ENV_VAR: "MY_ENV_VALUE" 152 - 153 - steps: 154 - - name: "Build backend" 155 - command: "go build" 156 - environment: 157 - GOOS: "darwin" 158 - GOARCH: "arm64" 159 - - name: "Build frontend" 160 - command: "npm run build" 161 - environment: 162 - NODE_ENV: "production" 163 - ``` 164 - 165 - If you want another example of a workflow, you can look at the one [Tangled uses to build the project](https://tangled.sh/@tangled.sh/core/blob/master/.tangled/workflows/build.yml).
+101
docs/styles.css
··· 1 + svg { 2 + width: 16px; 3 + height: 16px; 4 + } 5 + 6 + :root { 7 + --syntax-alert: #d20f39; 8 + --syntax-annotation: #fe640b; 9 + --syntax-attribute: #df8e1d; 10 + --syntax-basen: #40a02b; 11 + --syntax-builtin: #1e66f5; 12 + --syntax-controlflow: #8839ef; 13 + --syntax-char: #04a5e5; 14 + --syntax-constant: #fe640b; 15 + --syntax-comment: #9ca0b0; 16 + --syntax-commentvar: #7c7f93; 17 + --syntax-documentation: #9ca0b0; 18 + --syntax-datatype: #df8e1d; 19 + --syntax-decval: #40a02b; 20 + --syntax-error: #d20f39; 21 + --syntax-extension: #4c4f69; 22 + --syntax-float: #40a02b; 23 + --syntax-function: #1e66f5; 24 + --syntax-import: #40a02b; 25 + --syntax-information: #04a5e5; 26 + --syntax-keyword: #8839ef; 27 + --syntax-operator: #179299; 28 + --syntax-other: #8839ef; 29 + --syntax-preprocessor: #ea76cb; 30 + --syntax-specialchar: #04a5e5; 31 + --syntax-specialstring: #ea76cb; 32 + --syntax-string: #40a02b; 33 + --syntax-variable: #8839ef; 34 + --syntax-verbatimstring: #40a02b; 35 + --syntax-warning: #df8e1d; 36 + } 37 + 38 + @media (prefers-color-scheme: dark) { 39 + :root { 40 + --syntax-alert: #f38ba8; 41 + --syntax-annotation: #fab387; 42 + --syntax-attribute: #f9e2af; 43 + --syntax-basen: #a6e3a1; 44 + --syntax-builtin: #89b4fa; 45 + --syntax-controlflow: #cba6f7; 46 + --syntax-char: #89dceb; 47 + --syntax-constant: #fab387; 48 + --syntax-comment: #6c7086; 49 + --syntax-commentvar: #585b70; 50 + --syntax-documentation: #6c7086; 51 + --syntax-datatype: #f9e2af; 52 + --syntax-decval: #a6e3a1; 53 + --syntax-error: #f38ba8; 54 + --syntax-extension: #cdd6f4; 55 + --syntax-float: #a6e3a1; 56 + --syntax-function: #89b4fa; 57 + --syntax-import: #a6e3a1; 58 + --syntax-information: #89dceb; 59 + --syntax-keyword: #cba6f7; 60 + --syntax-operator: #94e2d5; 61 + --syntax-other: #cba6f7; 62 + --syntax-preprocessor: #f5c2e7; 63 + --syntax-specialchar: #89dceb; 64 + --syntax-specialstring: #f5c2e7; 65 + --syntax-string: #a6e3a1; 66 + --syntax-variable: #cba6f7; 67 + --syntax-verbatimstring: #a6e3a1; 68 + --syntax-warning: #f9e2af; 69 + } 70 + } 71 + 72 + /* pandoc syntax highlighting classes */ 73 + code span.al { color: var(--syntax-alert); font-weight: bold; } /* alert */ 74 + code span.an { color: var(--syntax-annotation); font-weight: bold; font-style: italic; } /* annotation */ 75 + code span.at { color: var(--syntax-attribute); } /* attribute */ 76 + code span.bn { color: var(--syntax-basen); } /* basen */ 77 + code span.bu { color: var(--syntax-builtin); } /* builtin */ 78 + code span.cf { color: var(--syntax-controlflow); font-weight: bold; } /* controlflow */ 79 + code span.ch { color: var(--syntax-char); } /* char */ 80 + code span.cn { color: var(--syntax-constant); } /* constant */ 81 + code span.co { color: var(--syntax-comment); font-style: italic; } /* comment */ 82 + code span.cv { color: var(--syntax-commentvar); font-weight: bold; font-style: italic; } /* commentvar */ 83 + code span.do { color: var(--syntax-documentation); font-style: italic; } /* documentation */ 84 + code span.dt { color: var(--syntax-datatype); } /* datatype */ 85 + code span.dv { color: var(--syntax-decval); } /* decval */ 86 + code span.er { color: var(--syntax-error); font-weight: bold; } /* error */ 87 + code span.ex { color: var(--syntax-extension); } /* extension */ 88 + code span.fl { color: var(--syntax-float); } /* float */ 89 + code span.fu { color: var(--syntax-function); } /* function */ 90 + code span.im { color: var(--syntax-import); font-weight: bold; } /* import */ 91 + code span.in { color: var(--syntax-information); font-weight: bold; font-style: italic; } /* information */ 92 + code span.kw { color: var(--syntax-keyword); font-weight: bold; } /* keyword */ 93 + code span.op { color: var(--syntax-operator); } /* operator */ 94 + code span.ot { color: var(--syntax-other); } /* other */ 95 + code span.pp { color: var(--syntax-preprocessor); } /* preprocessor */ 96 + code span.sc { color: var(--syntax-specialchar); } /* specialchar */ 97 + code span.ss { color: var(--syntax-specialstring); } /* specialstring */ 98 + code span.st { color: var(--syntax-string); } /* string */ 99 + code span.va { color: var(--syntax-variable); } /* variable */ 100 + code span.vs { color: var(--syntax-verbatimstring); } /* verbatimstring */ 101 + code span.wa { color: var(--syntax-warning); font-weight: bold; font-style: italic; } /* warning */
+158
docs/template.html
··· 1 + <!DOCTYPE html> 2 + <html xmlns="http://www.w3.org/1999/xhtml" lang="$lang$" xml:lang="$lang$"$if(dir)$ dir="$dir$"$endif$> 3 + <head> 4 + <meta charset="utf-8" /> 5 + <meta name="generator" content="pandoc" /> 6 + <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes" /> 7 + $for(author-meta)$ 8 + <meta name="author" content="$author-meta$" /> 9 + $endfor$ 10 + 11 + $if(date-meta)$ 12 + <meta name="dcterms.date" content="$date-meta$" /> 13 + $endif$ 14 + 15 + $if(keywords)$ 16 + <meta name="keywords" content="$for(keywords)$$keywords$$sep$, $endfor$" /> 17 + $endif$ 18 + 19 + $if(description-meta)$ 20 + <meta name="description" content="$description-meta$" /> 21 + $endif$ 22 + 23 + <title>$pagetitle$</title> 24 + 25 + <style> 26 + $styles.css()$ 27 + </style> 28 + 29 + $for(css)$ 30 + <link rel="stylesheet" href="$css$" /> 31 + $endfor$ 32 + 33 + $for(header-includes)$ 34 + $header-includes$ 35 + $endfor$ 36 + 37 + <link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin /> 38 + 39 + </head> 40 + <body class="bg-white dark:bg-gray-900 flex flex-col min-h-svh"> 41 + $for(include-before)$ 42 + $include-before$ 43 + $endfor$ 44 + 45 + $if(toc)$ 46 + <!-- mobile TOC trigger --> 47 + <div class="md:hidden px-6 py-4 border-b border-gray-200 dark:border-gray-700"> 48 + <button 49 + type="button" 50 + popovertarget="mobile-toc-popover" 51 + popovertargetaction="toggle" 52 + class="w-full flex gap-2 items-center text-sm font-semibold dark:text-white" 53 + > 54 + ${ menu.svg() } 55 + $if(toc-title)$$toc-title$$else$Table of Contents$endif$ 56 + </button> 57 + </div> 58 + 59 + <div 60 + id="mobile-toc-popover" 61 + popover 62 + class="mobile-toc-popover 63 + bg-gray-50 dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 64 + h-full overflow-y-auto shadow-sm 65 + px-6 py-4 fixed inset-x-0 top-0 w-fit max-w-4/5 m-0" 66 + > 67 + <div class="flex flex-col min-h-full"> 68 + <div class="flex-1 space-y-4"> 69 + <button 70 + type="button" 71 + popovertarget="mobile-toc-popover" 72 + popovertargetaction="toggle" 73 + class="w-full flex gap-2 items-center text-sm font-semibold dark:text-white mb-4"> 74 + ${ x.svg() } 75 + $if(toc-title)$$toc-title$$else$Table of Contents$endif$ 76 + </button> 77 + ${ logo.html() } 78 + ${ search.html() } 79 + ${ table-of-contents:toc.html() } 80 + </div> 81 + ${ single-page:mode.html() } 82 + </div> 83 + </div> 84 + 85 + <!-- desktop sidebar toc --> 86 + <nav 87 + id="$idprefix$TOC" 88 + role="doc-toc" 89 + class="hidden md:flex md:flex-col gap-4 fixed left-0 top-0 w-80 h-screen 90 + bg-gray-50 dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 91 + p-4 z-50 overflow-y-auto"> 92 + ${ logo.html() } 93 + ${ search.html() } 94 + <div class="flex-1"> 95 + $if(toc-title)$ 96 + <h2 id="$idprefix$toc-title" class="text-lg font-semibold mb-4 text-gray-900">$toc-title$</h2> 97 + $endif$ 98 + ${ table-of-contents:toc.html() } 99 + </div> 100 + ${ single-page:mode.html() } 101 + </nav> 102 + $endif$ 103 + 104 + <div class="$if(toc)$md:ml-80$endif$ flex-1 flex flex-col"> 105 + <main class="max-w-4xl w-full mx-auto p-6 flex-1"> 106 + $if(top)$ 107 + $-- only print title block if this is NOT the top page 108 + $else$ 109 + $if(title)$ 110 + <header id="title-block-header" class="mb-8 pb-8 border-b border-gray-200 dark:border-gray-700"> 111 + <h1 class="text-4xl font-bold mb-2 text-black dark:text-white">$title$</h1> 112 + $if(subtitle)$ 113 + <p class="text-xl text-gray-500 dark:text-gray-400 mb-2">$subtitle$</p> 114 + $endif$ 115 + $for(author)$ 116 + <p class="text-sm text-gray-500 dark:text-gray-400">$author$</p> 117 + $endfor$ 118 + $if(date)$ 119 + <p class="text-sm text-gray-500 dark:text-gray-400">Updated on $date$</p> 120 + $endif$ 121 + $endif$ 122 + </header> 123 + $endif$ 124 + 125 + $if(abstract)$ 126 + <article class="prose dark:prose-invert max-w-none"> 127 + $abstract$ 128 + </article> 129 + $endif$ 130 + 131 + <article class="prose dark:prose-invert max-w-none"> 132 + $body$ 133 + </article> 134 + </main> 135 + <nav id="sitenav" class="border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800"> 136 + <div class="max-w-4xl mx-auto px-8 py-4"> 137 + <div class="flex justify-between gap-4"> 138 + <span class="flex-1"> 139 + $if(previous.url)$ 140 + <span class="text-xs text-gray-500 dark:text-gray-400 uppercase block mb-1">Previous</span> 141 + <a href="$previous.url$" accesskey="p" rel="previous">$previous.title$</a> 142 + $endif$ 143 + </span> 144 + <span class="flex-1 text-right"> 145 + $if(next.url)$ 146 + <span class="text-xs text-gray-500 dark:text-gray-400 uppercase block mb-1">Next</span> 147 + <a href="$next.url$" accesskey="n" rel="next">$next.title$</a> 148 + $endif$ 149 + </span> 150 + </div> 151 + </div> 152 + </nav> 153 + </div> 154 + $for(include-after)$ 155 + $include-after$ 156 + $endfor$ 157 + </body> 158 + </html>
+4
docs/toc.html
··· 1 + <div class="[&_ul]:space-y-6 [&_ul]:pl-0 [&_ul]:font-bold [&_ul_ul]:pl-4 [&_ul_ul]:font-normal [&_ul_ul]:space-y-2 [&_li]:space-y-2"> 2 + $table-of-contents$ 3 + </div> 4 +
+26 -9
flake.lock
··· 1 1 { 2 2 "nodes": { 3 + "actor-typeahead-src": { 4 + "flake": false, 5 + "locked": { 6 + "lastModified": 1762835797, 7 + "narHash": "sha256-heizoWUKDdar6ymfZTnj3ytcEv/L4d4fzSmtr0HlXsQ=", 8 + "ref": "refs/heads/main", 9 + "rev": "677fe7f743050a4e7f09d4a6f87bbf1325a06f6b", 10 + "revCount": 6, 11 + "type": "git", 12 + "url": "https://tangled.org/@jakelazaroff.com/actor-typeahead" 13 + }, 14 + "original": { 15 + "type": "git", 16 + "url": "https://tangled.org/@jakelazaroff.com/actor-typeahead" 17 + } 18 + }, 3 19 "flake-compat": { 4 20 "flake": false, 5 21 "locked": { ··· 19 35 "systems": "systems" 20 36 }, 21 37 "locked": { 22 - "lastModified": 1694529238, 23 - "narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=", 38 + "lastModified": 1731533236, 39 + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 24 40 "owner": "numtide", 25 41 "repo": "flake-utils", 26 - "rev": "ff7b65b44d01cf9ba6a71320833626af21126384", 42 + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 27 43 "type": "github" 28 44 }, 29 45 "original": { ··· 40 56 ] 41 57 }, 42 58 "locked": { 43 - "lastModified": 1754078208, 44 - "narHash": "sha256-YVoIFDCDpYuU3riaDEJ3xiGdPOtsx4sR5eTzHTytPV8=", 59 + "lastModified": 1763982521, 60 + "narHash": "sha256-ur4QIAHwgFc0vXiaxn5No/FuZicxBr2p0gmT54xZkUQ=", 45 61 "owner": "nix-community", 46 62 "repo": "gomod2nix", 47 - "rev": "7f963246a71626c7fc70b431a315c4388a0c95cf", 63 + "rev": "02e63a239d6eabd595db56852535992c898eba72", 48 64 "type": "github" 49 65 }, 50 66 "original": { ··· 134 150 }, 135 151 "nixpkgs": { 136 152 "locked": { 137 - "lastModified": 1751984180, 138 - "narHash": "sha256-LwWRsENAZJKUdD3SpLluwDmdXY9F45ZEgCb0X+xgOL0=", 153 + "lastModified": 1766070988, 154 + "narHash": "sha256-G/WVghka6c4bAzMhTwT2vjLccg/awmHkdKSd2JrycLc=", 139 155 "owner": "nixos", 140 156 "repo": "nixpkgs", 141 - "rev": "9807714d6944a957c2e036f84b0ff8caf9930bc0", 157 + "rev": "c6245e83d836d0433170a16eb185cefe0572f8b8", 142 158 "type": "github" 143 159 }, 144 160 "original": { ··· 150 166 }, 151 167 "root": { 152 168 "inputs": { 169 + "actor-typeahead-src": "actor-typeahead-src", 153 170 "flake-compat": "flake-compat", 154 171 "gomod2nix": "gomod2nix", 155 172 "htmx-src": "htmx-src",
+37 -15
flake.nix
··· 33 33 url = "https://github.com/rsms/inter/releases/download/v4.1/Inter-4.1.zip"; 34 34 flake = false; 35 35 }; 36 + actor-typeahead-src = { 37 + url = "git+https://tangled.org/@jakelazaroff.com/actor-typeahead"; 38 + flake = false; 39 + }; 36 40 ibm-plex-mono-src = { 37 41 url = "https://github.com/IBM/plex/releases/download/%40ibm%2Fplex-mono%401.1.0/ibm-plex-mono.zip"; 38 42 flake = false; ··· 54 58 inter-fonts-src, 55 59 sqlite-lib-src, 56 60 ibm-plex-mono-src, 61 + actor-typeahead-src, 57 62 ... 58 63 }: let 59 64 supportedSystems = ["x86_64-linux" "x86_64-darwin" "aarch64-linux" "aarch64-darwin"]; ··· 71 76 }; 72 77 buildGoApplication = 73 78 (self.callPackage "${gomod2nix}/builder" { 74 - gomod2nix = gomod2nix.legacyPackages.${pkgs.system}.gomod2nix; 79 + gomod2nix = gomod2nix.legacyPackages.${pkgs.stdenv.hostPlatform.system}.gomod2nix; 75 80 }).buildGoApplication; 76 81 modules = ./nix/gomod2nix.toml; 77 82 sqlite-lib = self.callPackage ./nix/pkgs/sqlite-lib.nix { 78 - inherit (pkgs) gcc; 79 83 inherit sqlite-lib-src; 80 84 }; 81 85 lexgen = self.callPackage ./nix/pkgs/lexgen.nix {inherit indigo;}; 82 86 goat = self.callPackage ./nix/pkgs/goat.nix {inherit indigo;}; 83 87 appview-static-files = self.callPackage ./nix/pkgs/appview-static-files.nix { 84 - inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src; 88 + inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src actor-typeahead-src; 85 89 }; 86 90 appview = self.callPackage ./nix/pkgs/appview.nix {}; 91 + docs = self.callPackage ./nix/pkgs/docs.nix { 92 + inherit inter-fonts-src ibm-plex-mono-src lucide-src; 93 + }; 87 94 spindle = self.callPackage ./nix/pkgs/spindle.nix {}; 88 95 knot-unwrapped = self.callPackage ./nix/pkgs/knot-unwrapped.nix {}; 89 96 knot = self.callPackage ./nix/pkgs/knot.nix {}; 97 + dolly = self.callPackage ./nix/pkgs/dolly.nix {}; 90 98 }); 91 99 in { 92 100 overlays.default = final: prev: { 93 - inherit (mkPackageSet final) lexgen goat sqlite-lib spindle knot-unwrapped knot appview; 101 + inherit (mkPackageSet final) lexgen goat sqlite-lib spindle knot-unwrapped knot appview docs dolly; 94 102 }; 95 103 96 104 packages = forAllSystems (system: let ··· 99 107 staticPackages = mkPackageSet pkgs.pkgsStatic; 100 108 crossPackages = mkPackageSet pkgs.pkgsCross.gnu64.pkgsStatic; 101 109 in { 102 - inherit (packages) appview appview-static-files lexgen goat spindle knot knot-unwrapped sqlite-lib; 110 + inherit 111 + (packages) 112 + appview 113 + appview-static-files 114 + lexgen 115 + goat 116 + spindle 117 + knot 118 + knot-unwrapped 119 + sqlite-lib 120 + docs 121 + dolly 122 + ; 103 123 104 124 pkgsStatic-appview = staticPackages.appview; 105 125 pkgsStatic-knot = staticPackages.knot; 106 126 pkgsStatic-knot-unwrapped = staticPackages.knot-unwrapped; 107 127 pkgsStatic-spindle = staticPackages.spindle; 108 128 pkgsStatic-sqlite-lib = staticPackages.sqlite-lib; 129 + pkgsStatic-dolly = staticPackages.dolly; 109 130 110 131 pkgsCross-gnu64-pkgsStatic-appview = crossPackages.appview; 111 132 pkgsCross-gnu64-pkgsStatic-knot = crossPackages.knot; 112 133 pkgsCross-gnu64-pkgsStatic-knot-unwrapped = crossPackages.knot-unwrapped; 113 134 pkgsCross-gnu64-pkgsStatic-spindle = crossPackages.spindle; 135 + pkgsCross-gnu64-pkgsStatic-dolly = crossPackages.dolly; 114 136 115 137 treefmt-wrapper = pkgs.treefmt.withConfig { 116 138 settings.formatter = { ··· 151 173 nativeBuildInputs = [ 152 174 pkgs.go 153 175 pkgs.air 154 - pkgs.tilt 155 176 pkgs.gopls 156 177 pkgs.httpie 157 178 pkgs.litecli ··· 179 200 air-watcher = name: arg: 180 201 pkgs.writeShellScriptBin "run" 181 202 '' 182 - ${pkgs.air}/bin/air -c /dev/null \ 183 - -build.cmd "${pkgs.go}/bin/go build -o ./out/${name}.out ./cmd/${name}/main.go" \ 184 - -build.bin "./out/${name}.out" \ 185 - -build.args_bin "${arg}" \ 186 - -build.stop_on_error "true" \ 187 - -build.include_ext "go" 203 + export PATH=${pkgs.go}/bin:$PATH 204 + ${pkgs.air}/bin/air -c ./.air/${name}.toml \ 205 + -build.args_bin "${arg}" 188 206 ''; 189 207 tailwind-watcher = 190 208 pkgs.writeShellScriptBin "run" ··· 207 225 watch-knot = { 208 226 type = "app"; 209 227 program = ''${air-watcher "knot" "server"}/bin/run''; 228 + }; 229 + watch-spindle = { 230 + type = "app"; 231 + program = ''${air-watcher "spindle" ""}/bin/run''; 210 232 }; 211 233 watch-tailwind = { 212 234 type = "app"; ··· 279 301 }: { 280 302 imports = [./nix/modules/appview.nix]; 281 303 282 - services.tangled-appview.package = lib.mkDefault self.packages.${pkgs.system}.appview; 304 + services.tangled.appview.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.appview; 283 305 }; 284 306 nixosModules.knot = { 285 307 lib, ··· 288 310 }: { 289 311 imports = [./nix/modules/knot.nix]; 290 312 291 - services.tangled-knot.package = lib.mkDefault self.packages.${pkgs.system}.knot; 313 + services.tangled.knot.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.knot; 292 314 }; 293 315 nixosModules.spindle = { 294 316 lib, ··· 297 319 }: { 298 320 imports = [./nix/modules/spindle.nix]; 299 321 300 - services.tangled-spindle.package = lib.mkDefault self.packages.${pkgs.system}.spindle; 322 + services.tangled.spindle.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.spindle; 301 323 }; 302 324 }; 303 325 }
+7 -16
go.mod
··· 1 1 module tangled.org/core 2 2 3 - go 1.24.4 3 + go 1.25.0 4 4 5 5 require ( 6 6 github.com/Blank-Xu/sql-adapter v1.1.1 7 7 github.com/alecthomas/assert/v2 v2.11.0 8 8 github.com/alecthomas/chroma/v2 v2.15.0 9 9 github.com/avast/retry-go/v4 v4.6.1 10 + github.com/blevesearch/bleve/v2 v2.5.3 10 11 github.com/bluekeyes/go-gitdiff v0.8.1 11 12 github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e 12 13 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 14 + github.com/bmatcuk/doublestar/v4 v4.9.1 13 15 github.com/carlmjohnson/versioninfo v0.22.5 14 16 github.com/casbin/casbin/v2 v2.103.0 17 + github.com/charmbracelet/log v0.4.2 15 18 github.com/cloudflare/cloudflare-go v0.115.0 16 19 github.com/cyphar/filepath-securejoin v0.4.1 17 20 github.com/dgraph-io/ristretto v0.2.0 ··· 29 32 github.com/hiddeco/sshsig v0.2.0 30 33 github.com/hpcloud/tail v1.0.0 31 34 github.com/ipfs/go-cid v0.5.0 32 - github.com/lestrrat-go/jwx/v2 v2.1.6 33 35 github.com/mattn/go-sqlite3 v1.14.24 34 36 github.com/microcosm-cc/bluemonday v1.0.27 35 37 github.com/openbao/openbao/api/v2 v2.3.0 ··· 42 44 github.com/stretchr/testify v1.10.0 43 45 github.com/urfave/cli/v3 v3.3.3 44 46 github.com/whyrusleeping/cbor-gen v0.3.1 45 - github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57 46 47 github.com/yuin/goldmark v1.7.13 48 + github.com/yuin/goldmark-emoji v1.0.6 47 49 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc 50 + gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab 48 51 golang.org/x/crypto v0.40.0 49 52 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b 50 53 golang.org/x/image v0.31.0 51 54 golang.org/x/net v0.42.0 52 - golang.org/x/sync v0.17.0 53 55 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da 54 56 gopkg.in/yaml.v3 v3.0.1 55 57 ) ··· 65 67 github.com/aymerick/douceur v0.2.0 // indirect 66 68 github.com/beorn7/perks v1.0.1 // indirect 67 69 github.com/bits-and-blooms/bitset v1.22.0 // indirect 68 - github.com/blevesearch/bleve/v2 v2.5.3 // indirect 69 70 github.com/blevesearch/bleve_index_api v1.2.8 // indirect 70 71 github.com/blevesearch/geo v0.2.4 // indirect 71 72 github.com/blevesearch/go-faiss v1.0.25 // indirect ··· 83 84 github.com/blevesearch/zapx/v14 v14.4.2 // indirect 84 85 github.com/blevesearch/zapx/v15 v15.4.2 // indirect 85 86 github.com/blevesearch/zapx/v16 v16.2.4 // indirect 86 - github.com/bmatcuk/doublestar/v4 v4.7.1 // indirect 87 87 github.com/casbin/govaluate v1.3.0 // indirect 88 88 github.com/cenkalti/backoff/v4 v4.3.0 // indirect 89 89 github.com/cespare/xxhash/v2 v2.3.0 // indirect 90 90 github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect 91 91 github.com/charmbracelet/lipgloss v1.1.0 // indirect 92 - github.com/charmbracelet/log v0.4.2 // indirect 93 92 github.com/charmbracelet/x/ansi v0.8.0 // indirect 94 93 github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect 95 94 github.com/charmbracelet/x/term v0.2.1 // indirect ··· 98 97 github.com/containerd/errdefs/pkg v0.3.0 // indirect 99 98 github.com/containerd/log v0.1.0 // indirect 100 99 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 101 - github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect 102 100 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 103 101 github.com/distribution/reference v0.6.0 // indirect 104 102 github.com/dlclark/regexp2 v1.11.5 // indirect ··· 152 150 github.com/kevinburke/ssh_config v1.2.0 // indirect 153 151 github.com/klauspost/compress v1.18.0 // indirect 154 152 github.com/klauspost/cpuid/v2 v2.3.0 // indirect 155 - github.com/lestrrat-go/blackmagic v1.0.4 // indirect 156 - github.com/lestrrat-go/httpcc v1.0.1 // indirect 157 - github.com/lestrrat-go/httprc v1.0.6 // indirect 158 - github.com/lestrrat-go/iter v1.0.2 // indirect 159 - github.com/lestrrat-go/option v1.0.1 // indirect 160 153 github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 161 154 github.com/mattn/go-isatty v0.0.20 // indirect 162 155 github.com/mattn/go-runewidth v0.0.16 // indirect ··· 191 184 github.com/prometheus/procfs v0.16.1 // indirect 192 185 github.com/rivo/uniseg v0.4.7 // indirect 193 186 github.com/ryanuber/go-glob v1.0.0 // indirect 194 - github.com/segmentio/asm v1.2.0 // indirect 195 187 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect 196 188 github.com/spaolacci/murmur3 v1.1.0 // indirect 197 189 github.com/vmihailenco/go-tinylfu v0.2.2 // indirect 198 190 github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect 199 191 github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect 200 - github.com/wyatt915/treeblood v0.1.15 // indirect 201 192 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 202 - gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab // indirect 203 193 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 204 194 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 205 195 go.etcd.io/bbolt v1.4.0 // indirect ··· 213 203 go.uber.org/atomic v1.11.0 // indirect 214 204 go.uber.org/multierr v1.11.0 // indirect 215 205 go.uber.org/zap v1.27.0 // indirect 206 + golang.org/x/sync v0.17.0 // indirect 216 207 golang.org/x/sys v0.34.0 // indirect 217 208 golang.org/x/text v0.29.0 // indirect 218 209 golang.org/x/time v0.12.0 // indirect
+4 -21
go.sum
··· 71 71 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA= 72 72 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4= 73 73 github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= 74 - github.com/bmatcuk/doublestar/v4 v4.7.1 h1:fdDeAqgT47acgwd9bd9HxJRDmc9UAmPpc+2m0CXv75Q= 75 74 github.com/bmatcuk/doublestar/v4 v4.7.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= 75 + github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE= 76 + github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= 76 77 github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= 77 78 github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= 78 79 github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= ··· 124 125 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 125 126 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 126 127 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 127 - github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= 128 - github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= 129 128 github.com/dgraph-io/ristretto v0.2.0 h1:XAfl+7cmoUDWW/2Lx8TGZQjjxIQ2Ley9DSf52dru4WE= 130 129 github.com/dgraph-io/ristretto v0.2.0/go.mod h1:8uBHCU/PBV4Ag0CJrP47b9Ofby5dqWNh4FicAdoqFNU= 131 130 github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WAFKLNi6ZS0675eEUC9y3AlwSbQu1Y= ··· 328 327 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 329 328 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 330 329 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 331 - github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA= 332 - github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= 333 - github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= 334 - github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= 335 - github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k= 336 - github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo= 337 - github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= 338 - github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= 339 - github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVfecA= 340 - github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU= 341 - github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= 342 - github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= 343 330 github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 344 331 github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 345 332 github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= ··· 464 451 github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 465 452 github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= 466 453 github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= 467 - github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= 468 - github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= 469 454 github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= 470 455 github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= 471 456 github.com/sethvargo/go-envconfig v1.1.0 h1:cWZiJxeTm7AlCvzGXrEXaSTCNgip5oJepekh/BOQuog= ··· 510 495 github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw= 511 496 github.com/whyrusleeping/cbor-gen v0.3.1 h1:82ioxmhEYut7LBVGhGq8xoRkXPLElVuh5mV67AFfdv0= 512 497 github.com/whyrusleeping/cbor-gen v0.3.1/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= 513 - github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57 h1:UqtQdzLXnvdBdqn/go53qGyncw1wJ7Mq5SQdieM1/Ew= 514 - github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57/go.mod h1:BxSCWByWSRSuembL3cDG1IBUbkBoO/oW/6tF19aA4hs= 515 - github.com/wyatt915/treeblood v0.1.15 h1:3KZ3o2LpcKZAzOLqMoW9qeUzKEaKArKpbcPpTkNfQC8= 516 - github.com/wyatt915/treeblood v0.1.15/go.mod h1:i7+yhhmzdDP17/97pIsOSffw74EK/xk+qJ0029cSXUY= 517 498 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 518 499 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 519 500 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= ··· 524 505 github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 525 506 github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= 526 507 github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 508 + github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= 509 + github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= 527 510 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ= 528 511 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I= 529 512 gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab h1:gK9tS6QJw5F0SIhYJnGG2P83kuabOdmWBbSmZhJkz2A=
+36 -61
guard/guard.go
··· 12 12 "os/exec" 13 13 "strings" 14 14 15 - "github.com/bluesky-social/indigo/atproto/identity" 16 15 securejoin "github.com/cyphar/filepath-securejoin" 17 16 "github.com/urfave/cli/v3" 18 - "tangled.org/core/idresolver" 19 17 "tangled.org/core/log" 20 18 ) 21 19 ··· 93 91 "command", sshCommand, 94 92 "client", clientIP) 95 93 94 + // TODO: greet user with their resolved handle instead of did 96 95 if sshCommand == "" { 97 96 l.Info("access denied: no interactive shells", "user", incomingUser) 98 97 fmt.Fprintf(os.Stderr, "Hi @%s! You've successfully authenticated.\n", incomingUser) ··· 107 106 } 108 107 109 108 gitCommand := cmdParts[0] 110 - 111 - // did:foo/repo-name or 112 - // handle/repo-name or 113 - // any of the above with a leading slash (/) 114 - 115 - components := strings.Split(strings.TrimPrefix(strings.Trim(cmdParts[1], "'"), "/"), "/") 116 - l.Info("command components", "components", components) 117 - 118 - if len(components) != 2 { 119 - l.Error("invalid repo format", "components", components) 120 - fmt.Fprintln(os.Stderr, "invalid repo format, needs <user>/<repo> or /<user>/<repo>") 121 - os.Exit(-1) 122 - } 123 - 124 - didOrHandle := components[0] 125 - identity := resolveIdentity(ctx, l, didOrHandle) 126 - did := identity.DID.String() 127 - repoName := components[1] 128 - qualifiedRepoName, _ := securejoin.SecureJoin(did, repoName) 109 + repoPath := cmdParts[1] 129 110 130 111 validCommands := map[string]bool{ 131 112 "git-receive-pack": true, ··· 138 119 return fmt.Errorf("access denied: invalid git command") 139 120 } 140 121 141 - if gitCommand != "git-upload-pack" { 142 - if !isPushPermitted(l, incomingUser, qualifiedRepoName, endpoint) { 143 - l.Error("access denied: user not allowed", 144 - "did", incomingUser, 145 - "reponame", qualifiedRepoName) 146 - fmt.Fprintln(os.Stderr, "access denied: user not allowed") 147 - os.Exit(-1) 148 - } 122 + // qualify repo path from internal server which holds the knot config 123 + qualifiedRepoPath, err := guardAndQualifyRepo(l, endpoint, incomingUser, repoPath, gitCommand) 124 + if err != nil { 125 + l.Error("failed to run guard", "err", err) 126 + fmt.Fprintln(os.Stderr, err) 127 + os.Exit(1) 149 128 } 150 129 151 - fullPath, _ := securejoin.SecureJoin(gitDir, qualifiedRepoName) 130 + fullPath, _ := securejoin.SecureJoin(gitDir, qualifiedRepoPath) 152 131 153 132 l.Info("processing command", 154 133 "user", incomingUser, 155 134 "command", gitCommand, 156 - "repo", repoName, 135 + "repo", repoPath, 157 136 "fullPath", fullPath, 158 137 "client", clientIP) 159 138 ··· 177 156 gitCmd.Stdin = os.Stdin 178 157 gitCmd.Env = append(os.Environ(), 179 158 fmt.Sprintf("GIT_USER_DID=%s", incomingUser), 180 - fmt.Sprintf("GIT_USER_PDS_ENDPOINT=%s", identity.PDSEndpoint()), 181 159 ) 182 160 183 161 if err := gitCmd.Run(); err != nil { ··· 189 167 l.Info("command completed", 190 168 "user", incomingUser, 191 169 "command", gitCommand, 192 - "repo", repoName, 170 + "repo", repoPath, 193 171 "success", true) 194 172 195 173 return nil 196 174 } 197 175 198 - func resolveIdentity(ctx context.Context, l *slog.Logger, didOrHandle string) *identity.Identity { 199 - resolver := idresolver.DefaultResolver() 200 - ident, err := resolver.ResolveIdent(ctx, didOrHandle) 176 + // runs guardAndQualifyRepo logic 177 + func guardAndQualifyRepo(l *slog.Logger, endpoint, incomingUser, repo, gitCommand string) (string, error) { 178 + u, _ := url.Parse(endpoint + "/guard") 179 + q := u.Query() 180 + q.Add("user", incomingUser) 181 + q.Add("repo", repo) 182 + q.Add("gitCmd", gitCommand) 183 + u.RawQuery = q.Encode() 184 + 185 + resp, err := http.Get(u.String()) 201 186 if err != nil { 202 - l.Error("Error resolving handle", "error", err, "handle", didOrHandle) 203 - fmt.Fprintf(os.Stderr, "error resolving handle: %v\n", err) 204 - os.Exit(1) 205 - } 206 - if ident.Handle.IsInvalidHandle() { 207 - l.Error("Error resolving handle", "invalid handle", didOrHandle) 208 - fmt.Fprintf(os.Stderr, "error resolving handle: invalid handle\n") 209 - os.Exit(1) 187 + return "", err 210 188 } 211 - return ident 212 - } 189 + defer resp.Body.Close() 213 190 214 - func isPushPermitted(l *slog.Logger, user, qualifiedRepoName, endpoint string) bool { 215 - u, _ := url.Parse(endpoint + "/push-allowed") 216 - q := u.Query() 217 - q.Add("user", user) 218 - q.Add("repo", qualifiedRepoName) 219 - u.RawQuery = q.Encode() 191 + l.Info("Running guard", "url", u.String(), "status", resp.Status) 220 192 221 - req, err := http.Get(u.String()) 193 + body, err := io.ReadAll(resp.Body) 222 194 if err != nil { 223 - l.Error("Error verifying permissions", "error", err) 224 - fmt.Fprintf(os.Stderr, "error verifying permissions: %v\n", err) 225 - os.Exit(1) 195 + return "", err 226 196 } 227 - 228 - l.Info("Checking push permission", 229 - "url", u.String(), 230 - "status", req.Status) 197 + text := string(body) 231 198 232 - return req.StatusCode == http.StatusNoContent 199 + switch resp.StatusCode { 200 + case http.StatusOK: 201 + return text, nil 202 + case http.StatusForbidden: 203 + l.Error("access denied: user not allowed", "did", incomingUser, "reponame", text) 204 + return text, errors.New("access denied: user not allowed") 205 + default: 206 + return "", errors.New(text) 207 + } 233 208 }
+4 -4
hook/hook.go
··· 48 48 }, 49 49 Commands: []*cli.Command{ 50 50 { 51 - Name: "post-recieve", 52 - Usage: "sends a post-recieve hook to the knot (waits for stdin)", 53 - Action: postRecieve, 51 + Name: "post-receive", 52 + Usage: "sends a post-receive hook to the knot (waits for stdin)", 53 + Action: postReceive, 54 54 }, 55 55 }, 56 56 } 57 57 } 58 58 59 - func postRecieve(ctx context.Context, cmd *cli.Command) error { 59 + func postReceive(ctx context.Context, cmd *cli.Command) error { 60 60 gitDir := cmd.String("git-dir") 61 61 userDid := cmd.String("user-did") 62 62 userHandle := cmd.String("user-handle")
+1 -1
hook/setup.go
··· 138 138 option_var="GIT_PUSH_OPTION_$i" 139 139 push_options+=(-push-option "${!option_var}") 140 140 done 141 - %s hook -git-dir "$GIT_DIR" -user-did "$GIT_USER_DID" -user-handle "$GIT_USER_HANDLE" -internal-api "%s" "${push_options[@]}" post-recieve 141 + %s hook -git-dir "$GIT_DIR" -user-did "$GIT_USER_DID" -user-handle "$GIT_USER_HANDLE" -internal-api "%s" "${push_options[@]}" post-receive 142 142 `, executablePath, config.internalApi) 143 143 144 144 return os.WriteFile(hookPath, []byte(hookContent), 0755)
+88
ico/ico.go
··· 1 + package ico 2 + 3 + import ( 4 + "bytes" 5 + "encoding/binary" 6 + "fmt" 7 + "image" 8 + "image/png" 9 + ) 10 + 11 + type IconDir struct { 12 + Reserved uint16 // must be 0 13 + Type uint16 // 1 for ICO, 2 for CUR 14 + Count uint16 // number of images 15 + } 16 + 17 + type IconDirEntry struct { 18 + Width uint8 // 0 means 256 19 + Height uint8 // 0 means 256 20 + ColorCount uint8 21 + Reserved uint8 // must be 0 22 + ColorPlanes uint16 // 0 or 1 23 + BitsPerPixel uint16 24 + SizeInBytes uint32 25 + Offset uint32 26 + } 27 + 28 + func ImageToIco(img image.Image) ([]byte, error) { 29 + // encode image as png 30 + var pngBuf bytes.Buffer 31 + if err := png.Encode(&pngBuf, img); err != nil { 32 + return nil, fmt.Errorf("failed to encode PNG: %w", err) 33 + } 34 + pngData := pngBuf.Bytes() 35 + 36 + // get image dimensions 37 + bounds := img.Bounds() 38 + width := bounds.Dx() 39 + height := bounds.Dy() 40 + 41 + // prepare output buffer 42 + var icoBuf bytes.Buffer 43 + 44 + iconDir := IconDir{ 45 + Reserved: 0, 46 + Type: 1, // ICO format 47 + Count: 1, // One image 48 + } 49 + 50 + w := uint8(width) 51 + h := uint8(height) 52 + 53 + // width/height of 256 should be stored as 0 54 + if width == 256 { 55 + w = 0 56 + } 57 + if height == 256 { 58 + h = 0 59 + } 60 + 61 + iconDirEntry := IconDirEntry{ 62 + Width: w, 63 + Height: h, 64 + ColorCount: 0, // 0 for PNG (32-bit) 65 + Reserved: 0, 66 + ColorPlanes: 1, 67 + BitsPerPixel: 32, // PNG with alpha 68 + SizeInBytes: uint32(len(pngData)), 69 + Offset: 6 + 16, // Size of ICONDIR + ICONDIRENTRY 70 + } 71 + 72 + // write IconDir 73 + if err := binary.Write(&icoBuf, binary.LittleEndian, iconDir); err != nil { 74 + return nil, fmt.Errorf("failed to write ICONDIR: %w", err) 75 + } 76 + 77 + // write IconDirEntry 78 + if err := binary.Write(&icoBuf, binary.LittleEndian, iconDirEntry); err != nil { 79 + return nil, fmt.Errorf("failed to write ICONDIRENTRY: %w", err) 80 + } 81 + 82 + // write PNG data directly 83 + if _, err := icoBuf.Write(pngData); err != nil { 84 + return nil, fmt.Errorf("failed to write PNG data: %w", err) 85 + } 86 + 87 + return icoBuf.Bytes(), nil 88 + }
+17 -8
idresolver/resolver.go
··· 17 17 directory identity.Directory 18 18 } 19 19 20 - func BaseDirectory() identity.Directory { 20 + func BaseDirectory(plcUrl string) identity.Directory { 21 21 base := identity.BaseDirectory{ 22 - PLCURL: identity.DefaultPLCURL, 22 + PLCURL: plcUrl, 23 23 HTTPClient: http.Client{ 24 24 Timeout: time.Second * 10, 25 25 Transport: &http.Transport{ ··· 42 42 return &base 43 43 } 44 44 45 - func RedisDirectory(url string) (identity.Directory, error) { 45 + func RedisDirectory(url, plcUrl string) (identity.Directory, error) { 46 46 hitTTL := time.Hour * 24 47 47 errTTL := time.Second * 30 48 48 invalidHandleTTL := time.Minute * 5 49 - return redisdir.NewRedisDirectory(BaseDirectory(), url, hitTTL, errTTL, invalidHandleTTL, 10000) 49 + return redisdir.NewRedisDirectory( 50 + BaseDirectory(plcUrl), 51 + url, 52 + hitTTL, 53 + errTTL, 54 + invalidHandleTTL, 55 + 10000, 56 + ) 50 57 } 51 58 52 - func DefaultResolver() *Resolver { 59 + func DefaultResolver(plcUrl string) *Resolver { 60 + base := BaseDirectory(plcUrl) 61 + cached := identity.NewCacheDirectory(base, 250_000, time.Hour*24, time.Minute*2, time.Minute*5) 53 62 return &Resolver{ 54 - directory: identity.DefaultDirectory(), 63 + directory: &cached, 55 64 } 56 65 } 57 66 58 - func RedisResolver(redisUrl string) (*Resolver, error) { 59 - directory, err := RedisDirectory(redisUrl) 67 + func RedisResolver(redisUrl, plcUrl string) (*Resolver, error) { 68 + directory, err := RedisDirectory(redisUrl, plcUrl) 60 69 if err != nil { 61 70 return nil, err 62 71 }
+39
input.css
··· 161 161 @apply no-underline; 162 162 } 163 163 164 + .prose a.mention { 165 + @apply no-underline hover:underline font-bold; 166 + } 167 + 164 168 .prose li { 165 169 @apply my-0 py-0; 166 170 } ··· 241 245 details[data-callout] > summary::-webkit-details-marker { 242 246 display: none; 243 247 } 248 + 244 249 } 245 250 @layer utilities { 246 251 .error { ··· 250 255 @apply py-1 text-gray-900 dark:text-gray-100; 251 256 } 252 257 } 258 + 253 259 } 254 260 255 261 /* Background */ ··· 924 930 text-decoration: underline; 925 931 } 926 932 } 933 + 934 + actor-typeahead { 935 + --color-background: #ffffff; 936 + --color-border: #d1d5db; 937 + --color-shadow: #000000; 938 + --color-hover: #f9fafb; 939 + --color-avatar-fallback: #e5e7eb; 940 + --radius: 0.0; 941 + --padding-menu: 0.0rem; 942 + z-index: 1000; 943 + } 944 + 945 + actor-typeahead::part(handle) { 946 + color: #111827; 947 + } 948 + 949 + actor-typeahead::part(menu) { 950 + box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); 951 + } 952 + 953 + @media (prefers-color-scheme: dark) { 954 + actor-typeahead { 955 + --color-background: #1f2937; 956 + --color-border: #4b5563; 957 + --color-shadow: #000000; 958 + --color-hover: #374151; 959 + --color-avatar-fallback: #4b5563; 960 + } 961 + 962 + actor-typeahead::part(handle) { 963 + color: #f9fafb; 964 + } 965 + }
+15 -4
jetstream/jetstream.go
··· 72 72 // existing instances of the closure when j.WantedDids is mutated 73 73 return func(ctx context.Context, evt *models.Event) error { 74 74 75 + j.mu.RLock() 75 76 // empty filter => all dids allowed 76 - if len(j.wantedDids) == 0 { 77 - return processFunc(ctx, evt) 77 + matches := len(j.wantedDids) == 0 78 + if !matches { 79 + if _, ok := j.wantedDids[evt.Did]; ok { 80 + matches = true 81 + } 78 82 } 83 + j.mu.RUnlock() 79 84 80 - if _, ok := j.wantedDids[evt.Did]; ok { 85 + if matches { 81 86 return processFunc(ctx, evt) 82 87 } else { 83 88 return nil ··· 122 127 123 128 go func() { 124 129 if j.waitForDid { 125 - for len(j.wantedDids) == 0 { 130 + for { 131 + j.mu.RLock() 132 + hasDid := len(j.wantedDids) != 0 133 + j.mu.RUnlock() 134 + if hasDid { 135 + break 136 + } 126 137 time.Sleep(time.Second) 127 138 } 128 139 }
+1
knotserver/config/config.go
··· 19 19 InternalListenAddr string `env:"INTERNAL_LISTEN_ADDR, default=127.0.0.1:5444"` 20 20 DBPath string `env:"DB_PATH, default=knotserver.db"` 21 21 Hostname string `env:"HOSTNAME, required"` 22 + PlcUrl string `env:"PLC_URL, default=https://plc.directory"` 22 23 JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"` 23 24 Owner string `env:"OWNER, required"` 24 25 LogDids bool `env:"LOG_DIDS, default=true"`
+81
knotserver/db/db.go
··· 1 + package db 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + "log/slog" 7 + "strings" 8 + 9 + _ "github.com/mattn/go-sqlite3" 10 + "tangled.org/core/log" 11 + ) 12 + 13 + type DB struct { 14 + db *sql.DB 15 + logger *slog.Logger 16 + } 17 + 18 + func Setup(ctx context.Context, dbPath string) (*DB, error) { 19 + // https://github.com/mattn/go-sqlite3#connection-string 20 + opts := []string{ 21 + "_foreign_keys=1", 22 + "_journal_mode=WAL", 23 + "_synchronous=NORMAL", 24 + "_auto_vacuum=incremental", 25 + } 26 + 27 + logger := log.FromContext(ctx) 28 + logger = log.SubLogger(logger, "db") 29 + 30 + db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&")) 31 + if err != nil { 32 + return nil, err 33 + } 34 + 35 + conn, err := db.Conn(ctx) 36 + if err != nil { 37 + return nil, err 38 + } 39 + defer conn.Close() 40 + 41 + _, err = conn.ExecContext(ctx, ` 42 + create table if not exists known_dids ( 43 + did text primary key 44 + ); 45 + 46 + create table if not exists public_keys ( 47 + id integer primary key autoincrement, 48 + did text not null, 49 + key text not null, 50 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 51 + unique(did, key), 52 + foreign key (did) references known_dids(did) on delete cascade 53 + ); 54 + 55 + create table if not exists _jetstream ( 56 + id integer primary key autoincrement, 57 + last_time_us integer not null 58 + ); 59 + 60 + create table if not exists events ( 61 + rkey text not null, 62 + nsid text not null, 63 + event text not null, -- json 64 + created integer not null default (strftime('%s', 'now')), 65 + primary key (rkey, nsid) 66 + ); 67 + 68 + create table if not exists migrations ( 69 + id integer primary key autoincrement, 70 + name text unique 71 + ); 72 + `) 73 + if err != nil { 74 + return nil, err 75 + } 76 + 77 + return &DB{ 78 + db: db, 79 + logger: logger, 80 + }, nil 81 + }
-64
knotserver/db/init.go
··· 1 - package db 2 - 3 - import ( 4 - "database/sql" 5 - "strings" 6 - 7 - _ "github.com/mattn/go-sqlite3" 8 - ) 9 - 10 - type DB struct { 11 - db *sql.DB 12 - } 13 - 14 - func Setup(dbPath string) (*DB, error) { 15 - // https://github.com/mattn/go-sqlite3#connection-string 16 - opts := []string{ 17 - "_foreign_keys=1", 18 - "_journal_mode=WAL", 19 - "_synchronous=NORMAL", 20 - "_auto_vacuum=incremental", 21 - } 22 - 23 - db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&")) 24 - if err != nil { 25 - return nil, err 26 - } 27 - 28 - // NOTE: If any other migration is added here, you MUST 29 - // copy the pattern in appview: use a single sql.Conn 30 - // for every migration. 31 - 32 - _, err = db.Exec(` 33 - create table if not exists known_dids ( 34 - did text primary key 35 - ); 36 - 37 - create table if not exists public_keys ( 38 - id integer primary key autoincrement, 39 - did text not null, 40 - key text not null, 41 - created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 42 - unique(did, key), 43 - foreign key (did) references known_dids(did) on delete cascade 44 - ); 45 - 46 - create table if not exists _jetstream ( 47 - id integer primary key autoincrement, 48 - last_time_us integer not null 49 - ); 50 - 51 - create table if not exists events ( 52 - rkey text not null, 53 - nsid text not null, 54 - event text not null, -- json 55 - created integer not null default (strftime('%s', 'now')), 56 - primary key (rkey, nsid) 57 - ); 58 - `) 59 - if err != nil { 60 - return nil, err 61 - } 62 - 63 - return &DB{db: db}, nil 64 - }
+1 -17
knotserver/git/diff.go
··· 77 77 nd.Diff = append(nd.Diff, ndiff) 78 78 } 79 79 80 - nd.Stat.FilesChanged = len(diffs) 81 - nd.Commit.This = c.Hash.String() 82 - nd.Commit.PGPSignature = c.PGPSignature 83 - nd.Commit.Committer = c.Committer 84 - nd.Commit.Tree = c.TreeHash.String() 85 - 86 - if parent.Hash.IsZero() { 87 - nd.Commit.Parent = "" 88 - } else { 89 - nd.Commit.Parent = parent.Hash.String() 90 - } 91 - nd.Commit.Author = c.Author 92 - nd.Commit.Message = c.Message 93 - 94 - if v, ok := c.ExtraHeaders["change-id"]; ok { 95 - nd.Commit.ChangedId = string(v) 96 - } 80 + nd.Commit.FromGoGitCommit(c) 97 81 98 82 return &nd, nil 99 83 }
+38 -2
knotserver/git/fork.go
··· 3 3 import ( 4 4 "errors" 5 5 "fmt" 6 + "log/slog" 7 + "net/url" 6 8 "os/exec" 9 + "path/filepath" 7 10 8 11 "github.com/go-git/go-git/v5" 9 12 "github.com/go-git/go-git/v5/config" 13 + knotconfig "tangled.org/core/knotserver/config" 10 14 ) 11 15 12 - func Fork(repoPath, source string) error { 13 - cloneCmd := exec.Command("git", "clone", "--bare", source, repoPath) 16 + func Fork(repoPath, source string, cfg *knotconfig.Config) error { 17 + u, err := url.Parse(source) 18 + if err != nil { 19 + return fmt.Errorf("failed to parse source URL: %w", err) 20 + } 21 + 22 + if o := optimizeClone(u, cfg); o != nil { 23 + u = o 24 + } 25 + 26 + cloneCmd := exec.Command("git", "clone", "--bare", u.String(), repoPath) 14 27 if err := cloneCmd.Run(); err != nil { 15 28 return fmt.Errorf("failed to bare clone repository: %w", err) 16 29 } ··· 21 34 } 22 35 23 36 return nil 37 + } 38 + 39 + func optimizeClone(u *url.URL, cfg *knotconfig.Config) *url.URL { 40 + // only optimize if it's the same host 41 + if u.Host != cfg.Server.Hostname { 42 + return nil 43 + } 44 + 45 + local := filepath.Join(cfg.Repo.ScanPath, u.Path) 46 + 47 + // sanity check: is there a git repo there? 48 + if _, err := PlainOpen(local); err != nil { 49 + return nil 50 + } 51 + 52 + // create optimized file:// URL 53 + optimized := &url.URL{ 54 + Scheme: "file", 55 + Path: local, 56 + } 57 + 58 + slog.Debug("performing local clone", "url", optimized.String()) 59 + return optimized 24 60 } 25 61 26 62 func (g *GitRepo) Sync() error {
+60 -2
knotserver/git/git.go
··· 3 3 import ( 4 4 "archive/tar" 5 5 "bytes" 6 + "errors" 6 7 "fmt" 7 8 "io" 8 9 "io/fs" ··· 12 13 "time" 13 14 14 15 "github.com/go-git/go-git/v5" 16 + "github.com/go-git/go-git/v5/config" 15 17 "github.com/go-git/go-git/v5/plumbing" 16 18 "github.com/go-git/go-git/v5/plumbing/object" 17 19 ) 18 20 19 21 var ( 20 - ErrBinaryFile = fmt.Errorf("binary file") 21 - ErrNotBinaryFile = fmt.Errorf("not binary file") 22 + ErrBinaryFile = errors.New("binary file") 23 + ErrNotBinaryFile = errors.New("not binary file") 24 + ErrMissingGitModules = errors.New("no .gitmodules file found") 25 + ErrInvalidGitModules = errors.New("invalid .gitmodules file") 26 + ErrNotSubmodule = errors.New("path is not a submodule") 22 27 ) 23 28 24 29 type GitRepo struct { ··· 188 193 defer reader.Close() 189 194 190 195 return io.ReadAll(reader) 196 + } 197 + 198 + // read and parse .gitmodules 199 + func (g *GitRepo) Submodules() (*config.Modules, error) { 200 + c, err := g.r.CommitObject(g.h) 201 + if err != nil { 202 + return nil, fmt.Errorf("commit object: %w", err) 203 + } 204 + 205 + tree, err := c.Tree() 206 + if err != nil { 207 + return nil, fmt.Errorf("tree: %w", err) 208 + } 209 + 210 + // read .gitmodules file 211 + modulesEntry, err := tree.FindEntry(".gitmodules") 212 + if err != nil { 213 + return nil, fmt.Errorf("%w: %w", ErrMissingGitModules, err) 214 + } 215 + 216 + modulesFile, err := tree.TreeEntryFile(modulesEntry) 217 + if err != nil { 218 + return nil, fmt.Errorf("%w: failed to read file: %w", ErrInvalidGitModules, err) 219 + } 220 + 221 + content, err := modulesFile.Contents() 222 + if err != nil { 223 + return nil, fmt.Errorf("%w: failed to read contents: %w", ErrInvalidGitModules, err) 224 + } 225 + 226 + // parse .gitmodules 227 + modules := config.NewModules() 228 + if err = modules.Unmarshal([]byte(content)); err != nil { 229 + return nil, fmt.Errorf("%w: failed to parse: %w", ErrInvalidGitModules, err) 230 + } 231 + 232 + return modules, nil 233 + } 234 + 235 + func (g *GitRepo) Submodule(path string) (*config.Submodule, error) { 236 + modules, err := g.Submodules() 237 + if err != nil { 238 + return nil, err 239 + } 240 + 241 + for _, submodule := range modules.Submodules { 242 + if submodule.Path == path { 243 + return submodule, nil 244 + } 245 + } 246 + 247 + // path is not a submodule 248 + return nil, ErrNotSubmodule 191 249 } 192 250 193 251 func (g *GitRepo) Branch(name string) (*plumbing.Reference, error) {
+13 -1
knotserver/git/service/service.go
··· 95 95 return c.RunService(cmd) 96 96 } 97 97 98 + func (c *ServiceCommand) UploadArchive() error { 99 + cmd := exec.Command("git", []string{ 100 + "upload-archive", 101 + ".", 102 + }...) 103 + 104 + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} 105 + cmd.Env = append(cmd.Env, fmt.Sprintf("GIT_PROTOCOL=%s", c.GitProtocol)) 106 + cmd.Dir = c.Dir 107 + 108 + return c.RunService(cmd) 109 + } 110 + 98 111 func (c *ServiceCommand) UploadPack() error { 99 112 cmd := exec.Command("git", []string{ 100 - "-c", "uploadpack.allowFilter=true", 101 113 "upload-pack", 102 114 "--stateless-rpc", 103 115 ".",
+4 -13
knotserver/git/tree.go
··· 7 7 "path" 8 8 "time" 9 9 10 + "github.com/go-git/go-git/v5/plumbing/filemode" 10 11 "github.com/go-git/go-git/v5/plumbing/object" 11 12 "tangled.org/core/types" 12 13 ) ··· 53 54 } 54 55 55 56 for _, e := range subtree.Entries { 56 - mode, _ := e.Mode.ToOSFileMode() 57 57 sz, _ := subtree.Size(e.Name) 58 - 59 58 fpath := path.Join(parent, e.Name) 60 59 61 60 var lastCommit *types.LastCommitInfo ··· 69 68 70 69 nts = append(nts, types.NiceTree{ 71 70 Name: e.Name, 72 - Mode: mode.String(), 73 - IsFile: e.Mode.IsFile(), 71 + Mode: e.Mode.String(), 74 72 Size: sz, 75 73 LastCommit: lastCommit, 76 74 }) ··· 126 124 default: 127 125 } 128 126 129 - mode, err := e.Mode.ToOSFileMode() 130 - if err != nil { 131 - // TODO: log this 132 - continue 133 - } 134 - 135 127 if e.Mode.IsFile() { 136 - err = cb(e, currentTree, root) 137 - if errors.Is(err, TerminateWalk) { 128 + if err := cb(e, currentTree, root); errors.Is(err, TerminateWalk) { 138 129 return err 139 130 } 140 131 } 141 132 142 133 // e is a directory 143 - if mode.IsDir() { 134 + if e.Mode == filemode.Dir { 144 135 subtree, err := currentTree.Tree(e.Name) 145 136 if err != nil { 146 137 return fmt.Errorf("sub tree %s: %w", e.Name, err)
+47
knotserver/git.go
··· 56 56 } 57 57 } 58 58 59 + func (h *Knot) UploadArchive(w http.ResponseWriter, r *http.Request) { 60 + did := chi.URLParam(r, "did") 61 + name := chi.URLParam(r, "name") 62 + repo, err := securejoin.SecureJoin(h.c.Repo.ScanPath, filepath.Join(did, name)) 63 + if err != nil { 64 + gitError(w, err.Error(), http.StatusInternalServerError) 65 + h.l.Error("git: failed to secure join repo path", "handler", "UploadPack", "error", err) 66 + return 67 + } 68 + 69 + const expectedContentType = "application/x-git-upload-archive-request" 70 + contentType := r.Header.Get("Content-Type") 71 + if contentType != expectedContentType { 72 + gitError(w, fmt.Sprintf("Expected Content-Type: '%s', but received '%s'.", expectedContentType, contentType), http.StatusUnsupportedMediaType) 73 + } 74 + 75 + var bodyReader io.ReadCloser = r.Body 76 + if r.Header.Get("Content-Encoding") == "gzip" { 77 + gzipReader, err := gzip.NewReader(r.Body) 78 + if err != nil { 79 + gitError(w, err.Error(), http.StatusInternalServerError) 80 + h.l.Error("git: failed to create gzip reader", "handler", "UploadArchive", "error", err) 81 + return 82 + } 83 + defer gzipReader.Close() 84 + bodyReader = gzipReader 85 + } 86 + 87 + w.Header().Set("Content-Type", "application/x-git-upload-archive-result") 88 + 89 + h.l.Info("git: executing git-upload-archive", "handler", "UploadArchive", "repo", repo) 90 + 91 + cmd := service.ServiceCommand{ 92 + GitProtocol: r.Header.Get("Git-Protocol"), 93 + Dir: repo, 94 + Stdout: w, 95 + Stdin: bodyReader, 96 + } 97 + 98 + w.WriteHeader(http.StatusOK) 99 + 100 + if err := cmd.UploadArchive(); err != nil { 101 + h.l.Error("git: failed to execute git-upload-pack", "handler", "UploadPack", "error", err) 102 + return 103 + } 104 + } 105 + 59 106 func (h *Knot) UploadPack(w http.ResponseWriter, r *http.Request) { 60 107 did := chi.URLParam(r, "did") 61 108 name := chi.URLParam(r, "name")
+4 -8
knotserver/ingester.go
··· 16 16 "github.com/bluesky-social/jetstream/pkg/models" 17 17 securejoin "github.com/cyphar/filepath-securejoin" 18 18 "tangled.org/core/api/tangled" 19 - "tangled.org/core/idresolver" 20 19 "tangled.org/core/knotserver/db" 21 20 "tangled.org/core/knotserver/git" 22 21 "tangled.org/core/log" ··· 120 119 } 121 120 122 121 // resolve this aturi to extract the repo record 123 - resolver := idresolver.DefaultResolver() 124 - ident, err := resolver.ResolveIdent(ctx, repoAt.Authority().String()) 122 + ident, err := h.resolver.ResolveIdent(ctx, repoAt.Authority().String()) 125 123 if err != nil || ident.Handle.IsInvalidHandle() { 126 124 return fmt.Errorf("failed to resolve handle: %w", err) 127 125 } ··· 163 161 164 162 var pipeline workflow.RawPipeline 165 163 for _, e := range workflowDir { 166 - if !e.IsFile { 164 + if !e.IsFile() { 167 165 continue 168 166 } 169 167 ··· 233 231 return err 234 232 } 235 233 236 - resolver := idresolver.DefaultResolver() 237 - 238 - subjectId, err := resolver.ResolveIdent(ctx, record.Subject) 234 + subjectId, err := h.resolver.ResolveIdent(ctx, record.Subject) 239 235 if err != nil || subjectId.Handle.IsInvalidHandle() { 240 236 return err 241 237 } 242 238 243 239 // TODO: fix this for good, we need to fetch the record here unfortunately 244 240 // resolve this aturi to extract the repo record 245 - owner, err := resolver.ResolveIdent(ctx, repoAt.Authority().String()) 241 + owner, err := h.resolver.ResolveIdent(ctx, repoAt.Authority().String()) 246 242 if err != nil || owner.Handle.IsInvalidHandle() { 247 243 return fmt.Errorf("failed to resolve handle: %w", err) 248 244 }
+146 -49
knotserver/internal.go
··· 27 27 ) 28 28 29 29 type InternalHandle struct { 30 - db *db.DB 31 - c *config.Config 32 - e *rbac.Enforcer 33 - l *slog.Logger 34 - n *notifier.Notifier 30 + db *db.DB 31 + c *config.Config 32 + e *rbac.Enforcer 33 + l *slog.Logger 34 + n *notifier.Notifier 35 + res *idresolver.Resolver 35 36 } 36 37 37 38 func (h *InternalHandle) PushAllowed(w http.ResponseWriter, r *http.Request) { ··· 67 68 writeJSON(w, data) 68 69 } 69 70 71 + // response in text/plain format 72 + // the body will be qualified repository path on success/push-denied 73 + // or an error message when process failed 74 + func (h *InternalHandle) Guard(w http.ResponseWriter, r *http.Request) { 75 + l := h.l.With("handler", "PostReceiveHook") 76 + 77 + var ( 78 + incomingUser = r.URL.Query().Get("user") 79 + repo = r.URL.Query().Get("repo") 80 + gitCommand = r.URL.Query().Get("gitCmd") 81 + ) 82 + 83 + if incomingUser == "" || repo == "" || gitCommand == "" { 84 + w.WriteHeader(http.StatusBadRequest) 85 + l.Error("invalid request", "incomingUser", incomingUser, "repo", repo, "gitCommand", gitCommand) 86 + fmt.Fprintln(w, "invalid internal request") 87 + return 88 + } 89 + 90 + // did:foo/repo-name or 91 + // handle/repo-name or 92 + // any of the above with a leading slash (/) 93 + components := strings.Split(strings.TrimPrefix(strings.Trim(repo, "'"), "/"), "/") 94 + l.Info("command components", "components", components) 95 + 96 + if len(components) != 2 { 97 + w.WriteHeader(http.StatusBadRequest) 98 + l.Error("invalid repo format", "components", components) 99 + fmt.Fprintln(w, "invalid repo format, needs <user>/<repo> or /<user>/<repo>") 100 + return 101 + } 102 + repoOwner := components[0] 103 + repoName := components[1] 104 + 105 + resolver := idresolver.DefaultResolver(h.c.Server.PlcUrl) 106 + 107 + repoOwnerIdent, err := resolver.ResolveIdent(r.Context(), repoOwner) 108 + if err != nil || repoOwnerIdent.Handle.IsInvalidHandle() { 109 + l.Error("Error resolving handle", "handle", repoOwner, "err", err) 110 + w.WriteHeader(http.StatusInternalServerError) 111 + fmt.Fprintf(w, "error resolving handle: invalid handle\n") 112 + return 113 + } 114 + repoOwnerDid := repoOwnerIdent.DID.String() 115 + 116 + qualifiedRepo, _ := securejoin.SecureJoin(repoOwnerDid, repoName) 117 + 118 + if gitCommand == "git-receive-pack" { 119 + ok, err := h.e.IsPushAllowed(incomingUser, rbac.ThisServer, qualifiedRepo) 120 + if err != nil || !ok { 121 + w.WriteHeader(http.StatusForbidden) 122 + fmt.Fprint(w, repo) 123 + return 124 + } 125 + } 126 + 127 + w.WriteHeader(http.StatusOK) 128 + fmt.Fprint(w, qualifiedRepo) 129 + } 130 + 70 131 type PushOptions struct { 71 132 skipCi bool 72 133 verboseCi bool ··· 121 182 // non-fatal 122 183 } 123 184 124 - if (line.NewSha.String() != line.OldSha.String()) && line.OldSha.IsZero() { 125 - msg, err := h.replyCompare(line, repoDid, gitRelativeDir, repoName, r.Context()) 126 - if err != nil { 127 - l.Error("failed to reply with compare link", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 128 - // non-fatal 129 - } else { 130 - for msgLine := range msg { 131 - resp.Messages = append(resp.Messages, msg[msgLine]) 132 - } 133 - } 185 + err = h.emitCompareLink(&resp.Messages, line, repoDid, repoName) 186 + if err != nil { 187 + l.Error("failed to reply with compare link", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 188 + // non-fatal 134 189 } 135 190 136 191 err = h.triggerPipeline(&resp.Messages, line, gitUserDid, repoDid, repoName, pushOptions) ··· 143 198 writeJSON(w, resp) 144 199 } 145 200 146 - func (h *InternalHandle) replyCompare(line git.PostReceiveLine, repoOwner string, gitRelativeDir string, repoName string, ctx context.Context) ([]string, error) { 147 - l := h.l.With("handler", "replyCompare") 148 - userIdent, err := idresolver.DefaultResolver().ResolveIdent(ctx, repoOwner) 149 - user := repoOwner 150 - if err != nil { 151 - l.Error("Failed to fetch user identity", "err", err) 152 - // non-fatal 153 - } else { 154 - user = userIdent.Handle.String() 155 - } 156 - gr, err := git.PlainOpen(gitRelativeDir) 157 - if err != nil { 158 - l.Error("Failed to open git repository", "err", err) 159 - return []string{}, err 160 - } 161 - defaultBranch, err := gr.FindMainBranch() 162 - if err != nil { 163 - l.Error("Failed to fetch default branch", "err", err) 164 - return []string{}, err 165 - } 166 - if line.Ref == plumbing.NewBranchReferenceName(defaultBranch).String() { 167 - return []string{}, nil 168 - } 169 - ZWS := "\u200B" 170 - var msg []string 171 - msg = append(msg, ZWS) 172 - msg = append(msg, fmt.Sprintf("Create a PR pointing to %s", defaultBranch)) 173 - msg = append(msg, fmt.Sprintf("\t%s/%s/%s/compare/%s...%s", h.c.AppViewEndpoint, user, repoName, defaultBranch, strings.TrimPrefix(line.Ref, "refs/heads/"))) 174 - msg = append(msg, ZWS) 175 - return msg, nil 176 - } 177 - 178 201 func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error { 179 202 didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName) 180 203 if err != nil { ··· 220 243 return errors.Join(errs, h.db.InsertEvent(event, h.n)) 221 244 } 222 245 223 - func (h *InternalHandle) triggerPipeline(clientMsgs *[]string, line git.PostReceiveLine, gitUserDid, repoDid, repoName string, pushOptions PushOptions) error { 246 + func (h *InternalHandle) triggerPipeline( 247 + clientMsgs *[]string, 248 + line git.PostReceiveLine, 249 + gitUserDid string, 250 + repoDid string, 251 + repoName string, 252 + pushOptions PushOptions, 253 + ) error { 224 254 if pushOptions.skipCi { 225 255 return nil 226 256 } ··· 247 277 248 278 var pipeline workflow.RawPipeline 249 279 for _, e := range workflowDir { 250 - if !e.IsFile { 280 + if !e.IsFile() { 251 281 continue 252 282 } 253 283 ··· 315 345 return h.db.InsertEvent(event, h.n) 316 346 } 317 347 348 + func (h *InternalHandle) emitCompareLink( 349 + clientMsgs *[]string, 350 + line git.PostReceiveLine, 351 + repoDid string, 352 + repoName string, 353 + ) error { 354 + // this is a second push to a branch, don't reply with the link again 355 + if !line.OldSha.IsZero() { 356 + return nil 357 + } 358 + 359 + // the ref was not updated to a new hash, don't reply with the link 360 + // 361 + // NOTE: do we need this? 362 + if line.NewSha.String() == line.OldSha.String() { 363 + return nil 364 + } 365 + 366 + pushedRef := plumbing.ReferenceName(line.Ref) 367 + 368 + userIdent, err := h.res.ResolveIdent(context.Background(), repoDid) 369 + user := repoDid 370 + if err == nil { 371 + user = userIdent.Handle.String() 372 + } 373 + 374 + didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName) 375 + if err != nil { 376 + return err 377 + } 378 + 379 + repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo) 380 + if err != nil { 381 + return err 382 + } 383 + 384 + gr, err := git.PlainOpen(repoPath) 385 + if err != nil { 386 + return err 387 + } 388 + 389 + defaultBranch, err := gr.FindMainBranch() 390 + if err != nil { 391 + return err 392 + } 393 + 394 + // pushing to default branch 395 + if pushedRef == plumbing.NewBranchReferenceName(defaultBranch) { 396 + return nil 397 + } 398 + 399 + // pushing a tag, don't prompt the user the open a PR 400 + if pushedRef.IsTag() { 401 + return nil 402 + } 403 + 404 + ZWS := "\u200B" 405 + *clientMsgs = append(*clientMsgs, ZWS) 406 + *clientMsgs = append(*clientMsgs, fmt.Sprintf("Create a PR pointing to %s", defaultBranch)) 407 + *clientMsgs = append(*clientMsgs, fmt.Sprintf("\t%s/%s/%s/compare/%s...%s", h.c.AppViewEndpoint, user, repoName, defaultBranch, strings.TrimPrefix(line.Ref, "refs/heads/"))) 408 + *clientMsgs = append(*clientMsgs, ZWS) 409 + return nil 410 + } 411 + 318 412 func Internal(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, n *notifier.Notifier) http.Handler { 319 413 r := chi.NewRouter() 320 414 l := log.FromContext(ctx) 321 415 l = log.SubLogger(l, "internal") 416 + res := idresolver.DefaultResolver(c.Server.PlcUrl) 322 417 323 418 h := InternalHandle{ 324 419 db, ··· 326 421 e, 327 422 l, 328 423 n, 424 + res, 329 425 } 330 426 331 427 r.Get("/push-allowed", h.PushAllowed) 332 428 r.Get("/keys", h.InternalKeys) 429 + r.Get("/guard", h.Guard) 333 430 r.Post("/hooks/post-receive", h.PostReceiveHook) 334 431 r.Mount("/debug", middleware.Profiler()) 335 432
+18
knotserver/middleware.go
··· 33 33 ) 34 34 }) 35 35 } 36 + 37 + func (h *Knot) CORS(next http.Handler) http.Handler { 38 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 39 + // Set CORS headers 40 + w.Header().Set("Access-Control-Allow-Origin", "*") 41 + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") 42 + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") 43 + w.Header().Set("Access-Control-Max-Age", "86400") 44 + 45 + // Handle preflight requests 46 + if r.Method == "OPTIONS" { 47 + w.WriteHeader(http.StatusOK) 48 + return 49 + } 50 + 51 + next.ServeHTTP(w, r) 52 + }) 53 + }
+3 -1
knotserver/router.go
··· 36 36 l: log.FromContext(ctx), 37 37 jc: jc, 38 38 n: n, 39 - resolver: idresolver.DefaultResolver(), 39 + resolver: idresolver.DefaultResolver(c.Server.PlcUrl), 40 40 } 41 41 42 42 err := e.AddKnot(rbac.ThisServer) ··· 71 71 func (h *Knot) Router() http.Handler { 72 72 r := chi.NewRouter() 73 73 74 + r.Use(h.CORS) 74 75 r.Use(h.RequestLogger) 75 76 76 77 r.Get("/", func(w http.ResponseWriter, r *http.Request) { ··· 81 82 r.Route("/{name}", func(r chi.Router) { 82 83 // routes for git operations 83 84 r.Get("/info/refs", h.InfoRefs) 85 + r.Post("/git-upload-archive", h.UploadArchive) 84 86 r.Post("/git-upload-pack", h.UploadPack) 85 87 r.Post("/git-receive-pack", h.ReceivePack) 86 88 })
+1 -1
knotserver/server.go
··· 64 64 logger.Info("running in dev mode, signature verification is disabled") 65 65 } 66 66 67 - db, err := db.Setup(c.Server.DBPath) 67 + db, err := db.Setup(ctx, c.Server.DBPath) 68 68 if err != nil { 69 69 return fmt.Errorf("failed to load db: %w", err) 70 70 }
+1 -1
knotserver/xrpc/create_repo.go
··· 84 84 repoPath, _ := securejoin.SecureJoin(h.Config.Repo.ScanPath, relativeRepoPath) 85 85 86 86 if data.Source != nil && *data.Source != "" { 87 - err = git.Fork(repoPath, *data.Source) 87 + err = git.Fork(repoPath, *data.Source, h.Config) 88 88 if err != nil { 89 89 l.Error("forking repo", "error", err.Error()) 90 90 writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError)
+21 -2
knotserver/xrpc/repo_blob.go
··· 42 42 return 43 43 } 44 44 45 + // first check if this path is a submodule 46 + submodule, err := gr.Submodule(treePath) 47 + if err != nil { 48 + // this is okay, continue and try to treat it as a regular file 49 + } else { 50 + response := tangled.RepoBlob_Output{ 51 + Ref: ref, 52 + Path: treePath, 53 + Submodule: &tangled.RepoBlob_Submodule{ 54 + Name: submodule.Name, 55 + Url: submodule.URL, 56 + Branch: &submodule.Branch, 57 + }, 58 + } 59 + writeJson(w, response) 60 + return 61 + } 62 + 45 63 contents, err := gr.RawContent(treePath) 46 64 if err != nil { 47 65 x.Logger.Error("file content", "error", err.Error(), "treePath", treePath) ··· 101 119 var encoding string 102 120 103 121 isBinary := !isTextual(mimeType) 122 + size := int64(len(contents)) 104 123 105 124 if isBinary { 106 125 content = base64.StdEncoding.EncodeToString(contents) ··· 113 132 response := tangled.RepoBlob_Output{ 114 133 Ref: ref, 115 134 Path: treePath, 116 - Content: content, 135 + Content: &content, 117 136 Encoding: &encoding, 118 - Size: &[]int64{int64(len(contents))}[0], 137 + Size: &size, 119 138 IsBinary: &isBinary, 120 139 } 121 140
+6 -1
knotserver/xrpc/repo_log.go
··· 62 62 return 63 63 } 64 64 65 + tcommits := make([]types.Commit, len(commits)) 66 + for i, c := range commits { 67 + tcommits[i].FromGoGitCommit(c) 68 + } 69 + 65 70 // Create response using existing types.RepoLogResponse 66 71 response := types.RepoLogResponse{ 67 - Commits: commits, 72 + Commits: tcommits, 68 73 Ref: ref, 69 74 Page: (offset / limit) + 1, 70 75 PerPage: limit,
+3 -5
knotserver/xrpc/repo_tree.go
··· 67 67 treeEntries := make([]*tangled.RepoTree_TreeEntry, len(files)) 68 68 for i, file := range files { 69 69 entry := &tangled.RepoTree_TreeEntry{ 70 - Name: file.Name, 71 - Mode: file.Mode, 72 - Size: file.Size, 73 - Is_file: file.IsFile, 74 - Is_subtree: file.IsSubtree, 70 + Name: file.Name, 71 + Mode: file.Mode, 72 + Size: file.Size, 75 73 } 76 74 77 75 if file.LastCommit != nil {
+5
lexicons/actor/profile.json
··· 64 64 "type": "string", 65 65 "format": "at-uri" 66 66 } 67 + }, 68 + "pronouns": { 69 + "type": "string", 70 + "description": "Preferred gender pronouns.", 71 + "maxLength": 40 67 72 } 68 73 } 69 74 }
+14
lexicons/issue/comment.json
··· 29 29 "replyTo": { 30 30 "type": "string", 31 31 "format": "at-uri" 32 + }, 33 + "mentions": { 34 + "type": "array", 35 + "items": { 36 + "type": "string", 37 + "format": "did" 38 + } 39 + }, 40 + "references": { 41 + "type": "array", 42 + "items": { 43 + "type": "string", 44 + "format": "at-uri" 45 + } 32 46 } 33 47 } 34 48 }
+14
lexicons/issue/issue.json
··· 24 24 "createdAt": { 25 25 "type": "string", 26 26 "format": "datetime" 27 + }, 28 + "mentions": { 29 + "type": "array", 30 + "items": { 31 + "type": "string", 32 + "format": "did" 33 + } 34 + }, 35 + "references": { 36 + "type": "array", 37 + "items": { 38 + "type": "string", 39 + "format": "at-uri" 40 + } 27 41 } 28 42 } 29 43 }
+14
lexicons/pulls/comment.json
··· 25 25 "createdAt": { 26 26 "type": "string", 27 27 "format": "datetime" 28 + }, 29 + "mentions": { 30 + "type": "array", 31 + "items": { 32 + "type": "string", 33 + "format": "did" 34 + } 35 + }, 36 + "references": { 37 + "type": "array", 38 + "items": { 39 + "type": "string", 40 + "format": "at-uri" 41 + } 28 42 } 29 43 } 30 44 }
+24 -2
lexicons/pulls/pull.json
··· 12 12 "required": [ 13 13 "target", 14 14 "title", 15 - "patch", 15 + "patchBlob", 16 16 "createdAt" 17 17 ], 18 18 "properties": { ··· 27 27 "type": "string" 28 28 }, 29 29 "patch": { 30 - "type": "string" 30 + "type": "string", 31 + "description": "(deprecated) use patchBlob instead" 32 + }, 33 + "patchBlob": { 34 + "type": "blob", 35 + "accept": [ 36 + "text/x-patch" 37 + ], 38 + "description": "patch content" 31 39 }, 32 40 "source": { 33 41 "type": "ref", ··· 36 44 "createdAt": { 37 45 "type": "string", 38 46 "format": "datetime" 47 + }, 48 + "mentions": { 49 + "type": "array", 50 + "items": { 51 + "type": "string", 52 + "format": "did" 53 + } 54 + }, 55 + "references": { 56 + "type": "array", 57 + "items": { 58 + "type": "string", 59 + "format": "at-uri" 60 + } 39 61 } 40 62 } 41 63 }
+49 -5
lexicons/repo/blob.json
··· 6 6 "type": "query", 7 7 "parameters": { 8 8 "type": "params", 9 - "required": ["repo", "ref", "path"], 9 + "required": [ 10 + "repo", 11 + "ref", 12 + "path" 13 + ], 10 14 "properties": { 11 15 "repo": { 12 16 "type": "string", ··· 31 35 "encoding": "application/json", 32 36 "schema": { 33 37 "type": "object", 34 - "required": ["ref", "path", "content"], 38 + "required": [ 39 + "ref", 40 + "path" 41 + ], 35 42 "properties": { 36 43 "ref": { 37 44 "type": "string", ··· 48 55 "encoding": { 49 56 "type": "string", 50 57 "description": "Content encoding", 51 - "enum": ["utf-8", "base64"] 58 + "enum": [ 59 + "utf-8", 60 + "base64" 61 + ] 52 62 }, 53 63 "size": { 54 64 "type": "integer", ··· 61 71 "mimeType": { 62 72 "type": "string", 63 73 "description": "MIME type of the file" 74 + }, 75 + "submodule": { 76 + "type": "ref", 77 + "ref": "#submodule", 78 + "description": "Submodule information if path is a submodule" 64 79 }, 65 80 "lastCommit": { 66 81 "type": "ref", ··· 90 105 }, 91 106 "lastCommit": { 92 107 "type": "object", 93 - "required": ["hash", "message", "when"], 108 + "required": [ 109 + "hash", 110 + "message", 111 + "when" 112 + ], 94 113 "properties": { 95 114 "hash": { 96 115 "type": "string", ··· 117 136 }, 118 137 "signature": { 119 138 "type": "object", 120 - "required": ["name", "email", "when"], 139 + "required": [ 140 + "name", 141 + "email", 142 + "when" 143 + ], 121 144 "properties": { 122 145 "name": { 123 146 "type": "string", ··· 131 154 "type": "string", 132 155 "format": "datetime", 133 156 "description": "Author timestamp" 157 + } 158 + } 159 + }, 160 + "submodule": { 161 + "type": "object", 162 + "required": [ 163 + "name", 164 + "url" 165 + ], 166 + "properties": { 167 + "name": { 168 + "type": "string", 169 + "description": "Submodule name" 170 + }, 171 + "url": { 172 + "type": "string", 173 + "description": "Submodule repository URL" 174 + }, 175 + "branch": { 176 + "type": "string", 177 + "description": "Branch to track in the submodule" 134 178 } 135 179 } 136 180 }
+15
lexicons/repo/repo.json
··· 32 32 "minGraphemes": 1, 33 33 "maxGraphemes": 140 34 34 }, 35 + "website": { 36 + "type": "string", 37 + "format": "uri", 38 + "description": "Any URI related to the repo" 39 + }, 40 + "topics": { 41 + "type": "array", 42 + "description": "Topics related to the repo", 43 + "items": { 44 + "type": "string", 45 + "minLength": 1, 46 + "maxLength": 50 47 + }, 48 + "maxLength": 50 49 + }, 35 50 "source": { 36 51 "type": "string", 37 52 "format": "uri",
+1 -9
lexicons/repo/tree.json
··· 91 91 }, 92 92 "treeEntry": { 93 93 "type": "object", 94 - "required": ["name", "mode", "size", "is_file", "is_subtree"], 94 + "required": ["name", "mode", "size"], 95 95 "properties": { 96 96 "name": { 97 97 "type": "string", ··· 104 104 "size": { 105 105 "type": "integer", 106 106 "description": "File size in bytes" 107 - }, 108 - "is_file": { 109 - "type": "boolean", 110 - "description": "Whether this entry is a file" 111 - }, 112 - "is_subtree": { 113 - "type": "boolean", 114 - "description": "Whether this entry is a directory/subtree" 115 107 }, 116 108 "last_commit": { 117 109 "type": "ref",
+5 -32
nix/gomod2nix.toml
··· 109 109 version = "v0.0.0-20241210005130-ea96859b93d1" 110 110 hash = "sha256-AiapbrkjXboIKc5QNiWH0KyNs0zKnn6UlGwWFlkUfm0=" 111 111 [mod."github.com/bmatcuk/doublestar/v4"] 112 - version = "v4.7.1" 113 - hash = "sha256-idO38nWZtmxvE0CgdziepwFM+Xc70Isr6NiKEZjHOjA=" 112 + version = "v4.9.1" 113 + hash = "sha256-0iyHjyTAsfhgYSsE+NKxSNGBuM3Id615VWeQhssTShE=" 114 114 [mod."github.com/carlmjohnson/versioninfo"] 115 115 version = "v0.22.5" 116 116 hash = "sha256-tf7yKVFTUPmGKBLK43bjyIRQUboCYduh3I5HXE5+LPw=" ··· 165 165 [mod."github.com/davecgh/go-spew"] 166 166 version = "v1.1.2-0.20180830191138-d8f796af33cc" 167 167 hash = "sha256-fV9oI51xjHdOmEx6+dlq7Ku2Ag+m/bmbzPo6A4Y74qc=" 168 - [mod."github.com/decred/dcrd/dcrec/secp256k1/v4"] 169 - version = "v4.4.0" 170 - hash = "sha256-qrhEIwhDll3cxoVpMbm1NQ9/HTI42S7ms8Buzlo5HCg=" 171 168 [mod."github.com/dgraph-io/ristretto"] 172 169 version = "v0.2.0" 173 170 hash = "sha256-bnpxX+oO/Qf7IJevA0gsbloVoqRx+5bh7RQ9d9eLNYw=" ··· 373 370 [mod."github.com/klauspost/cpuid/v2"] 374 371 version = "v2.3.0" 375 372 hash = "sha256-50JhbQyT67BK38HIdJihPtjV7orYp96HknI2VP7A9Yc=" 376 - [mod."github.com/lestrrat-go/blackmagic"] 377 - version = "v1.0.4" 378 - hash = "sha256-HmWOpwoPDNMwLdOi7onNn3Sb+ZsAa3Ai3gVBbXmQ0e8=" 379 - [mod."github.com/lestrrat-go/httpcc"] 380 - version = "v1.0.1" 381 - hash = "sha256-SMRSwJpqDIs/xL0l2e8vP0W65qtCHX2wigcOeqPJmos=" 382 - [mod."github.com/lestrrat-go/httprc"] 383 - version = "v1.0.6" 384 - hash = "sha256-mfZzePEhrmyyu/avEBd2MsDXyto8dq5+fyu5lA8GUWM=" 385 - [mod."github.com/lestrrat-go/iter"] 386 - version = "v1.0.2" 387 - hash = "sha256-30tErRf7Qu/NOAt1YURXY/XJSA6sCr6hYQfO8QqHrtw=" 388 - [mod."github.com/lestrrat-go/jwx/v2"] 389 - version = "v2.1.6" 390 - hash = "sha256-0LszXRZIba+X8AOrs3T4uanAUafBdlVB8/MpUNEFpbc=" 391 - [mod."github.com/lestrrat-go/option"] 392 - version = "v1.0.1" 393 - hash = "sha256-jVcIYYVsxElIS/l2akEw32vdEPR8+anR6oeT1FoYULI=" 394 373 [mod."github.com/lucasb-eyer/go-colorful"] 395 374 version = "v1.2.0" 396 375 hash = "sha256-Gg9dDJFCTaHrKHRR1SrJgZ8fWieJkybljybkI9x0gyE=" ··· 511 490 [mod."github.com/ryanuber/go-glob"] 512 491 version = "v1.0.0" 513 492 hash = "sha256-YkMl1utwUhi3E0sHK23ISpAsPyj4+KeXyXKoFYGXGVY=" 514 - [mod."github.com/segmentio/asm"] 515 - version = "v1.2.0" 516 - hash = "sha256-zbNuKxNrUDUc6IlmRQNuJQzVe5Ol/mqp7srDg9IMMqs=" 517 493 [mod."github.com/sergi/go-diff"] 518 494 version = "v1.1.0" 519 495 hash = "sha256-8NJMabldpf40uwQN20T6QXx5KORDibCBJL02KD661xY=" ··· 548 524 [mod."github.com/whyrusleeping/cbor-gen"] 549 525 version = "v0.3.1" 550 526 hash = "sha256-PAd8M2Z8t6rVRBII+Rg8Bz+QaJIwbW64bfyqsv31kgc=" 551 - [mod."github.com/wyatt915/goldmark-treeblood"] 552 - version = "v0.0.0-20250825231212-5dcbdb2f4b57" 553 - hash = "sha256-IZEsUXTBTsNgWoD7vqRUc9aFCCHNjzk1IUmI9O+NCnM=" 554 - [mod."github.com/wyatt915/treeblood"] 555 - version = "v0.1.15" 556 - hash = "sha256-hb99exdkoY2Qv8WdDxhwgPXGbEYimUr6wFtPXEvcO9g=" 557 527 [mod."github.com/xo/terminfo"] 558 528 version = "v0.0.0-20220910002029-abceb7e1c41e" 559 529 hash = "sha256-GyCDxxMQhXA3Pi/TsWXpA8cX5akEoZV7CFx4RO3rARU=" 560 530 [mod."github.com/yuin/goldmark"] 561 531 version = "v1.7.13" 562 532 hash = "sha256-vBCxZrPYPc8x/nvAAv3Au59dCCyfS80Vw3/a9EXK7TE=" 533 + [mod."github.com/yuin/goldmark-emoji"] 534 + version = "v1.0.6" 535 + hash = "sha256-+d6bZzOPE+JSFsZbQNZMCWE+n3jgcQnkPETVk47mxSY=" 563 536 [mod."github.com/yuin/goldmark-highlighting/v2"] 564 537 version = "v2.0.0-20230729083705-37449abec8cc" 565 538 hash = "sha256-HpiwU7jIeDUAg2zOpTIiviQir8dpRPuXYh2nqFFccpg="
+285 -18
nix/modules/appview.nix
··· 3 3 lib, 4 4 ... 5 5 }: let 6 - cfg = config.services.tangled-appview; 6 + cfg = config.services.tangled.appview; 7 7 in 8 8 with lib; { 9 9 options = { 10 - services.tangled-appview = { 10 + services.tangled.appview = { 11 11 enable = mkOption { 12 12 type = types.bool; 13 13 default = false; 14 14 description = "Enable tangled appview"; 15 15 }; 16 + 16 17 package = mkOption { 17 18 type = types.package; 18 19 description = "Package to use for the appview"; 19 20 }; 21 + 22 + # core configuration 20 23 port = mkOption { 21 - type = types.int; 24 + type = types.port; 22 25 default = 3000; 23 26 description = "Port to run the appview on"; 24 27 }; 25 - cookie_secret = mkOption { 28 + 29 + listenAddr = mkOption { 30 + type = types.str; 31 + default = "0.0.0.0:${toString cfg.port}"; 32 + description = "Listen address for the appview service"; 33 + }; 34 + 35 + dbPath = mkOption { 26 36 type = types.str; 27 - default = "00000000000000000000000000000000"; 28 - description = "Cookie secret"; 37 + default = "/var/lib/appview/appview.db"; 38 + description = "Path to the SQLite database file"; 39 + }; 40 + 41 + appviewHost = mkOption { 42 + type = types.str; 43 + default = "https://tangled.org"; 44 + example = "https://example.com"; 45 + description = "Public host URL for the appview instance"; 46 + }; 47 + 48 + appviewName = mkOption { 49 + type = types.str; 50 + default = "Tangled"; 51 + description = "Display name for the appview instance"; 52 + }; 53 + 54 + dev = mkOption { 55 + type = types.bool; 56 + default = false; 57 + description = "Enable development mode"; 58 + }; 59 + 60 + disallowedNicknamesFile = mkOption { 61 + type = types.nullOr types.path; 62 + default = null; 63 + description = "Path to file containing disallowed nicknames"; 64 + }; 65 + 66 + # redis configuration 67 + redis = { 68 + addr = mkOption { 69 + type = types.str; 70 + default = "localhost:6379"; 71 + description = "Redis server address"; 72 + }; 73 + 74 + db = mkOption { 75 + type = types.int; 76 + default = 0; 77 + description = "Redis database number"; 78 + }; 79 + }; 80 + 81 + # jetstream configuration 82 + jetstream = { 83 + endpoint = mkOption { 84 + type = types.str; 85 + default = "wss://jetstream1.us-east.bsky.network/subscribe"; 86 + description = "Jetstream WebSocket endpoint"; 87 + }; 88 + }; 89 + 90 + # knotstream consumer configuration 91 + knotstream = { 92 + retryInterval = mkOption { 93 + type = types.str; 94 + default = "60s"; 95 + description = "Initial retry interval for knotstream consumer"; 96 + }; 97 + 98 + maxRetryInterval = mkOption { 99 + type = types.str; 100 + default = "120m"; 101 + description = "Maximum retry interval for knotstream consumer"; 102 + }; 103 + 104 + connectionTimeout = mkOption { 105 + type = types.str; 106 + default = "5s"; 107 + description = "Connection timeout for knotstream consumer"; 108 + }; 109 + 110 + workerCount = mkOption { 111 + type = types.int; 112 + default = 64; 113 + description = "Number of workers for knotstream consumer"; 114 + }; 115 + 116 + queueSize = mkOption { 117 + type = types.int; 118 + default = 100; 119 + description = "Queue size for knotstream consumer"; 120 + }; 121 + }; 122 + 123 + # spindlestream consumer configuration 124 + spindlestream = { 125 + retryInterval = mkOption { 126 + type = types.str; 127 + default = "60s"; 128 + description = "Initial retry interval for spindlestream consumer"; 129 + }; 130 + 131 + maxRetryInterval = mkOption { 132 + type = types.str; 133 + default = "120m"; 134 + description = "Maximum retry interval for spindlestream consumer"; 135 + }; 136 + 137 + connectionTimeout = mkOption { 138 + type = types.str; 139 + default = "5s"; 140 + description = "Connection timeout for spindlestream consumer"; 141 + }; 142 + 143 + workerCount = mkOption { 144 + type = types.int; 145 + default = 64; 146 + description = "Number of workers for spindlestream consumer"; 147 + }; 148 + 149 + queueSize = mkOption { 150 + type = types.int; 151 + default = 100; 152 + description = "Queue size for spindlestream consumer"; 153 + }; 154 + }; 155 + 156 + # resend configuration 157 + resend = { 158 + sentFrom = mkOption { 159 + type = types.str; 160 + default = "noreply@notifs.tangled.sh"; 161 + description = "Email address to send notifications from"; 162 + }; 163 + }; 164 + 165 + # posthog configuration 166 + posthog = { 167 + endpoint = mkOption { 168 + type = types.str; 169 + default = "https://eu.i.posthog.com"; 170 + description = "PostHog API endpoint"; 171 + }; 172 + }; 173 + 174 + # camo configuration 175 + camo = { 176 + host = mkOption { 177 + type = types.str; 178 + default = "https://camo.tangled.sh"; 179 + description = "Camo proxy host URL"; 180 + }; 29 181 }; 182 + 183 + # avatar configuration 184 + avatar = { 185 + host = mkOption { 186 + type = types.str; 187 + default = "https://avatar.tangled.sh"; 188 + description = "Avatar service host URL"; 189 + }; 190 + }; 191 + 192 + plc = { 193 + url = mkOption { 194 + type = types.str; 195 + default = "https://plc.directory"; 196 + description = "PLC directory URL"; 197 + }; 198 + }; 199 + 200 + pds = { 201 + host = mkOption { 202 + type = types.str; 203 + default = "https://tngl.sh"; 204 + description = "PDS host URL"; 205 + }; 206 + }; 207 + 208 + label = { 209 + defaults = mkOption { 210 + type = types.listOf types.str; 211 + default = [ 212 + "at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/wontfix" 213 + "at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/good-first-issue" 214 + "at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/duplicate" 215 + "at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/documentation" 216 + "at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/assignee" 217 + ]; 218 + description = "Default label definitions"; 219 + }; 220 + 221 + goodFirstIssue = mkOption { 222 + type = types.str; 223 + default = "at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/good-first-issue"; 224 + description = "Good first issue label definition"; 225 + }; 226 + }; 227 + 30 228 environmentFile = mkOption { 31 229 type = with types; nullOr path; 32 230 default = null; 33 - example = "/etc/tangled-appview.env"; 231 + example = "/etc/appview.env"; 34 232 description = '' 35 233 Additional environment file as defined in {manpage}`systemd.exec(5)`. 36 234 37 - Sensitive secrets such as {env}`TANGLED_COOKIE_SECRET` may be 38 - passed to the service without makeing them world readable in the 39 - nix store. 40 - 235 + Sensitive secrets such as {env}`TANGLED_COOKIE_SECRET`, 236 + {env}`TANGLED_OAUTH_CLIENT_SECRET`, {env}`TANGLED_RESEND_API_KEY`, 237 + {env}`TANGLED_CAMO_SHARED_SECRET`, {env}`TANGLED_AVATAR_SHARED_SECRET`, 238 + {env}`TANGLED_REDIS_PASS`, {env}`TANGLED_PDS_ADMIN_SECRET`, 239 + {env}`TANGLED_CLOUDFLARE_API_TOKEN`, {env}`TANGLED_CLOUDFLARE_ZONE_ID`, 240 + {env}`TANGLED_CLOUDFLARE_TURNSTILE_SITE_KEY`, 241 + {env}`TANGLED_CLOUDFLARE_TURNSTILE_SECRET_KEY`, 242 + {env}`TANGLED_POSTHOG_API_KEY`, {env}`TANGLED_APP_PASSWORD`, 243 + and {env}`TANGLED_ALT_APP_PASSWORD` may be passed to the service 244 + without making them world readable in the nix store. 41 245 ''; 42 246 }; 43 247 }; 44 248 }; 45 249 46 250 config = mkIf cfg.enable { 47 - systemd.services.tangled-appview = { 251 + services.redis.servers.appview = { 252 + enable = true; 253 + port = 6379; 254 + }; 255 + 256 + systemd.services.appview = { 48 257 description = "tangled appview service"; 49 258 wantedBy = ["multi-user.target"]; 259 + after = ["redis-appview.service" "network-online.target"]; 260 + requires = ["redis-appview.service"]; 261 + wants = ["network-online.target"]; 50 262 51 263 serviceConfig = { 52 - ListenStream = "0.0.0.0:${toString cfg.port}"; 264 + Type = "simple"; 53 265 ExecStart = "${cfg.package}/bin/appview"; 54 266 Restart = "always"; 55 - EnvironmentFile = optional (cfg.environmentFile != null) cfg.environmentFile; 267 + RestartSec = "10s"; 268 + EnvironmentFile = mkIf (cfg.environmentFile != null) cfg.environmentFile; 269 + 270 + # state directory 271 + StateDirectory = "appview"; 272 + WorkingDirectory = "/var/lib/appview"; 273 + 274 + # security hardening 275 + NoNewPrivileges = true; 276 + PrivateTmp = true; 277 + ProtectSystem = "strict"; 278 + ProtectHome = true; 279 + ReadWritePaths = ["/var/lib/appview"]; 56 280 }; 57 281 58 - environment = { 59 - TANGLED_DB_PATH = "appview.db"; 60 - TANGLED_COOKIE_SECRET = cfg.cookie_secret; 61 - }; 282 + environment = 283 + { 284 + TANGLED_DB_PATH = cfg.dbPath; 285 + TANGLED_LISTEN_ADDR = cfg.listenAddr; 286 + TANGLED_APPVIEW_HOST = cfg.appviewHost; 287 + TANGLED_APPVIEW_NAME = cfg.appviewName; 288 + TANGLED_DEV = 289 + if cfg.dev 290 + then "true" 291 + else "false"; 292 + } 293 + // optionalAttrs (cfg.disallowedNicknamesFile != null) { 294 + TANGLED_DISALLOWED_NICKNAMES_FILE = cfg.disallowedNicknamesFile; 295 + } 296 + // { 297 + TANGLED_REDIS_ADDR = cfg.redis.addr; 298 + TANGLED_REDIS_DB = toString cfg.redis.db; 299 + 300 + TANGLED_JETSTREAM_ENDPOINT = cfg.jetstream.endpoint; 301 + 302 + TANGLED_KNOTSTREAM_RETRY_INTERVAL = cfg.knotstream.retryInterval; 303 + TANGLED_KNOTSTREAM_MAX_RETRY_INTERVAL = cfg.knotstream.maxRetryInterval; 304 + TANGLED_KNOTSTREAM_CONNECTION_TIMEOUT = cfg.knotstream.connectionTimeout; 305 + TANGLED_KNOTSTREAM_WORKER_COUNT = toString cfg.knotstream.workerCount; 306 + TANGLED_KNOTSTREAM_QUEUE_SIZE = toString cfg.knotstream.queueSize; 307 + 308 + TANGLED_SPINDLESTREAM_RETRY_INTERVAL = cfg.spindlestream.retryInterval; 309 + TANGLED_SPINDLESTREAM_MAX_RETRY_INTERVAL = cfg.spindlestream.maxRetryInterval; 310 + TANGLED_SPINDLESTREAM_CONNECTION_TIMEOUT = cfg.spindlestream.connectionTimeout; 311 + TANGLED_SPINDLESTREAM_WORKER_COUNT = toString cfg.spindlestream.workerCount; 312 + TANGLED_SPINDLESTREAM_QUEUE_SIZE = toString cfg.spindlestream.queueSize; 313 + 314 + TANGLED_RESEND_SENT_FROM = cfg.resend.sentFrom; 315 + 316 + TANGLED_POSTHOG_ENDPOINT = cfg.posthog.endpoint; 317 + 318 + TANGLED_CAMO_HOST = cfg.camo.host; 319 + 320 + TANGLED_AVATAR_HOST = cfg.avatar.host; 321 + 322 + TANGLED_PLC_URL = cfg.plc.url; 323 + 324 + TANGLED_PDS_HOST = cfg.pds.host; 325 + 326 + TANGLED_LABEL_DEFAULTS = concatStringsSep "," cfg.label.defaults; 327 + TANGLED_LABEL_GFI = cfg.label.goodFirstIssue; 328 + }; 62 329 }; 63 330 }; 64 331 }
+76 -4
nix/modules/knot.nix
··· 4 4 lib, 5 5 ... 6 6 }: let 7 - cfg = config.services.tangled-knot; 7 + cfg = config.services.tangled.knot; 8 8 in 9 9 with lib; { 10 10 options = { 11 - services.tangled-knot = { 11 + services.tangled.knot = { 12 12 enable = mkOption { 13 13 type = types.bool; 14 14 default = false; ··· 51 51 description = "Path where repositories are scanned from"; 52 52 }; 53 53 54 + readme = mkOption { 55 + type = types.listOf types.str; 56 + default = [ 57 + "README.md" 58 + "readme.md" 59 + "README" 60 + "readme" 61 + "README.markdown" 62 + "readme.markdown" 63 + "README.txt" 64 + "readme.txt" 65 + "README.rst" 66 + "readme.rst" 67 + "README.org" 68 + "readme.org" 69 + "README.asciidoc" 70 + "readme.asciidoc" 71 + ]; 72 + description = "List of README filenames to look for (in priority order)"; 73 + }; 74 + 54 75 mainBranch = mkOption { 55 76 type = types.str; 56 77 default = "main"; 57 78 description = "Default branch name for repositories"; 79 + }; 80 + }; 81 + 82 + git = { 83 + userName = mkOption { 84 + type = types.str; 85 + default = "Tangled"; 86 + description = "Git user name used as committer"; 87 + }; 88 + 89 + userEmail = mkOption { 90 + type = types.str; 91 + default = "noreply@tangled.org"; 92 + description = "Git user email used as committer"; 58 93 }; 59 94 }; 60 95 ··· 111 146 description = "Hostname for the server (required)"; 112 147 }; 113 148 149 + plcUrl = mkOption { 150 + type = types.str; 151 + default = "https://plc.directory"; 152 + description = "atproto PLC directory"; 153 + }; 154 + 155 + jetstreamEndpoint = mkOption { 156 + type = types.str; 157 + default = "wss://jetstream1.us-west.bsky.network/subscribe"; 158 + description = "Jetstream endpoint to subscribe to"; 159 + }; 160 + 161 + logDids = mkOption { 162 + type = types.bool; 163 + default = true; 164 + description = "Enable logging of DIDs"; 165 + }; 166 + 114 167 dev = mkOption { 115 168 type = types.bool; 116 169 default = false; ··· 142 195 Match User ${cfg.gitUser} 143 196 AuthorizedKeysCommand /etc/ssh/keyfetch_wrapper 144 197 AuthorizedKeysCommandUser nobody 198 + ChallengeResponseAuthentication no 199 + PasswordAuthentication no 145 200 ''; 146 201 }; 147 202 ··· 178 233 mkdir -p "${cfg.stateDir}/.config/git" 179 234 cat > "${cfg.stateDir}/.config/git/config" << EOF 180 235 [user] 181 - name = Git User 182 - email = git@example.com 236 + name = ${cfg.git.userName} 237 + email = ${cfg.git.userEmail} 183 238 [receive] 184 239 advertisePushOptions = true 240 + [uploadpack] 241 + allowFilter = true 185 242 EOF 186 243 ${setMotd} 187 244 chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.stateDir}" ··· 193 250 WorkingDirectory = cfg.stateDir; 194 251 Environment = [ 195 252 "KNOT_REPO_SCAN_PATH=${cfg.repo.scanPath}" 253 + "KNOT_REPO_README=${concatStringsSep "," cfg.repo.readme}" 196 254 "KNOT_REPO_MAIN_BRANCH=${cfg.repo.mainBranch}" 255 + "KNOT_GIT_USER_NAME=${cfg.git.userName}" 256 + "KNOT_GIT_USER_EMAIL=${cfg.git.userEmail}" 197 257 "APPVIEW_ENDPOINT=${cfg.appviewEndpoint}" 198 258 "KNOT_SERVER_INTERNAL_LISTEN_ADDR=${cfg.server.internalListenAddr}" 199 259 "KNOT_SERVER_LISTEN_ADDR=${cfg.server.listenAddr}" 200 260 "KNOT_SERVER_DB_PATH=${cfg.server.dbPath}" 201 261 "KNOT_SERVER_HOSTNAME=${cfg.server.hostname}" 262 + "KNOT_SERVER_PLC_URL=${cfg.server.plcUrl}" 263 + "KNOT_SERVER_JETSTREAM_ENDPOINT=${cfg.server.jetstreamEndpoint}" 202 264 "KNOT_SERVER_OWNER=${cfg.server.owner}" 265 + "KNOT_SERVER_LOG_DIDS=${ 266 + if cfg.server.logDids 267 + then "true" 268 + else "false" 269 + }" 270 + "KNOT_SERVER_DEV=${ 271 + if cfg.server.dev 272 + then "true" 273 + else "false" 274 + }" 203 275 ]; 204 276 ExecStart = "${cfg.package}/bin/knot server"; 205 277 Restart = "always";
+10 -3
nix/modules/spindle.nix
··· 3 3 lib, 4 4 ... 5 5 }: let 6 - cfg = config.services.tangled-spindle; 6 + cfg = config.services.tangled.spindle; 7 7 in 8 8 with lib; { 9 9 options = { 10 - services.tangled-spindle = { 10 + services.tangled.spindle = { 11 11 enable = mkOption { 12 12 type = types.bool; 13 13 default = false; ··· 35 35 type = types.str; 36 36 example = "my.spindle.com"; 37 37 description = "Hostname for the server (required)"; 38 + }; 39 + 40 + plcUrl = mkOption { 41 + type = types.str; 42 + default = "https://plc.directory"; 43 + description = "atproto PLC directory"; 38 44 }; 39 45 40 46 jetstreamEndpoint = mkOption { ··· 119 125 "SPINDLE_SERVER_LISTEN_ADDR=${cfg.server.listenAddr}" 120 126 "SPINDLE_SERVER_DB_PATH=${cfg.server.dbPath}" 121 127 "SPINDLE_SERVER_HOSTNAME=${cfg.server.hostname}" 122 - "SPINDLE_SERVER_JETSTREAM=${cfg.server.jetstreamEndpoint}" 128 + "SPINDLE_SERVER_PLC_URL=${cfg.server.plcUrl}" 129 + "SPINDLE_SERVER_JETSTREAM_ENDPOINT=${cfg.server.jetstreamEndpoint}" 123 130 "SPINDLE_SERVER_DEV=${lib.boolToString cfg.server.dev}" 124 131 "SPINDLE_SERVER_OWNER=${cfg.server.owner}" 125 132 "SPINDLE_SERVER_MAX_JOB_COUNT=${toString cfg.server.maxJobCount}"
+8 -1
nix/pkgs/appview-static-files.nix
··· 5 5 lucide-src, 6 6 inter-fonts-src, 7 7 ibm-plex-mono-src, 8 + actor-typeahead-src, 8 9 sqlite-lib, 9 10 tailwindcss, 11 + dolly, 10 12 src, 11 13 }: 12 14 runCommandLocal "appview-static-files" { ··· 16 18 (allow file-read* (subpath "/System/Library/OpenSSL")) 17 19 ''; 18 20 } '' 19 - mkdir -p $out/{fonts,icons} && cd $out 21 + mkdir -p $out/{fonts,icons,logos} && cd $out 20 22 cp -f ${htmx-src} htmx.min.js 21 23 cp -f ${htmx-ws-src} htmx-ext-ws.min.js 22 24 cp -rf ${lucide-src}/*.svg icons/ ··· 24 26 cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 fonts/ 25 27 cp -f ${inter-fonts-src}/InterVariable*.ttf fonts/ 26 28 cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono*.woff2 fonts/ 29 + cp -f ${actor-typeahead-src}/actor-typeahead.js . 30 + 31 + ${dolly}/bin/dolly -output logos/dolly.png -size 180x180 32 + ${dolly}/bin/dolly -output logos/dolly.ico -size 48x48 33 + ${dolly}/bin/dolly -output logos/dolly.svg -color currentColor 27 34 # tailwindcss -c $src/tailwind.config.js -i $src/input.css -o tw.css won't work 28 35 # for whatever reason (produces broken css), so we are doing this instead 29 36 cd ${src} && ${tailwindcss}/bin/tailwindcss -i input.css -o $out/tw.css
+57
nix/pkgs/docs.nix
··· 1 + { 2 + pandoc, 3 + tailwindcss, 4 + runCommandLocal, 5 + inter-fonts-src, 6 + ibm-plex-mono-src, 7 + lucide-src, 8 + dolly, 9 + src, 10 + }: 11 + runCommandLocal "docs" {} '' 12 + mkdir -p working 13 + 14 + # copy templates, themes, styles, filters to working directory 15 + cp ${src}/docs/*.html working/ 16 + cp ${src}/docs/*.theme working/ 17 + cp ${src}/docs/*.css working/ 18 + 19 + # icons 20 + cp -rf ${lucide-src}/*.svg working/ 21 + 22 + # logo 23 + ${dolly}/bin/dolly -output working/dolly.svg -color currentColor 24 + 25 + # content - chunked 26 + ${pandoc}/bin/pandoc ${src}/docs/DOCS.md \ 27 + -o $out/ \ 28 + -t chunkedhtml \ 29 + --variable toc \ 30 + --variable-json single-page=false \ 31 + --toc-depth=2 \ 32 + --css=stylesheet.css \ 33 + --chunk-template="%i.html" \ 34 + --highlight-style=working/highlight.theme \ 35 + --template=working/template.html 36 + 37 + # content - single page 38 + ${pandoc}/bin/pandoc ${src}/docs/DOCS.md \ 39 + -o $out/single-page.html \ 40 + --toc \ 41 + --variable toc \ 42 + --variable single-page \ 43 + --toc-depth=2 \ 44 + --css=stylesheet.css \ 45 + --highlight-style=working/highlight.theme \ 46 + --template=working/template.html 47 + 48 + # fonts 49 + mkdir -p $out/static/fonts 50 + cp -f ${inter-fonts-src}/web/InterVariable*.woff2 $out/static/fonts/ 51 + cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 $out/static/fonts/ 52 + cp -f ${inter-fonts-src}/InterVariable*.ttf $out/static/fonts/ 53 + cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono*.woff2 $out/static/fonts/ 54 + 55 + # styles 56 + cd ${src} && ${tailwindcss}/bin/tailwindcss -i input.css -o $out/stylesheet.css 57 + ''
+21
nix/pkgs/dolly.nix
··· 1 + { 2 + buildGoApplication, 3 + modules, 4 + src, 5 + }: 6 + buildGoApplication { 7 + pname = "dolly"; 8 + version = "0.1.0"; 9 + inherit src modules; 10 + 11 + # patch the static dir 12 + postUnpack = '' 13 + pushd source 14 + mkdir -p appview/pages/static 15 + touch appview/pages/static/x 16 + popd 17 + ''; 18 + 19 + doCheck = false; 20 + subPackages = ["cmd/dolly"]; 21 + }
+1 -1
nix/pkgs/knot-unwrapped.nix
··· 4 4 sqlite-lib, 5 5 src, 6 6 }: let 7 - version = "1.9.1-alpha"; 7 + version = "1.11.0-alpha"; 8 8 in 9 9 buildGoApplication { 10 10 pname = "knot";
+7 -5
nix/pkgs/sqlite-lib.nix
··· 1 1 { 2 - gcc, 3 2 stdenv, 4 3 sqlite-lib-src, 5 4 }: 6 5 stdenv.mkDerivation { 7 6 name = "sqlite-lib"; 8 7 src = sqlite-lib-src; 9 - nativeBuildInputs = [gcc]; 8 + 10 9 buildPhase = '' 11 - gcc -c sqlite3.c 12 - ar rcs libsqlite3.a sqlite3.o 13 - ranlib libsqlite3.a 10 + $CC -c sqlite3.c 11 + $AR rcs libsqlite3.a sqlite3.o 12 + $RANLIB libsqlite3.a 13 + ''; 14 + 15 + installPhase = '' 14 16 mkdir -p $out/include $out/lib 15 17 cp *.h $out/include 16 18 cp libsqlite3.a $out/lib
+25 -12
nix/vm.nix
··· 8 8 var = builtins.getEnv name; 9 9 in 10 10 if var == "" 11 - then throw "\$${name} must be defined, see docs/hacking.md for more details" 11 + then throw "\$${name} must be defined, see https://docs.tangled.org/hacking-on-tangled.html#hacking-on-tangled for more details" 12 12 else var; 13 + envVarOr = name: default: let 14 + var = builtins.getEnv name; 15 + in 16 + if var != "" 17 + then var 18 + else default; 19 + 20 + plcUrl = envVarOr "TANGLED_VM_PLC_URL" "https://plc.directory"; 21 + jetstream = envVarOr "TANGLED_VM_JETSTREAM_ENDPOINT" "wss://jetstream1.us-west.bsky.network/subscribe"; 13 22 in 14 23 nixpkgs.lib.nixosSystem { 15 24 inherit system; ··· 39 48 # knot 40 49 { 41 50 from = "host"; 42 - host.port = 6000; 43 - guest.port = 6000; 51 + host.port = 6444; 52 + guest.port = 6444; 44 53 } 45 54 # spindle 46 55 { ··· 73 82 time.timeZone = "Europe/London"; 74 83 services.getty.autologinUser = "root"; 75 84 environment.systemPackages = with pkgs; [curl vim git sqlite litecli]; 76 - services.tangled-knot = { 85 + services.tangled.knot = { 77 86 enable = true; 78 87 motd = "Welcome to the development knot!\n"; 79 88 server = { 80 89 owner = envVar "TANGLED_VM_KNOT_OWNER"; 81 - hostname = "localhost:6000"; 82 - listenAddr = "0.0.0.0:6000"; 90 + hostname = envVarOr "TANGLED_VM_KNOT_HOST" "localhost:6444"; 91 + plcUrl = plcUrl; 92 + jetstreamEndpoint = jetstream; 93 + listenAddr = "0.0.0.0:6444"; 83 94 }; 84 95 }; 85 - services.tangled-spindle = { 96 + services.tangled.spindle = { 86 97 enable = true; 87 98 server = { 88 99 owner = envVar "TANGLED_VM_SPINDLE_OWNER"; 89 - hostname = "localhost:6555"; 100 + hostname = envVarOr "TANGLED_VM_SPINDLE_HOST" "localhost:6555"; 101 + plcUrl = plcUrl; 102 + jetstreamEndpoint = jetstream; 90 103 listenAddr = "0.0.0.0:6555"; 91 104 dev = true; 92 105 queueSize = 100; ··· 99 112 users = { 100 113 # So we don't have to deal with permission clashing between 101 114 # blank disk VMs and existing state 102 - users.${config.services.tangled-knot.gitUser}.uid = 666; 103 - groups.${config.services.tangled-knot.gitUser}.gid = 666; 115 + users.${config.services.tangled.knot.gitUser}.uid = 666; 116 + groups.${config.services.tangled.knot.gitUser}.gid = 666; 104 117 105 118 # TODO: separate spindle user 106 119 }; ··· 120 133 serviceConfig.PermissionsStartOnly = true; 121 134 }; 122 135 in { 123 - knot = mkDataSyncScripts "/mnt/knot-data" config.services.tangled-knot.stateDir; 124 - spindle = mkDataSyncScripts "/mnt/spindle-data" (builtins.dirOf config.services.tangled-spindle.server.dbPath); 136 + knot = mkDataSyncScripts "/mnt/knot-data" config.services.tangled.knot.stateDir; 137 + spindle = mkDataSyncScripts "/mnt/spindle-data" (builtins.dirOf config.services.tangled.spindle.server.dbPath); 125 138 }; 126 139 }) 127 140 ];
+122
orm/orm.go
··· 1 + package orm 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + "fmt" 7 + "log/slog" 8 + "reflect" 9 + "strings" 10 + ) 11 + 12 + type migrationFn = func(*sql.Tx) error 13 + 14 + func RunMigration(c *sql.Conn, logger *slog.Logger, name string, migrationFn migrationFn) error { 15 + logger = logger.With("migration", name) 16 + 17 + tx, err := c.BeginTx(context.Background(), nil) 18 + if err != nil { 19 + return err 20 + } 21 + defer tx.Rollback() 22 + 23 + var exists bool 24 + err = tx.QueryRow("select exists (select 1 from migrations where name = ?)", name).Scan(&exists) 25 + if err != nil { 26 + return err 27 + } 28 + 29 + if !exists { 30 + // run migration 31 + err = migrationFn(tx) 32 + if err != nil { 33 + logger.Error("failed to run migration", "err", err) 34 + return err 35 + } 36 + 37 + // mark migration as complete 38 + _, err = tx.Exec("insert into migrations (name) values (?)", name) 39 + if err != nil { 40 + logger.Error("failed to mark migration as complete", "err", err) 41 + return err 42 + } 43 + 44 + // commit the transaction 45 + if err := tx.Commit(); err != nil { 46 + return err 47 + } 48 + 49 + logger.Info("migration applied successfully") 50 + } else { 51 + logger.Warn("skipped migration, already applied") 52 + } 53 + 54 + return nil 55 + } 56 + 57 + type Filter struct { 58 + Key string 59 + arg any 60 + Cmp string 61 + } 62 + 63 + func newFilter(key, cmp string, arg any) Filter { 64 + return Filter{ 65 + Key: key, 66 + arg: arg, 67 + Cmp: cmp, 68 + } 69 + } 70 + 71 + func FilterEq(key string, arg any) Filter { return newFilter(key, "=", arg) } 72 + func FilterNotEq(key string, arg any) Filter { return newFilter(key, "<>", arg) } 73 + func FilterGte(key string, arg any) Filter { return newFilter(key, ">=", arg) } 74 + func FilterLte(key string, arg any) Filter { return newFilter(key, "<=", arg) } 75 + func FilterIs(key string, arg any) Filter { return newFilter(key, "is", arg) } 76 + func FilterIsNot(key string, arg any) Filter { return newFilter(key, "is not", arg) } 77 + func FilterIn(key string, arg any) Filter { return newFilter(key, "in", arg) } 78 + func FilterLike(key string, arg any) Filter { return newFilter(key, "like", arg) } 79 + func FilterNotLike(key string, arg any) Filter { return newFilter(key, "not like", arg) } 80 + func FilterContains(key string, arg any) Filter { 81 + return newFilter(key, "like", fmt.Sprintf("%%%v%%", arg)) 82 + } 83 + 84 + func (f Filter) Condition() string { 85 + rv := reflect.ValueOf(f.arg) 86 + kind := rv.Kind() 87 + 88 + // if we have `FilterIn(k, [1, 2, 3])`, compile it down to `k in (?, ?, ?)` 89 + if (kind == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8) || kind == reflect.Array { 90 + if rv.Len() == 0 { 91 + // always false 92 + return "1 = 0" 93 + } 94 + 95 + placeholders := make([]string, rv.Len()) 96 + for i := range placeholders { 97 + placeholders[i] = "?" 98 + } 99 + 100 + return fmt.Sprintf("%s %s (%s)", f.Key, f.Cmp, strings.Join(placeholders, ", ")) 101 + } 102 + 103 + return fmt.Sprintf("%s %s ?", f.Key, f.Cmp) 104 + } 105 + 106 + func (f Filter) Arg() []any { 107 + rv := reflect.ValueOf(f.arg) 108 + kind := rv.Kind() 109 + if (kind == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8) || kind == reflect.Array { 110 + if rv.Len() == 0 { 111 + return nil 112 + } 113 + 114 + out := make([]any, rv.Len()) 115 + for i := range rv.Len() { 116 + out[i] = rv.Index(i).Interface() 117 + } 118 + return out 119 + } 120 + 121 + return []any{f.arg} 122 + }
-1
patchutil/patchutil.go
··· 296 296 } 297 297 298 298 nd := types.NiceDiff{} 299 - nd.Commit.Parent = targetBranch 300 299 301 300 for _, d := range diffs { 302 301 ndiff := types.Diff{}
+8
rbac/rbac.go
··· 285 285 return e.E.Enforce(user, domain, repo, "repo:delete") 286 286 } 287 287 288 + func (e *Enforcer) IsRepoOwner(user, domain, repo string) (bool, error) { 289 + return e.E.Enforce(user, domain, repo, "repo:owner") 290 + } 291 + 292 + func (e *Enforcer) IsRepoCollaborator(user, domain, repo string) (bool, error) { 293 + return e.E.Enforce(user, domain, repo, "repo:collaborator") 294 + } 295 + 288 296 func (e *Enforcer) IsPushAllowed(user, domain, repo string) (bool, error) { 289 297 return e.E.Enforce(user, domain, repo, "repo:push") 290 298 }
+3 -3
readme.md
··· 10 10 11 11 ## docs 12 12 13 - * [knot hosting guide](/docs/knot-hosting.md) 14 - * [contributing guide](/docs/contributing.md) **please read before opening a PR!** 15 - * [hacking on tangled](/docs/hacking.md) 13 + - [knot hosting guide](https://docs.tangled.org/knot-self-hosting-guide.html#knot-self-hosting-guide) 14 + - [contributing guide](https://docs.tangled.org/contribution-guide.html#contribution-guide) **please read before opening a PR!** 15 + - [hacking on tangled](https://docs.tangled.org/hacking-on-tangled.html#hacking-on-tangled) 16 16 17 17 ## security 18 18
+31
sets/gen.go
··· 1 + package sets 2 + 3 + import ( 4 + "math/rand" 5 + "reflect" 6 + "testing/quick" 7 + ) 8 + 9 + func (_ Set[T]) Generate(rand *rand.Rand, size int) reflect.Value { 10 + s := New[T]() 11 + 12 + var zero T 13 + itemType := reflect.TypeOf(zero) 14 + 15 + for { 16 + if s.Len() >= size { 17 + break 18 + } 19 + 20 + item, ok := quick.Value(itemType, rand) 21 + if !ok { 22 + continue 23 + } 24 + 25 + if val, ok := item.Interface().(T); ok { 26 + s.Insert(val) 27 + } 28 + } 29 + 30 + return reflect.ValueOf(s) 31 + }
+35
sets/readme.txt
··· 1 + sets 2 + ---- 3 + set datastructure for go with generics and iterators. the 4 + api is supposed to mimic rust's std::collections::HashSet api. 5 + 6 + s1 := sets.Collect(slices.Values([]int{1, 2, 3, 4})) 7 + s2 := sets.Collect(slices.Values([]int{1, 2, 3, 4, 5, 6})) 8 + 9 + union := sets.Collect(s1.Union(s2)) 10 + intersect := sets.Collect(s1.Intersection(s2)) 11 + diff := sets.Collect(s1.Difference(s2)) 12 + symdiff := sets.Collect(s1.SymmetricDifference(s2)) 13 + 14 + s1.Len() // 4 15 + s1.Contains(1) // true 16 + s1.IsEmpty() // false 17 + s1.IsSubset(s2) // true 18 + s1.IsSuperset(s2) // false 19 + s1.IsDisjoint(s2) // false 20 + 21 + if exists := s1.Insert(1); exists { 22 + // already existed in set 23 + } 24 + 25 + if existed := s1.Remove(1); existed { 26 + // existed in set, now removed 27 + } 28 + 29 + 30 + testing 31 + ------- 32 + includes property-based tests using the wonderful 33 + testing/quick module! 34 + 35 + go test -v
+174
sets/set.go
··· 1 + package sets 2 + 3 + import ( 4 + "iter" 5 + "maps" 6 + ) 7 + 8 + type Set[T comparable] struct { 9 + data map[T]struct{} 10 + } 11 + 12 + func New[T comparable]() Set[T] { 13 + return Set[T]{ 14 + data: make(map[T]struct{}), 15 + } 16 + } 17 + 18 + func (s *Set[T]) Insert(item T) bool { 19 + _, exists := s.data[item] 20 + s.data[item] = struct{}{} 21 + return !exists 22 + } 23 + 24 + func Singleton[T comparable](item T) Set[T] { 25 + n := New[T]() 26 + _ = n.Insert(item) 27 + return n 28 + } 29 + 30 + func (s *Set[T]) Remove(item T) bool { 31 + _, exists := s.data[item] 32 + if exists { 33 + delete(s.data, item) 34 + } 35 + return exists 36 + } 37 + 38 + func (s Set[T]) Contains(item T) bool { 39 + _, exists := s.data[item] 40 + return exists 41 + } 42 + 43 + func (s Set[T]) Len() int { 44 + return len(s.data) 45 + } 46 + 47 + func (s Set[T]) IsEmpty() bool { 48 + return len(s.data) == 0 49 + } 50 + 51 + func (s *Set[T]) Clear() { 52 + s.data = make(map[T]struct{}) 53 + } 54 + 55 + func (s Set[T]) All() iter.Seq[T] { 56 + return func(yield func(T) bool) { 57 + for item := range s.data { 58 + if !yield(item) { 59 + return 60 + } 61 + } 62 + } 63 + } 64 + 65 + func (s Set[T]) Clone() Set[T] { 66 + return Set[T]{ 67 + data: maps.Clone(s.data), 68 + } 69 + } 70 + 71 + func (s Set[T]) Union(other Set[T]) iter.Seq[T] { 72 + if s.Len() >= other.Len() { 73 + return chain(s.All(), other.Difference(s)) 74 + } else { 75 + return chain(other.All(), s.Difference(other)) 76 + } 77 + } 78 + 79 + func chain[T any](seqs ...iter.Seq[T]) iter.Seq[T] { 80 + return func(yield func(T) bool) { 81 + for _, seq := range seqs { 82 + for item := range seq { 83 + if !yield(item) { 84 + return 85 + } 86 + } 87 + } 88 + } 89 + } 90 + 91 + func (s Set[T]) Intersection(other Set[T]) iter.Seq[T] { 92 + return func(yield func(T) bool) { 93 + for item := range s.data { 94 + if other.Contains(item) { 95 + if !yield(item) { 96 + return 97 + } 98 + } 99 + } 100 + } 101 + } 102 + 103 + func (s Set[T]) Difference(other Set[T]) iter.Seq[T] { 104 + return func(yield func(T) bool) { 105 + for item := range s.data { 106 + if !other.Contains(item) { 107 + if !yield(item) { 108 + return 109 + } 110 + } 111 + } 112 + } 113 + } 114 + 115 + func (s Set[T]) SymmetricDifference(other Set[T]) iter.Seq[T] { 116 + return func(yield func(T) bool) { 117 + for item := range s.data { 118 + if !other.Contains(item) { 119 + if !yield(item) { 120 + return 121 + } 122 + } 123 + } 124 + for item := range other.data { 125 + if !s.Contains(item) { 126 + if !yield(item) { 127 + return 128 + } 129 + } 130 + } 131 + } 132 + } 133 + 134 + func (s Set[T]) IsSubset(other Set[T]) bool { 135 + for item := range s.data { 136 + if !other.Contains(item) { 137 + return false 138 + } 139 + } 140 + return true 141 + } 142 + 143 + func (s Set[T]) IsSuperset(other Set[T]) bool { 144 + return other.IsSubset(s) 145 + } 146 + 147 + func (s Set[T]) IsDisjoint(other Set[T]) bool { 148 + for item := range s.data { 149 + if other.Contains(item) { 150 + return false 151 + } 152 + } 153 + return true 154 + } 155 + 156 + func (s Set[T]) Equal(other Set[T]) bool { 157 + if s.Len() != other.Len() { 158 + return false 159 + } 160 + for item := range s.data { 161 + if !other.Contains(item) { 162 + return false 163 + } 164 + } 165 + return true 166 + } 167 + 168 + func Collect[T comparable](seq iter.Seq[T]) Set[T] { 169 + result := New[T]() 170 + for item := range seq { 171 + result.Insert(item) 172 + } 173 + return result 174 + }
+411
sets/set_test.go
··· 1 + package sets 2 + 3 + import ( 4 + "slices" 5 + "testing" 6 + "testing/quick" 7 + ) 8 + 9 + func TestNew(t *testing.T) { 10 + s := New[int]() 11 + if s.Len() != 0 { 12 + t.Errorf("New set should be empty, got length %d", s.Len()) 13 + } 14 + if !s.IsEmpty() { 15 + t.Error("New set should be empty") 16 + } 17 + } 18 + 19 + func TestFromSlice(t *testing.T) { 20 + s := Collect(slices.Values([]int{1, 2, 3, 2, 1})) 21 + if s.Len() != 3 { 22 + t.Errorf("Expected length 3, got %d", s.Len()) 23 + } 24 + if !s.Contains(1) || !s.Contains(2) || !s.Contains(3) { 25 + t.Error("Set should contain all unique elements from slice") 26 + } 27 + } 28 + 29 + func TestInsert(t *testing.T) { 30 + s := New[string]() 31 + 32 + if !s.Insert("hello") { 33 + t.Error("First insert should return true") 34 + } 35 + if s.Insert("hello") { 36 + t.Error("Duplicate insert should return false") 37 + } 38 + if s.Len() != 1 { 39 + t.Errorf("Expected length 1, got %d", s.Len()) 40 + } 41 + } 42 + 43 + func TestRemove(t *testing.T) { 44 + s := Collect(slices.Values([]int{1, 2, 3})) 45 + 46 + if !s.Remove(2) { 47 + t.Error("Remove existing element should return true") 48 + } 49 + if s.Remove(2) { 50 + t.Error("Remove non-existing element should return false") 51 + } 52 + if s.Contains(2) { 53 + t.Error("Element should be removed") 54 + } 55 + if s.Len() != 2 { 56 + t.Errorf("Expected length 2, got %d", s.Len()) 57 + } 58 + } 59 + 60 + func TestContains(t *testing.T) { 61 + s := Collect(slices.Values([]int{1, 2, 3})) 62 + 63 + if !s.Contains(1) { 64 + t.Error("Should contain 1") 65 + } 66 + if s.Contains(4) { 67 + t.Error("Should not contain 4") 68 + } 69 + } 70 + 71 + func TestClear(t *testing.T) { 72 + s := Collect(slices.Values([]int{1, 2, 3})) 73 + s.Clear() 74 + 75 + if !s.IsEmpty() { 76 + t.Error("Set should be empty after clear") 77 + } 78 + if s.Len() != 0 { 79 + t.Errorf("Expected length 0, got %d", s.Len()) 80 + } 81 + } 82 + 83 + func TestIterator(t *testing.T) { 84 + s := Collect(slices.Values([]int{1, 2, 3})) 85 + var items []int 86 + 87 + for item := range s.All() { 88 + items = append(items, item) 89 + } 90 + 91 + slices.Sort(items) 92 + expected := []int{1, 2, 3} 93 + if !slices.Equal(items, expected) { 94 + t.Errorf("Expected %v, got %v", expected, items) 95 + } 96 + } 97 + 98 + func TestClone(t *testing.T) { 99 + s1 := Collect(slices.Values([]int{1, 2, 3})) 100 + s2 := s1.Clone() 101 + 102 + if !s1.Equal(s2) { 103 + t.Error("Cloned set should be equal to original") 104 + } 105 + 106 + s2.Insert(4) 107 + if s1.Contains(4) { 108 + t.Error("Modifying clone should not affect original") 109 + } 110 + } 111 + 112 + func TestUnion(t *testing.T) { 113 + s1 := Collect(slices.Values([]int{1, 2})) 114 + s2 := Collect(slices.Values([]int{2, 3})) 115 + 116 + result := Collect(s1.Union(s2)) 117 + expected := Collect(slices.Values([]int{1, 2, 3})) 118 + 119 + if !result.Equal(expected) { 120 + t.Errorf("Expected %v, got %v", expected, result) 121 + } 122 + } 123 + 124 + func TestIntersection(t *testing.T) { 125 + s1 := Collect(slices.Values([]int{1, 2, 3})) 126 + s2 := Collect(slices.Values([]int{2, 3, 4})) 127 + 128 + expected := Collect(slices.Values([]int{2, 3})) 129 + result := Collect(s1.Intersection(s2)) 130 + 131 + if !result.Equal(expected) { 132 + t.Errorf("Expected %v, got %v", expected, result) 133 + } 134 + } 135 + 136 + func TestDifference(t *testing.T) { 137 + s1 := Collect(slices.Values([]int{1, 2, 3})) 138 + s2 := Collect(slices.Values([]int{2, 3, 4})) 139 + 140 + expected := Collect(slices.Values([]int{1})) 141 + result := Collect(s1.Difference(s2)) 142 + 143 + if !result.Equal(expected) { 144 + t.Errorf("Expected %v, got %v", expected, result) 145 + } 146 + } 147 + 148 + func TestSymmetricDifference(t *testing.T) { 149 + s1 := Collect(slices.Values([]int{1, 2, 3})) 150 + s2 := Collect(slices.Values([]int{2, 3, 4})) 151 + 152 + expected := Collect(slices.Values([]int{1, 4})) 153 + result := Collect(s1.SymmetricDifference(s2)) 154 + 155 + if !result.Equal(expected) { 156 + t.Errorf("Expected %v, got %v", expected, result) 157 + } 158 + } 159 + 160 + func TestSymmetricDifferenceCommutativeProperty(t *testing.T) { 161 + s1 := Collect(slices.Values([]int{1, 2, 3})) 162 + s2 := Collect(slices.Values([]int{2, 3, 4})) 163 + 164 + result1 := Collect(s1.SymmetricDifference(s2)) 165 + result2 := Collect(s2.SymmetricDifference(s1)) 166 + 167 + if !result1.Equal(result2) { 168 + t.Errorf("Expected %v, got %v", result1, result2) 169 + } 170 + } 171 + 172 + func TestIsSubset(t *testing.T) { 173 + s1 := Collect(slices.Values([]int{1, 2})) 174 + s2 := Collect(slices.Values([]int{1, 2, 3})) 175 + 176 + if !s1.IsSubset(s2) { 177 + t.Error("s1 should be subset of s2") 178 + } 179 + if s2.IsSubset(s1) { 180 + t.Error("s2 should not be subset of s1") 181 + } 182 + } 183 + 184 + func TestIsSuperset(t *testing.T) { 185 + s1 := Collect(slices.Values([]int{1, 2, 3})) 186 + s2 := Collect(slices.Values([]int{1, 2})) 187 + 188 + if !s1.IsSuperset(s2) { 189 + t.Error("s1 should be superset of s2") 190 + } 191 + if s2.IsSuperset(s1) { 192 + t.Error("s2 should not be superset of s1") 193 + } 194 + } 195 + 196 + func TestIsDisjoint(t *testing.T) { 197 + s1 := Collect(slices.Values([]int{1, 2})) 198 + s2 := Collect(slices.Values([]int{3, 4})) 199 + s3 := Collect(slices.Values([]int{2, 3})) 200 + 201 + if !s1.IsDisjoint(s2) { 202 + t.Error("s1 and s2 should be disjoint") 203 + } 204 + if s1.IsDisjoint(s3) { 205 + t.Error("s1 and s3 should not be disjoint") 206 + } 207 + } 208 + 209 + func TestEqual(t *testing.T) { 210 + s1 := Collect(slices.Values([]int{1, 2, 3})) 211 + s2 := Collect(slices.Values([]int{3, 2, 1})) 212 + s3 := Collect(slices.Values([]int{1, 2})) 213 + 214 + if !s1.Equal(s2) { 215 + t.Error("s1 and s2 should be equal") 216 + } 217 + if s1.Equal(s3) { 218 + t.Error("s1 and s3 should not be equal") 219 + } 220 + } 221 + 222 + func TestCollect(t *testing.T) { 223 + s1 := Collect(slices.Values([]int{1, 2})) 224 + s2 := Collect(slices.Values([]int{2, 3})) 225 + 226 + unionSet := Collect(s1.Union(s2)) 227 + if unionSet.Len() != 3 { 228 + t.Errorf("Expected union set length 3, got %d", unionSet.Len()) 229 + } 230 + if !unionSet.Contains(1) || !unionSet.Contains(2) || !unionSet.Contains(3) { 231 + t.Error("Union set should contain 1, 2, and 3") 232 + } 233 + 234 + diffSet := Collect(s1.Difference(s2)) 235 + if diffSet.Len() != 1 { 236 + t.Errorf("Expected difference set length 1, got %d", diffSet.Len()) 237 + } 238 + if !diffSet.Contains(1) { 239 + t.Error("Difference set should contain 1") 240 + } 241 + } 242 + 243 + func TestPropertySingleonLen(t *testing.T) { 244 + f := func(item int) bool { 245 + single := Singleton(item) 246 + return single.Len() == 1 247 + } 248 + 249 + if err := quick.Check(f, nil); err != nil { 250 + t.Error(err) 251 + } 252 + } 253 + 254 + func TestPropertyInsertIdempotent(t *testing.T) { 255 + f := func(s Set[int], item int) bool { 256 + clone := s.Clone() 257 + 258 + clone.Insert(item) 259 + firstLen := clone.Len() 260 + 261 + clone.Insert(item) 262 + secondLen := clone.Len() 263 + 264 + return firstLen == secondLen 265 + } 266 + 267 + if err := quick.Check(f, nil); err != nil { 268 + t.Error(err) 269 + } 270 + } 271 + 272 + func TestPropertyUnionCommutative(t *testing.T) { 273 + f := func(s1 Set[int], s2 Set[int]) bool { 274 + union1 := Collect(s1.Union(s2)) 275 + union2 := Collect(s2.Union(s1)) 276 + return union1.Equal(union2) 277 + } 278 + 279 + if err := quick.Check(f, nil); err != nil { 280 + t.Error(err) 281 + } 282 + } 283 + 284 + func TestPropertyIntersectionCommutative(t *testing.T) { 285 + f := func(s1 Set[int], s2 Set[int]) bool { 286 + inter1 := Collect(s1.Intersection(s2)) 287 + inter2 := Collect(s2.Intersection(s1)) 288 + return inter1.Equal(inter2) 289 + } 290 + 291 + if err := quick.Check(f, nil); err != nil { 292 + t.Error(err) 293 + } 294 + } 295 + 296 + func TestPropertyCloneEquals(t *testing.T) { 297 + f := func(s Set[int]) bool { 298 + clone := s.Clone() 299 + return s.Equal(clone) 300 + } 301 + 302 + if err := quick.Check(f, nil); err != nil { 303 + t.Error(err) 304 + } 305 + } 306 + 307 + func TestPropertyIntersectionIsSubset(t *testing.T) { 308 + f := func(s1 Set[int], s2 Set[int]) bool { 309 + inter := Collect(s1.Intersection(s2)) 310 + return inter.IsSubset(s1) && inter.IsSubset(s2) 311 + } 312 + 313 + if err := quick.Check(f, nil); err != nil { 314 + t.Error(err) 315 + } 316 + } 317 + 318 + func TestPropertyUnionIsSuperset(t *testing.T) { 319 + f := func(s1 Set[int], s2 Set[int]) bool { 320 + union := Collect(s1.Union(s2)) 321 + return union.IsSuperset(s1) && union.IsSuperset(s2) 322 + } 323 + 324 + if err := quick.Check(f, nil); err != nil { 325 + t.Error(err) 326 + } 327 + } 328 + 329 + func TestPropertyDifferenceDisjoint(t *testing.T) { 330 + f := func(s1 Set[int], s2 Set[int]) bool { 331 + diff := Collect(s1.Difference(s2)) 332 + return diff.IsDisjoint(s2) 333 + } 334 + 335 + if err := quick.Check(f, nil); err != nil { 336 + t.Error(err) 337 + } 338 + } 339 + 340 + func TestPropertySymmetricDifferenceCommutative(t *testing.T) { 341 + f := func(s1 Set[int], s2 Set[int]) bool { 342 + symDiff1 := Collect(s1.SymmetricDifference(s2)) 343 + symDiff2 := Collect(s2.SymmetricDifference(s1)) 344 + return symDiff1.Equal(symDiff2) 345 + } 346 + 347 + if err := quick.Check(f, nil); err != nil { 348 + t.Error(err) 349 + } 350 + } 351 + 352 + func TestPropertyRemoveWorks(t *testing.T) { 353 + f := func(s Set[int], item int) bool { 354 + clone := s.Clone() 355 + clone.Insert(item) 356 + clone.Remove(item) 357 + return !clone.Contains(item) 358 + } 359 + 360 + if err := quick.Check(f, nil); err != nil { 361 + t.Error(err) 362 + } 363 + } 364 + 365 + func TestPropertyClearEmpty(t *testing.T) { 366 + f := func(s Set[int]) bool { 367 + s.Clear() 368 + return s.IsEmpty() && s.Len() == 0 369 + } 370 + 371 + if err := quick.Check(f, nil); err != nil { 372 + t.Error(err) 373 + } 374 + } 375 + 376 + func TestPropertyIsSubsetReflexive(t *testing.T) { 377 + f := func(s Set[int]) bool { 378 + return s.IsSubset(s) 379 + } 380 + 381 + if err := quick.Check(f, nil); err != nil { 382 + t.Error(err) 383 + } 384 + } 385 + 386 + func TestPropertyDeMorganUnion(t *testing.T) { 387 + f := func(s1 Set[int], s2 Set[int], universe Set[int]) bool { 388 + // create a universe that contains both sets 389 + u := universe.Clone() 390 + for item := range s1.All() { 391 + u.Insert(item) 392 + } 393 + for item := range s2.All() { 394 + u.Insert(item) 395 + } 396 + 397 + // (A u B)' = A' n B' 398 + union := Collect(s1.Union(s2)) 399 + complementUnion := Collect(u.Difference(union)) 400 + 401 + complementS1 := Collect(u.Difference(s1)) 402 + complementS2 := Collect(u.Difference(s2)) 403 + intersectionComplements := Collect(complementS1.Intersection(complementS2)) 404 + 405 + return complementUnion.Equal(intersectionComplements) 406 + } 407 + 408 + if err := quick.Check(f, nil); err != nil { 409 + t.Error(err) 410 + } 411 + }
+1
spindle/config/config.go
··· 13 13 DBPath string `env:"DB_PATH, default=spindle.db"` 14 14 Hostname string `env:"HOSTNAME, required"` 15 15 JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"` 16 + PlcUrl string `env:"PLC_URL, default=https://plc.directory"` 16 17 Dev bool `env:"DEV, default=false"` 17 18 Owner string `env:"OWNER, required"` 18 19 Secrets Secrets `env:",prefix=SECRETS_"`
+1
spindle/db/repos.go
··· 16 16 if err != nil { 17 17 return nil, err 18 18 } 19 + defer rows.Close() 19 20 20 21 var knots []string 21 22 for rows.Next() {
+34 -23
spindle/engine/engine.go
··· 3 3 import ( 4 4 "context" 5 5 "errors" 6 - "fmt" 7 6 "log/slog" 7 + "sync" 8 8 9 9 securejoin "github.com/cyphar/filepath-securejoin" 10 - "golang.org/x/sync/errgroup" 11 10 "tangled.org/core/notifier" 12 11 "tangled.org/core/spindle/config" 13 12 "tangled.org/core/spindle/db" ··· 31 30 } 32 31 } 33 32 34 - eg, ctx := errgroup.WithContext(ctx) 33 + var wg sync.WaitGroup 35 34 for eng, wfs := range pipeline.Workflows { 36 35 workflowTimeout := eng.WorkflowTimeout() 37 36 l.Info("using workflow timeout", "timeout", workflowTimeout) 38 37 39 38 for _, w := range wfs { 40 - eg.Go(func() error { 39 + wg.Add(1) 40 + go func() { 41 + defer wg.Done() 42 + 41 43 wid := models.WorkflowId{ 42 44 PipelineId: pipelineId, 43 45 Name: w.Name, ··· 45 47 46 48 err := db.StatusRunning(wid, n) 47 49 if err != nil { 48 - return err 50 + l.Error("failed to set workflow status to running", "wid", wid, "err", err) 51 + return 49 52 } 50 53 51 54 err = eng.SetupWorkflow(ctx, wid, &w) ··· 61 64 62 65 dbErr := db.StatusFailed(wid, err.Error(), -1, n) 63 66 if dbErr != nil { 64 - return dbErr 67 + l.Error("failed to set workflow status to failed", "wid", wid, "err", dbErr) 65 68 } 66 - return err 69 + return 67 70 } 68 71 defer eng.DestroyWorkflow(ctx, wid) 69 72 70 - wfLogger, err := models.NewWorkflowLogger(cfg.Server.LogDir, wid) 73 + secretValues := make([]string, len(allSecrets)) 74 + for i, s := range allSecrets { 75 + secretValues[i] = s.Value 76 + } 77 + wfLogger, err := models.NewWorkflowLogger(cfg.Server.LogDir, wid, secretValues) 71 78 if err != nil { 72 79 l.Warn("failed to setup step logger; logs will not be persisted", "error", err) 73 80 wfLogger = nil ··· 79 86 defer cancel() 80 87 81 88 for stepIdx, step := range w.Steps { 89 + // log start of step 82 90 if wfLogger != nil { 83 - ctl := wfLogger.ControlWriter(stepIdx, step) 84 - ctl.Write([]byte(step.Name())) 91 + wfLogger. 92 + ControlWriter(stepIdx, step, models.StepStatusStart). 93 + Write([]byte{0}) 85 94 } 86 95 87 96 err = eng.RunStep(ctx, wid, &w, stepIdx, allSecrets, wfLogger) 97 + 98 + // log end of step 99 + if wfLogger != nil { 100 + wfLogger. 101 + ControlWriter(stepIdx, step, models.StepStatusEnd). 102 + Write([]byte{0}) 103 + } 104 + 88 105 if err != nil { 89 106 if errors.Is(err, ErrTimedOut) { 90 107 dbErr := db.StatusTimeout(wid, n) 91 108 if dbErr != nil { 92 - return dbErr 109 + l.Error("failed to set workflow status to timeout", "wid", wid, "err", dbErr) 93 110 } 94 111 } else { 95 112 dbErr := db.StatusFailed(wid, err.Error(), -1, n) 96 113 if dbErr != nil { 97 - return dbErr 114 + l.Error("failed to set workflow status to failed", "wid", wid, "err", dbErr) 98 115 } 99 116 } 100 - 101 - return fmt.Errorf("starting steps image: %w", err) 117 + return 102 118 } 103 119 } 104 120 105 121 err = db.StatusSuccess(wid, n) 106 122 if err != nil { 107 - return err 123 + l.Error("failed to set workflow status to success", "wid", wid, "err", err) 108 124 } 109 - 110 - return nil 111 - }) 125 + }() 112 126 } 113 127 } 114 128 115 - if err := eg.Wait(); err != nil { 116 - l.Error("failed to run one or more workflows", "err", err) 117 - } else { 118 - l.Error("successfully ran full pipeline") 119 - } 129 + wg.Wait() 130 + l.Info("all workflows completed") 120 131 }
+12 -11
spindle/engines/nixery/engine.go
··· 73 73 type addlFields struct { 74 74 image string 75 75 container string 76 - env map[string]string 77 76 } 78 77 79 78 func (e *Engine) InitWorkflow(twf tangled.Pipeline_Workflow, tpl tangled.Pipeline) (*models.Workflow, error) { ··· 103 102 swf.Steps = append(swf.Steps, sstep) 104 103 } 105 104 swf.Name = twf.Name 106 - addl.env = dwf.Environment 105 + swf.Environment = dwf.Environment 107 106 addl.image = workflowImage(dwf.Dependencies, e.cfg.NixeryPipelines.Nixery) 108 107 109 108 setup := &setupSteps{} 110 109 111 110 setup.addStep(nixConfStep()) 112 - setup.addStep(cloneStep(twf, *tpl.TriggerMetadata, e.cfg.Server.Dev)) 111 + setup.addStep(models.BuildCloneStep(twf, *tpl.TriggerMetadata, e.cfg.Server.Dev)) 113 112 // this step could be empty 114 113 if s := dependencyStep(dwf.Dependencies); s != nil { 115 114 setup.addStep(*s) ··· 288 287 289 288 func (e *Engine) RunStep(ctx context.Context, wid models.WorkflowId, w *models.Workflow, idx int, secrets []secrets.UnlockedSecret, wfLogger *models.WorkflowLogger) error { 290 289 addl := w.Data.(addlFields) 291 - workflowEnvs := ConstructEnvs(addl.env) 290 + workflowEnvs := ConstructEnvs(w.Environment) 292 291 // TODO(winter): should SetupWorkflow also have secret access? 293 292 // IMO yes, but probably worth thinking on. 294 293 for _, s := range secrets { 295 294 workflowEnvs.AddEnv(s.Key, s.Value) 296 295 } 297 296 298 - step := w.Steps[idx].(Step) 297 + step := w.Steps[idx] 299 298 300 299 select { 301 300 case <-ctx.Done(): ··· 304 303 } 305 304 306 305 envs := append(EnvVars(nil), workflowEnvs...) 307 - for k, v := range step.environment { 308 - envs.AddEnv(k, v) 306 + if nixStep, ok := step.(Step); ok { 307 + for k, v := range nixStep.environment { 308 + envs.AddEnv(k, v) 309 + } 309 310 } 310 311 envs.AddEnv("HOME", homeDir) 311 312 312 313 mkExecResp, err := e.docker.ContainerExecCreate(ctx, addl.container, container.ExecOptions{ 313 - Cmd: []string{"bash", "-c", step.command}, 314 + Cmd: []string{"bash", "-c", step.Command()}, 314 315 AttachStdout: true, 315 316 AttachStderr: true, 316 317 Env: envs, ··· 333 334 // Docker doesn't provide an API to kill an exec run 334 335 // (sure, we could grab the PID and kill it ourselves, 335 336 // but that's wasted effort) 336 - e.l.Warn("step timed out", "step", step.Name) 337 + e.l.Warn("step timed out", "step", step.Name()) 337 338 338 339 <-tailDone 339 340 ··· 381 382 defer logs.Close() 382 383 383 384 _, err = stdcopy.StdCopy( 384 - wfLogger.DataWriter("stdout"), 385 - wfLogger.DataWriter("stderr"), 385 + wfLogger.DataWriter(stepIdx, "stdout"), 386 + wfLogger.DataWriter(stepIdx, "stderr"), 386 387 logs.Reader, 387 388 ) 388 389 if err != nil && err != io.EOF && !errors.Is(err, context.DeadlineExceeded) {
-73
spindle/engines/nixery/setup_steps.go
··· 2 2 3 3 import ( 4 4 "fmt" 5 - "path" 6 5 "strings" 7 - 8 - "tangled.org/core/api/tangled" 9 - "tangled.org/core/workflow" 10 6 ) 11 7 12 8 func nixConfStep() Step { ··· 17 13 command: setupCmd, 18 14 name: "Configure Nix", 19 15 } 20 - } 21 - 22 - // cloneOptsAsSteps processes clone options and adds corresponding steps 23 - // to the beginning of the workflow's step list if cloning is not skipped. 24 - // 25 - // the steps to do here are: 26 - // - git init 27 - // - git remote add origin <url> 28 - // - git fetch --depth=<d> --recurse-submodules=<yes|no> <sha> 29 - // - git checkout FETCH_HEAD 30 - func cloneStep(twf tangled.Pipeline_Workflow, tr tangled.Pipeline_TriggerMetadata, dev bool) Step { 31 - if twf.Clone.Skip { 32 - return Step{} 33 - } 34 - 35 - var commands []string 36 - 37 - // initialize git repo in workspace 38 - commands = append(commands, "git init") 39 - 40 - // add repo as git remote 41 - scheme := "https://" 42 - if dev { 43 - scheme = "http://" 44 - tr.Repo.Knot = strings.ReplaceAll(tr.Repo.Knot, "localhost", "host.docker.internal") 45 - } 46 - url := scheme + path.Join(tr.Repo.Knot, tr.Repo.Did, tr.Repo.Repo) 47 - commands = append(commands, fmt.Sprintf("git remote add origin %s", url)) 48 - 49 - // run git fetch 50 - { 51 - var fetchArgs []string 52 - 53 - // default clone depth is 1 54 - depth := 1 55 - if twf.Clone.Depth > 1 { 56 - depth = int(twf.Clone.Depth) 57 - } 58 - fetchArgs = append(fetchArgs, fmt.Sprintf("--depth=%d", depth)) 59 - 60 - // optionally recurse submodules 61 - if twf.Clone.Submodules { 62 - fetchArgs = append(fetchArgs, "--recurse-submodules=yes") 63 - } 64 - 65 - // set remote to fetch from 66 - fetchArgs = append(fetchArgs, "origin") 67 - 68 - // set revision to checkout 69 - switch workflow.TriggerKind(tr.Kind) { 70 - case workflow.TriggerKindManual: 71 - // TODO: unimplemented 72 - case workflow.TriggerKindPush: 73 - fetchArgs = append(fetchArgs, tr.Push.NewSha) 74 - case workflow.TriggerKindPullRequest: 75 - fetchArgs = append(fetchArgs, tr.PullRequest.SourceSha) 76 - } 77 - 78 - commands = append(commands, fmt.Sprintf("git fetch %s", strings.Join(fetchArgs, " "))) 79 - } 80 - 81 - // run git checkout 82 - commands = append(commands, "git checkout FETCH_HEAD") 83 - 84 - cloneStep := Step{ 85 - command: strings.Join(commands, "\n"), 86 - name: "Clone repository into workspace", 87 - } 88 - return cloneStep 89 16 } 90 17 91 18 // dependencyStep processes dependencies defined in the workflow.
+3 -7
spindle/ingester.go
··· 9 9 10 10 "tangled.org/core/api/tangled" 11 11 "tangled.org/core/eventconsumer" 12 - "tangled.org/core/idresolver" 13 12 "tangled.org/core/rbac" 14 13 "tangled.org/core/spindle/db" 15 14 ··· 142 141 func (s *Spindle) ingestRepo(ctx context.Context, e *models.Event) error { 143 142 var err error 144 143 did := e.Did 145 - resolver := idresolver.DefaultResolver() 146 144 147 145 l := s.l.With("component", "ingester", "record", tangled.RepoNSID) 148 146 ··· 190 188 } 191 189 192 190 // add collaborators to rbac 193 - owner, err := resolver.ResolveIdent(ctx, did) 191 + owner, err := s.res.ResolveIdent(ctx, did) 194 192 if err != nil || owner.Handle.IsInvalidHandle() { 195 193 return err 196 194 } ··· 225 223 return err 226 224 } 227 225 228 - resolver := idresolver.DefaultResolver() 229 - 230 - subjectId, err := resolver.ResolveIdent(ctx, record.Subject) 226 + subjectId, err := s.res.ResolveIdent(ctx, record.Subject) 231 227 if err != nil || subjectId.Handle.IsInvalidHandle() { 232 228 return err 233 229 } ··· 240 236 241 237 // TODO: get rid of this entirely 242 238 // resolve this aturi to extract the repo record 243 - owner, err := resolver.ResolveIdent(ctx, repoAt.Authority().String()) 239 + owner, err := s.res.ResolveIdent(ctx, repoAt.Authority().String()) 244 240 if err != nil || owner.Handle.IsInvalidHandle() { 245 241 return fmt.Errorf("failed to resolve handle: %w", err) 246 242 }
+150
spindle/models/clone.go
··· 1 + package models 2 + 3 + import ( 4 + "fmt" 5 + "strings" 6 + 7 + "tangled.org/core/api/tangled" 8 + "tangled.org/core/workflow" 9 + ) 10 + 11 + type CloneStep struct { 12 + name string 13 + kind StepKind 14 + commands []string 15 + } 16 + 17 + func (s CloneStep) Name() string { 18 + return s.name 19 + } 20 + 21 + func (s CloneStep) Commands() []string { 22 + return s.commands 23 + } 24 + 25 + func (s CloneStep) Command() string { 26 + return strings.Join(s.commands, "\n") 27 + } 28 + 29 + func (s CloneStep) Kind() StepKind { 30 + return s.kind 31 + } 32 + 33 + // BuildCloneStep generates git clone commands. 34 + // The caller must ensure the current working directory is set to the desired 35 + // workspace directory before executing these commands. 36 + // 37 + // The generated commands are: 38 + // - git init 39 + // - git remote add origin <url> 40 + // - git fetch --depth=<d> --recurse-submodules=<yes|no> <sha> 41 + // - git checkout FETCH_HEAD 42 + // 43 + // Supports all trigger types (push, PR, manual) and clone options. 44 + func BuildCloneStep(twf tangled.Pipeline_Workflow, tr tangled.Pipeline_TriggerMetadata, dev bool) CloneStep { 45 + if twf.Clone != nil && twf.Clone.Skip { 46 + return CloneStep{} 47 + } 48 + 49 + commitSHA, err := extractCommitSHA(tr) 50 + if err != nil { 51 + return CloneStep{ 52 + kind: StepKindSystem, 53 + name: "Clone repository into workspace (error)", 54 + commands: []string{fmt.Sprintf("echo 'Failed to get clone info: %s' && exit 1", err.Error())}, 55 + } 56 + } 57 + 58 + repoURL := BuildRepoURL(tr.Repo, dev) 59 + 60 + var cloneOpts tangled.Pipeline_CloneOpts 61 + if twf.Clone != nil { 62 + cloneOpts = *twf.Clone 63 + } 64 + fetchArgs := buildFetchArgs(cloneOpts, commitSHA) 65 + 66 + return CloneStep{ 67 + kind: StepKindSystem, 68 + name: "Clone repository into workspace", 69 + commands: []string{ 70 + "git init", 71 + fmt.Sprintf("git remote add origin %s", repoURL), 72 + fmt.Sprintf("git fetch %s", strings.Join(fetchArgs, " ")), 73 + "git checkout FETCH_HEAD", 74 + }, 75 + } 76 + } 77 + 78 + // extractCommitSHA extracts the commit SHA from trigger metadata based on trigger type 79 + func extractCommitSHA(tr tangled.Pipeline_TriggerMetadata) (string, error) { 80 + switch workflow.TriggerKind(tr.Kind) { 81 + case workflow.TriggerKindPush: 82 + if tr.Push == nil { 83 + return "", fmt.Errorf("push trigger metadata is nil") 84 + } 85 + return tr.Push.NewSha, nil 86 + 87 + case workflow.TriggerKindPullRequest: 88 + if tr.PullRequest == nil { 89 + return "", fmt.Errorf("pull request trigger metadata is nil") 90 + } 91 + return tr.PullRequest.SourceSha, nil 92 + 93 + case workflow.TriggerKindManual: 94 + // Manual triggers don't have an explicit SHA in the metadata 95 + // For now, return empty string - could be enhanced to fetch from default branch 96 + // TODO: Implement manual trigger SHA resolution (fetch default branch HEAD) 97 + return "", nil 98 + 99 + default: 100 + return "", fmt.Errorf("unknown trigger kind: %s", tr.Kind) 101 + } 102 + } 103 + 104 + // BuildRepoURL constructs the repository URL from repo metadata. 105 + func BuildRepoURL(repo *tangled.Pipeline_TriggerRepo, devMode bool) string { 106 + if repo == nil { 107 + return "" 108 + } 109 + 110 + scheme := "https://" 111 + if devMode { 112 + scheme = "http://" 113 + } 114 + 115 + // Get host from knot 116 + host := repo.Knot 117 + 118 + // In dev mode, replace localhost with host.docker.internal for Docker networking 119 + if devMode && strings.Contains(host, "localhost") { 120 + host = strings.ReplaceAll(host, "localhost", "host.docker.internal") 121 + } 122 + 123 + // Build URL: {scheme}{knot}/{did}/{repo} 124 + return fmt.Sprintf("%s%s/%s/%s", scheme, host, repo.Did, repo.Repo) 125 + } 126 + 127 + // buildFetchArgs constructs the arguments for git fetch based on clone options 128 + func buildFetchArgs(clone tangled.Pipeline_CloneOpts, sha string) []string { 129 + args := []string{} 130 + 131 + // Set fetch depth (default to 1 for shallow clone) 132 + depth := clone.Depth 133 + if depth == 0 { 134 + depth = 1 135 + } 136 + args = append(args, fmt.Sprintf("--depth=%d", depth)) 137 + 138 + // Add submodules if requested 139 + if clone.Submodules { 140 + args = append(args, "--recurse-submodules=yes") 141 + } 142 + 143 + // Add remote and SHA 144 + args = append(args, "origin") 145 + if sha != "" { 146 + args = append(args, sha) 147 + } 148 + 149 + return args 150 + }
+371
spindle/models/clone_test.go
··· 1 + package models 2 + 3 + import ( 4 + "strings" 5 + "testing" 6 + 7 + "tangled.org/core/api/tangled" 8 + "tangled.org/core/workflow" 9 + ) 10 + 11 + func TestBuildCloneStep_PushTrigger(t *testing.T) { 12 + twf := tangled.Pipeline_Workflow{ 13 + Clone: &tangled.Pipeline_CloneOpts{ 14 + Depth: 1, 15 + Submodules: false, 16 + Skip: false, 17 + }, 18 + } 19 + tr := tangled.Pipeline_TriggerMetadata{ 20 + Kind: string(workflow.TriggerKindPush), 21 + Push: &tangled.Pipeline_PushTriggerData{ 22 + NewSha: "abc123", 23 + OldSha: "def456", 24 + Ref: "refs/heads/main", 25 + }, 26 + Repo: &tangled.Pipeline_TriggerRepo{ 27 + Knot: "example.com", 28 + Did: "did:plc:user123", 29 + Repo: "my-repo", 30 + }, 31 + } 32 + 33 + step := BuildCloneStep(twf, tr, false) 34 + 35 + if step.Kind() != StepKindSystem { 36 + t.Errorf("Expected StepKindSystem, got %v", step.Kind()) 37 + } 38 + 39 + if step.Name() != "Clone repository into workspace" { 40 + t.Errorf("Expected 'Clone repository into workspace', got '%s'", step.Name()) 41 + } 42 + 43 + commands := step.Commands() 44 + if len(commands) != 4 { 45 + t.Errorf("Expected 4 commands, got %d", len(commands)) 46 + } 47 + 48 + // Verify commands contain expected git operations 49 + allCmds := strings.Join(commands, " ") 50 + if !strings.Contains(allCmds, "git init") { 51 + t.Error("Commands should contain 'git init'") 52 + } 53 + if !strings.Contains(allCmds, "git remote add origin") { 54 + t.Error("Commands should contain 'git remote add origin'") 55 + } 56 + if !strings.Contains(allCmds, "git fetch") { 57 + t.Error("Commands should contain 'git fetch'") 58 + } 59 + if !strings.Contains(allCmds, "abc123") { 60 + t.Error("Commands should contain commit SHA") 61 + } 62 + if !strings.Contains(allCmds, "git checkout FETCH_HEAD") { 63 + t.Error("Commands should contain 'git checkout FETCH_HEAD'") 64 + } 65 + if !strings.Contains(allCmds, "https://example.com/did:plc:user123/my-repo") { 66 + t.Error("Commands should contain expected repo URL") 67 + } 68 + } 69 + 70 + func TestBuildCloneStep_PullRequestTrigger(t *testing.T) { 71 + twf := tangled.Pipeline_Workflow{ 72 + Clone: &tangled.Pipeline_CloneOpts{ 73 + Depth: 1, 74 + Skip: false, 75 + }, 76 + } 77 + tr := tangled.Pipeline_TriggerMetadata{ 78 + Kind: string(workflow.TriggerKindPullRequest), 79 + PullRequest: &tangled.Pipeline_PullRequestTriggerData{ 80 + SourceSha: "pr-sha-789", 81 + SourceBranch: "feature-branch", 82 + TargetBranch: "main", 83 + Action: "opened", 84 + }, 85 + Repo: &tangled.Pipeline_TriggerRepo{ 86 + Knot: "example.com", 87 + Did: "did:plc:user123", 88 + Repo: "my-repo", 89 + }, 90 + } 91 + 92 + step := BuildCloneStep(twf, tr, false) 93 + 94 + allCmds := strings.Join(step.Commands(), " ") 95 + if !strings.Contains(allCmds, "pr-sha-789") { 96 + t.Error("Commands should contain PR commit SHA") 97 + } 98 + } 99 + 100 + func TestBuildCloneStep_ManualTrigger(t *testing.T) { 101 + twf := tangled.Pipeline_Workflow{ 102 + Clone: &tangled.Pipeline_CloneOpts{ 103 + Depth: 1, 104 + Skip: false, 105 + }, 106 + } 107 + tr := tangled.Pipeline_TriggerMetadata{ 108 + Kind: string(workflow.TriggerKindManual), 109 + Manual: &tangled.Pipeline_ManualTriggerData{ 110 + Inputs: nil, 111 + }, 112 + Repo: &tangled.Pipeline_TriggerRepo{ 113 + Knot: "example.com", 114 + Did: "did:plc:user123", 115 + Repo: "my-repo", 116 + }, 117 + } 118 + 119 + step := BuildCloneStep(twf, tr, false) 120 + 121 + // Manual triggers don't have a SHA yet (TODO), so git fetch won't include a SHA 122 + allCmds := strings.Join(step.Commands(), " ") 123 + // Should still have basic git commands 124 + if !strings.Contains(allCmds, "git init") { 125 + t.Error("Commands should contain 'git init'") 126 + } 127 + if !strings.Contains(allCmds, "git fetch") { 128 + t.Error("Commands should contain 'git fetch'") 129 + } 130 + } 131 + 132 + func TestBuildCloneStep_SkipFlag(t *testing.T) { 133 + twf := tangled.Pipeline_Workflow{ 134 + Clone: &tangled.Pipeline_CloneOpts{ 135 + Skip: true, 136 + }, 137 + } 138 + tr := tangled.Pipeline_TriggerMetadata{ 139 + Kind: string(workflow.TriggerKindPush), 140 + Push: &tangled.Pipeline_PushTriggerData{ 141 + NewSha: "abc123", 142 + }, 143 + Repo: &tangled.Pipeline_TriggerRepo{ 144 + Knot: "example.com", 145 + Did: "did:plc:user123", 146 + Repo: "my-repo", 147 + }, 148 + } 149 + 150 + step := BuildCloneStep(twf, tr, false) 151 + 152 + // Empty step when skip is true 153 + if step.Name() != "" { 154 + t.Error("Expected empty step name when Skip is true") 155 + } 156 + if len(step.Commands()) != 0 { 157 + t.Errorf("Expected no commands when Skip is true, got %d commands", len(step.Commands())) 158 + } 159 + } 160 + 161 + func TestBuildCloneStep_DevMode(t *testing.T) { 162 + twf := tangled.Pipeline_Workflow{ 163 + Clone: &tangled.Pipeline_CloneOpts{ 164 + Depth: 1, 165 + Skip: false, 166 + }, 167 + } 168 + tr := tangled.Pipeline_TriggerMetadata{ 169 + Kind: string(workflow.TriggerKindPush), 170 + Push: &tangled.Pipeline_PushTriggerData{ 171 + NewSha: "abc123", 172 + }, 173 + Repo: &tangled.Pipeline_TriggerRepo{ 174 + Knot: "localhost:3000", 175 + Did: "did:plc:user123", 176 + Repo: "my-repo", 177 + }, 178 + } 179 + 180 + step := BuildCloneStep(twf, tr, true) 181 + 182 + // In dev mode, should use http:// and replace localhost with host.docker.internal 183 + allCmds := strings.Join(step.Commands(), " ") 184 + expectedURL := "http://host.docker.internal:3000/did:plc:user123/my-repo" 185 + if !strings.Contains(allCmds, expectedURL) { 186 + t.Errorf("Expected dev mode URL '%s' in commands", expectedURL) 187 + } 188 + } 189 + 190 + func TestBuildCloneStep_DepthAndSubmodules(t *testing.T) { 191 + twf := tangled.Pipeline_Workflow{ 192 + Clone: &tangled.Pipeline_CloneOpts{ 193 + Depth: 10, 194 + Submodules: true, 195 + Skip: false, 196 + }, 197 + } 198 + tr := tangled.Pipeline_TriggerMetadata{ 199 + Kind: string(workflow.TriggerKindPush), 200 + Push: &tangled.Pipeline_PushTriggerData{ 201 + NewSha: "abc123", 202 + }, 203 + Repo: &tangled.Pipeline_TriggerRepo{ 204 + Knot: "example.com", 205 + Did: "did:plc:user123", 206 + Repo: "my-repo", 207 + }, 208 + } 209 + 210 + step := BuildCloneStep(twf, tr, false) 211 + 212 + allCmds := strings.Join(step.Commands(), " ") 213 + if !strings.Contains(allCmds, "--depth=10") { 214 + t.Error("Commands should contain '--depth=10'") 215 + } 216 + 217 + if !strings.Contains(allCmds, "--recurse-submodules=yes") { 218 + t.Error("Commands should contain '--recurse-submodules=yes'") 219 + } 220 + } 221 + 222 + func TestBuildCloneStep_DefaultDepth(t *testing.T) { 223 + twf := tangled.Pipeline_Workflow{ 224 + Clone: &tangled.Pipeline_CloneOpts{ 225 + Depth: 0, // Default should be 1 226 + Skip: false, 227 + }, 228 + } 229 + tr := tangled.Pipeline_TriggerMetadata{ 230 + Kind: string(workflow.TriggerKindPush), 231 + Push: &tangled.Pipeline_PushTriggerData{ 232 + NewSha: "abc123", 233 + }, 234 + Repo: &tangled.Pipeline_TriggerRepo{ 235 + Knot: "example.com", 236 + Did: "did:plc:user123", 237 + Repo: "my-repo", 238 + }, 239 + } 240 + 241 + step := BuildCloneStep(twf, tr, false) 242 + 243 + allCmds := strings.Join(step.Commands(), " ") 244 + if !strings.Contains(allCmds, "--depth=1") { 245 + t.Error("Commands should default to '--depth=1'") 246 + } 247 + } 248 + 249 + func TestBuildCloneStep_NilPushData(t *testing.T) { 250 + twf := tangled.Pipeline_Workflow{ 251 + Clone: &tangled.Pipeline_CloneOpts{ 252 + Depth: 1, 253 + Skip: false, 254 + }, 255 + } 256 + tr := tangled.Pipeline_TriggerMetadata{ 257 + Kind: string(workflow.TriggerKindPush), 258 + Push: nil, // Nil push data should create error step 259 + Repo: &tangled.Pipeline_TriggerRepo{ 260 + Knot: "example.com", 261 + Did: "did:plc:user123", 262 + Repo: "my-repo", 263 + }, 264 + } 265 + 266 + step := BuildCloneStep(twf, tr, false) 267 + 268 + // Should return an error step 269 + if !strings.Contains(step.Name(), "error") { 270 + t.Error("Expected error in step name when push data is nil") 271 + } 272 + 273 + allCmds := strings.Join(step.Commands(), " ") 274 + if !strings.Contains(allCmds, "Failed to get clone info") { 275 + t.Error("Commands should contain error message") 276 + } 277 + if !strings.Contains(allCmds, "exit 1") { 278 + t.Error("Commands should exit with error") 279 + } 280 + } 281 + 282 + func TestBuildCloneStep_NilPRData(t *testing.T) { 283 + twf := tangled.Pipeline_Workflow{ 284 + Clone: &tangled.Pipeline_CloneOpts{ 285 + Depth: 1, 286 + Skip: false, 287 + }, 288 + } 289 + tr := tangled.Pipeline_TriggerMetadata{ 290 + Kind: string(workflow.TriggerKindPullRequest), 291 + PullRequest: nil, // Nil PR data should create error step 292 + Repo: &tangled.Pipeline_TriggerRepo{ 293 + Knot: "example.com", 294 + Did: "did:plc:user123", 295 + Repo: "my-repo", 296 + }, 297 + } 298 + 299 + step := BuildCloneStep(twf, tr, false) 300 + 301 + // Should return an error step 302 + if !strings.Contains(step.Name(), "error") { 303 + t.Error("Expected error in step name when pull request data is nil") 304 + } 305 + 306 + allCmds := strings.Join(step.Commands(), " ") 307 + if !strings.Contains(allCmds, "Failed to get clone info") { 308 + t.Error("Commands should contain error message") 309 + } 310 + } 311 + 312 + func TestBuildCloneStep_UnknownTriggerKind(t *testing.T) { 313 + twf := tangled.Pipeline_Workflow{ 314 + Clone: &tangled.Pipeline_CloneOpts{ 315 + Depth: 1, 316 + Skip: false, 317 + }, 318 + } 319 + tr := tangled.Pipeline_TriggerMetadata{ 320 + Kind: "unknown_trigger", 321 + Repo: &tangled.Pipeline_TriggerRepo{ 322 + Knot: "example.com", 323 + Did: "did:plc:user123", 324 + Repo: "my-repo", 325 + }, 326 + } 327 + 328 + step := BuildCloneStep(twf, tr, false) 329 + 330 + // Should return an error step 331 + if !strings.Contains(step.Name(), "error") { 332 + t.Error("Expected error in step name for unknown trigger kind") 333 + } 334 + 335 + allCmds := strings.Join(step.Commands(), " ") 336 + if !strings.Contains(allCmds, "unknown trigger kind") { 337 + t.Error("Commands should contain error message about unknown trigger kind") 338 + } 339 + } 340 + 341 + func TestBuildCloneStep_NilCloneOpts(t *testing.T) { 342 + twf := tangled.Pipeline_Workflow{ 343 + Clone: nil, // Nil clone options should use defaults 344 + } 345 + tr := tangled.Pipeline_TriggerMetadata{ 346 + Kind: string(workflow.TriggerKindPush), 347 + Push: &tangled.Pipeline_PushTriggerData{ 348 + NewSha: "abc123", 349 + }, 350 + Repo: &tangled.Pipeline_TriggerRepo{ 351 + Knot: "example.com", 352 + Did: "did:plc:user123", 353 + Repo: "my-repo", 354 + }, 355 + } 356 + 357 + step := BuildCloneStep(twf, tr, false) 358 + 359 + // Should still work with default options 360 + if step.Kind() != StepKindSystem { 361 + t.Errorf("Expected StepKindSystem, got %v", step.Kind()) 362 + } 363 + 364 + allCmds := strings.Join(step.Commands(), " ") 365 + if !strings.Contains(allCmds, "--depth=1") { 366 + t.Error("Commands should default to '--depth=1' when Clone is nil") 367 + } 368 + if !strings.Contains(allCmds, "git init") { 369 + t.Error("Commands should contain 'git init'") 370 + } 371 + }
+20 -12
spindle/models/logger.go
··· 12 12 type WorkflowLogger struct { 13 13 file *os.File 14 14 encoder *json.Encoder 15 + mask *SecretMask 15 16 } 16 17 17 - func NewWorkflowLogger(baseDir string, wid WorkflowId) (*WorkflowLogger, error) { 18 + func NewWorkflowLogger(baseDir string, wid WorkflowId, secretValues []string) (*WorkflowLogger, error) { 18 19 path := LogFilePath(baseDir, wid) 19 20 20 21 file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) ··· 25 26 return &WorkflowLogger{ 26 27 file: file, 27 28 encoder: json.NewEncoder(file), 29 + mask: NewSecretMask(secretValues), 28 30 }, nil 29 31 } 30 32 ··· 37 39 return l.file.Close() 38 40 } 39 41 40 - func (l *WorkflowLogger) DataWriter(stream string) io.Writer { 41 - // TODO: emit stream 42 + func (l *WorkflowLogger) DataWriter(idx int, stream string) io.Writer { 42 43 return &dataWriter{ 43 44 logger: l, 45 + idx: idx, 44 46 stream: stream, 45 47 } 46 48 } 47 49 48 - func (l *WorkflowLogger) ControlWriter(idx int, step Step) io.Writer { 50 + func (l *WorkflowLogger) ControlWriter(idx int, step Step, stepStatus StepStatus) io.Writer { 49 51 return &controlWriter{ 50 - logger: l, 51 - idx: idx, 52 - step: step, 52 + logger: l, 53 + idx: idx, 54 + step: step, 55 + stepStatus: stepStatus, 53 56 } 54 57 } 55 58 56 59 type dataWriter struct { 57 60 logger *WorkflowLogger 61 + idx int 58 62 stream string 59 63 } 60 64 61 65 func (w *dataWriter) Write(p []byte) (int, error) { 62 66 line := strings.TrimRight(string(p), "\r\n") 63 - entry := NewDataLogLine(line, w.stream) 67 + if w.logger.mask != nil { 68 + line = w.logger.mask.Mask(line) 69 + } 70 + entry := NewDataLogLine(w.idx, line, w.stream) 64 71 if err := w.logger.encoder.Encode(entry); err != nil { 65 72 return 0, err 66 73 } ··· 68 75 } 69 76 70 77 type controlWriter struct { 71 - logger *WorkflowLogger 72 - idx int 73 - step Step 78 + logger *WorkflowLogger 79 + idx int 80 + step Step 81 + stepStatus StepStatus 74 82 } 75 83 76 84 func (w *controlWriter) Write(_ []byte) (int, error) { 77 - entry := NewControlLogLine(w.idx, w.step) 85 + entry := NewControlLogLine(w.idx, w.step, w.stepStatus) 78 86 if err := w.logger.encoder.Encode(entry); err != nil { 79 87 return 0, err 80 88 }
+23 -8
spindle/models/models.go
··· 4 4 "fmt" 5 5 "regexp" 6 6 "slices" 7 + "time" 7 8 8 9 "tangled.org/core/api/tangled" 9 10 ··· 76 77 var ( 77 78 // step log data 78 79 LogKindData LogKind = "data" 79 - // indicates start/end of a step 80 + // indicates status of a step 80 81 LogKindControl LogKind = "control" 81 82 ) 82 83 84 + // step status indicator in control log lines 85 + type StepStatus string 86 + 87 + var ( 88 + StepStatusStart StepStatus = "start" 89 + StepStatusEnd StepStatus = "end" 90 + ) 91 + 83 92 type LogLine struct { 84 - Kind LogKind `json:"kind"` 85 - Content string `json:"content"` 93 + Kind LogKind `json:"kind"` 94 + Content string `json:"content"` 95 + Time time.Time `json:"time"` 96 + StepId int `json:"step_id"` 86 97 87 98 // fields if kind is "data" 88 99 Stream string `json:"stream,omitempty"` 89 100 90 101 // fields if kind is "control" 91 - StepId int `json:"step_id,omitempty"` 92 - StepKind StepKind `json:"step_kind,omitempty"` 93 - StepCommand string `json:"step_command,omitempty"` 102 + StepStatus StepStatus `json:"step_status,omitempty"` 103 + StepKind StepKind `json:"step_kind,omitempty"` 104 + StepCommand string `json:"step_command,omitempty"` 94 105 } 95 106 96 - func NewDataLogLine(content, stream string) LogLine { 107 + func NewDataLogLine(idx int, content, stream string) LogLine { 97 108 return LogLine{ 98 109 Kind: LogKindData, 110 + Time: time.Now(), 99 111 Content: content, 112 + StepId: idx, 100 113 Stream: stream, 101 114 } 102 115 } 103 116 104 - func NewControlLogLine(idx int, step Step) LogLine { 117 + func NewControlLogLine(idx int, step Step, status StepStatus) LogLine { 105 118 return LogLine{ 106 119 Kind: LogKindControl, 120 + Time: time.Now(), 107 121 Content: step.Name(), 108 122 StepId: idx, 123 + StepStatus: status, 109 124 StepKind: step.Kind(), 110 125 StepCommand: step.Command(), 111 126 }
+4 -3
spindle/models/pipeline.go
··· 22 22 ) 23 23 24 24 type Workflow struct { 25 - Steps []Step 26 - Name string 27 - Data any 25 + Steps []Step 26 + Name string 27 + Data any 28 + Environment map[string]string 28 29 }
+77
spindle/models/pipeline_env.go
··· 1 + package models 2 + 3 + import ( 4 + "strings" 5 + 6 + "github.com/go-git/go-git/v5/plumbing" 7 + "tangled.org/core/api/tangled" 8 + "tangled.org/core/workflow" 9 + ) 10 + 11 + // PipelineEnvVars extracts environment variables from pipeline trigger metadata. 12 + // These are framework-provided variables that are injected into workflow steps. 13 + func PipelineEnvVars(tr *tangled.Pipeline_TriggerMetadata, pipelineId PipelineId, devMode bool) map[string]string { 14 + if tr == nil { 15 + return nil 16 + } 17 + 18 + env := make(map[string]string) 19 + 20 + // Standard CI environment variable 21 + env["CI"] = "true" 22 + 23 + env["TANGLED_PIPELINE_ID"] = pipelineId.Rkey 24 + 25 + // Repo info 26 + if tr.Repo != nil { 27 + env["TANGLED_REPO_KNOT"] = tr.Repo.Knot 28 + env["TANGLED_REPO_DID"] = tr.Repo.Did 29 + env["TANGLED_REPO_NAME"] = tr.Repo.Repo 30 + env["TANGLED_REPO_DEFAULT_BRANCH"] = tr.Repo.DefaultBranch 31 + env["TANGLED_REPO_URL"] = BuildRepoURL(tr.Repo, devMode) 32 + } 33 + 34 + switch workflow.TriggerKind(tr.Kind) { 35 + case workflow.TriggerKindPush: 36 + if tr.Push != nil { 37 + refName := plumbing.ReferenceName(tr.Push.Ref) 38 + refType := "branch" 39 + if refName.IsTag() { 40 + refType = "tag" 41 + } 42 + 43 + env["TANGLED_REF"] = tr.Push.Ref 44 + env["TANGLED_REF_NAME"] = refName.Short() 45 + env["TANGLED_REF_TYPE"] = refType 46 + env["TANGLED_SHA"] = tr.Push.NewSha 47 + env["TANGLED_COMMIT_SHA"] = tr.Push.NewSha 48 + } 49 + 50 + case workflow.TriggerKindPullRequest: 51 + if tr.PullRequest != nil { 52 + // For PRs, the "ref" is the source branch 53 + env["TANGLED_REF"] = "refs/heads/" + tr.PullRequest.SourceBranch 54 + env["TANGLED_REF_NAME"] = tr.PullRequest.SourceBranch 55 + env["TANGLED_REF_TYPE"] = "branch" 56 + env["TANGLED_SHA"] = tr.PullRequest.SourceSha 57 + env["TANGLED_COMMIT_SHA"] = tr.PullRequest.SourceSha 58 + 59 + // PR-specific variables 60 + env["TANGLED_PR_SOURCE_BRANCH"] = tr.PullRequest.SourceBranch 61 + env["TANGLED_PR_TARGET_BRANCH"] = tr.PullRequest.TargetBranch 62 + env["TANGLED_PR_SOURCE_SHA"] = tr.PullRequest.SourceSha 63 + env["TANGLED_PR_ACTION"] = tr.PullRequest.Action 64 + } 65 + 66 + case workflow.TriggerKindManual: 67 + // Manual triggers may not have ref/sha info 68 + // Include any manual inputs if present 69 + if tr.Manual != nil { 70 + for _, pair := range tr.Manual.Inputs { 71 + env["TANGLED_INPUT_"+strings.ToUpper(pair.Key)] = pair.Value 72 + } 73 + } 74 + } 75 + 76 + return env 77 + }
+260
spindle/models/pipeline_env_test.go
··· 1 + package models 2 + 3 + import ( 4 + "testing" 5 + 6 + "tangled.org/core/api/tangled" 7 + "tangled.org/core/workflow" 8 + ) 9 + 10 + func TestPipelineEnvVars_PushBranch(t *testing.T) { 11 + tr := &tangled.Pipeline_TriggerMetadata{ 12 + Kind: string(workflow.TriggerKindPush), 13 + Push: &tangled.Pipeline_PushTriggerData{ 14 + NewSha: "abc123def456", 15 + OldSha: "000000000000", 16 + Ref: "refs/heads/main", 17 + }, 18 + Repo: &tangled.Pipeline_TriggerRepo{ 19 + Knot: "example.com", 20 + Did: "did:plc:user123", 21 + Repo: "my-repo", 22 + DefaultBranch: "main", 23 + }, 24 + } 25 + id := PipelineId{ 26 + Knot: "example.com", 27 + Rkey: "123123", 28 + } 29 + env := PipelineEnvVars(tr, id, false) 30 + 31 + // Check standard CI variable 32 + if env["CI"] != "true" { 33 + t.Errorf("Expected CI='true', got '%s'", env["CI"]) 34 + } 35 + 36 + // Check ref variables 37 + if env["TANGLED_REF"] != "refs/heads/main" { 38 + t.Errorf("Expected TANGLED_REF='refs/heads/main', got '%s'", env["TANGLED_REF"]) 39 + } 40 + if env["TANGLED_REF_NAME"] != "main" { 41 + t.Errorf("Expected TANGLED_REF_NAME='main', got '%s'", env["TANGLED_REF_NAME"]) 42 + } 43 + if env["TANGLED_REF_TYPE"] != "branch" { 44 + t.Errorf("Expected TANGLED_REF_TYPE='branch', got '%s'", env["TANGLED_REF_TYPE"]) 45 + } 46 + 47 + // Check SHA variables 48 + if env["TANGLED_SHA"] != "abc123def456" { 49 + t.Errorf("Expected TANGLED_SHA='abc123def456', got '%s'", env["TANGLED_SHA"]) 50 + } 51 + if env["TANGLED_COMMIT_SHA"] != "abc123def456" { 52 + t.Errorf("Expected TANGLED_COMMIT_SHA='abc123def456', got '%s'", env["TANGLED_COMMIT_SHA"]) 53 + } 54 + 55 + // Check repo variables 56 + if env["TANGLED_REPO_KNOT"] != "example.com" { 57 + t.Errorf("Expected TANGLED_REPO_KNOT='example.com', got '%s'", env["TANGLED_REPO_KNOT"]) 58 + } 59 + if env["TANGLED_REPO_DID"] != "did:plc:user123" { 60 + t.Errorf("Expected TANGLED_REPO_DID='did:plc:user123', got '%s'", env["TANGLED_REPO_DID"]) 61 + } 62 + if env["TANGLED_REPO_NAME"] != "my-repo" { 63 + t.Errorf("Expected TANGLED_REPO_NAME='my-repo', got '%s'", env["TANGLED_REPO_NAME"]) 64 + } 65 + if env["TANGLED_REPO_DEFAULT_BRANCH"] != "main" { 66 + t.Errorf("Expected TANGLED_REPO_DEFAULT_BRANCH='main', got '%s'", env["TANGLED_REPO_DEFAULT_BRANCH"]) 67 + } 68 + if env["TANGLED_REPO_URL"] != "https://example.com/did:plc:user123/my-repo" { 69 + t.Errorf("Expected TANGLED_REPO_URL='https://example.com/did:plc:user123/my-repo', got '%s'", env["TANGLED_REPO_URL"]) 70 + } 71 + } 72 + 73 + func TestPipelineEnvVars_PushTag(t *testing.T) { 74 + tr := &tangled.Pipeline_TriggerMetadata{ 75 + Kind: string(workflow.TriggerKindPush), 76 + Push: &tangled.Pipeline_PushTriggerData{ 77 + NewSha: "abc123def456", 78 + OldSha: "000000000000", 79 + Ref: "refs/tags/v1.2.3", 80 + }, 81 + Repo: &tangled.Pipeline_TriggerRepo{ 82 + Knot: "example.com", 83 + Did: "did:plc:user123", 84 + Repo: "my-repo", 85 + }, 86 + } 87 + id := PipelineId{ 88 + Knot: "example.com", 89 + Rkey: "123123", 90 + } 91 + env := PipelineEnvVars(tr, id, false) 92 + 93 + if env["TANGLED_REF"] != "refs/tags/v1.2.3" { 94 + t.Errorf("Expected TANGLED_REF='refs/tags/v1.2.3', got '%s'", env["TANGLED_REF"]) 95 + } 96 + if env["TANGLED_REF_NAME"] != "v1.2.3" { 97 + t.Errorf("Expected TANGLED_REF_NAME='v1.2.3', got '%s'", env["TANGLED_REF_NAME"]) 98 + } 99 + if env["TANGLED_REF_TYPE"] != "tag" { 100 + t.Errorf("Expected TANGLED_REF_TYPE='tag', got '%s'", env["TANGLED_REF_TYPE"]) 101 + } 102 + } 103 + 104 + func TestPipelineEnvVars_PullRequest(t *testing.T) { 105 + tr := &tangled.Pipeline_TriggerMetadata{ 106 + Kind: string(workflow.TriggerKindPullRequest), 107 + PullRequest: &tangled.Pipeline_PullRequestTriggerData{ 108 + SourceBranch: "feature-branch", 109 + TargetBranch: "main", 110 + SourceSha: "pr-sha-789", 111 + Action: "opened", 112 + }, 113 + Repo: &tangled.Pipeline_TriggerRepo{ 114 + Knot: "example.com", 115 + Did: "did:plc:user123", 116 + Repo: "my-repo", 117 + }, 118 + } 119 + id := PipelineId{ 120 + Knot: "example.com", 121 + Rkey: "123123", 122 + } 123 + env := PipelineEnvVars(tr, id, false) 124 + 125 + // Check ref variables for PR 126 + if env["TANGLED_REF"] != "refs/heads/feature-branch" { 127 + t.Errorf("Expected TANGLED_REF='refs/heads/feature-branch', got '%s'", env["TANGLED_REF"]) 128 + } 129 + if env["TANGLED_REF_NAME"] != "feature-branch" { 130 + t.Errorf("Expected TANGLED_REF_NAME='feature-branch', got '%s'", env["TANGLED_REF_NAME"]) 131 + } 132 + if env["TANGLED_REF_TYPE"] != "branch" { 133 + t.Errorf("Expected TANGLED_REF_TYPE='branch', got '%s'", env["TANGLED_REF_TYPE"]) 134 + } 135 + 136 + // Check SHA variables 137 + if env["TANGLED_SHA"] != "pr-sha-789" { 138 + t.Errorf("Expected TANGLED_SHA='pr-sha-789', got '%s'", env["TANGLED_SHA"]) 139 + } 140 + if env["TANGLED_COMMIT_SHA"] != "pr-sha-789" { 141 + t.Errorf("Expected TANGLED_COMMIT_SHA='pr-sha-789', got '%s'", env["TANGLED_COMMIT_SHA"]) 142 + } 143 + 144 + // Check PR-specific variables 145 + if env["TANGLED_PR_SOURCE_BRANCH"] != "feature-branch" { 146 + t.Errorf("Expected TANGLED_PR_SOURCE_BRANCH='feature-branch', got '%s'", env["TANGLED_PR_SOURCE_BRANCH"]) 147 + } 148 + if env["TANGLED_PR_TARGET_BRANCH"] != "main" { 149 + t.Errorf("Expected TANGLED_PR_TARGET_BRANCH='main', got '%s'", env["TANGLED_PR_TARGET_BRANCH"]) 150 + } 151 + if env["TANGLED_PR_SOURCE_SHA"] != "pr-sha-789" { 152 + t.Errorf("Expected TANGLED_PR_SOURCE_SHA='pr-sha-789', got '%s'", env["TANGLED_PR_SOURCE_SHA"]) 153 + } 154 + if env["TANGLED_PR_ACTION"] != "opened" { 155 + t.Errorf("Expected TANGLED_PR_ACTION='opened', got '%s'", env["TANGLED_PR_ACTION"]) 156 + } 157 + } 158 + 159 + func TestPipelineEnvVars_ManualWithInputs(t *testing.T) { 160 + tr := &tangled.Pipeline_TriggerMetadata{ 161 + Kind: string(workflow.TriggerKindManual), 162 + Manual: &tangled.Pipeline_ManualTriggerData{ 163 + Inputs: []*tangled.Pipeline_Pair{ 164 + {Key: "version", Value: "1.0.0"}, 165 + {Key: "environment", Value: "production"}, 166 + }, 167 + }, 168 + Repo: &tangled.Pipeline_TriggerRepo{ 169 + Knot: "example.com", 170 + Did: "did:plc:user123", 171 + Repo: "my-repo", 172 + }, 173 + } 174 + id := PipelineId{ 175 + Knot: "example.com", 176 + Rkey: "123123", 177 + } 178 + env := PipelineEnvVars(tr, id, false) 179 + 180 + // Check manual input variables 181 + if env["TANGLED_INPUT_VERSION"] != "1.0.0" { 182 + t.Errorf("Expected TANGLED_INPUT_VERSION='1.0.0', got '%s'", env["TANGLED_INPUT_VERSION"]) 183 + } 184 + if env["TANGLED_INPUT_ENVIRONMENT"] != "production" { 185 + t.Errorf("Expected TANGLED_INPUT_ENVIRONMENT='production', got '%s'", env["TANGLED_INPUT_ENVIRONMENT"]) 186 + } 187 + 188 + // Manual triggers shouldn't have ref/sha variables 189 + if _, ok := env["TANGLED_REF"]; ok { 190 + t.Error("Manual trigger should not have TANGLED_REF") 191 + } 192 + if _, ok := env["TANGLED_SHA"]; ok { 193 + t.Error("Manual trigger should not have TANGLED_SHA") 194 + } 195 + } 196 + 197 + func TestPipelineEnvVars_DevMode(t *testing.T) { 198 + tr := &tangled.Pipeline_TriggerMetadata{ 199 + Kind: string(workflow.TriggerKindPush), 200 + Push: &tangled.Pipeline_PushTriggerData{ 201 + NewSha: "abc123", 202 + Ref: "refs/heads/main", 203 + }, 204 + Repo: &tangled.Pipeline_TriggerRepo{ 205 + Knot: "localhost:3000", 206 + Did: "did:plc:user123", 207 + Repo: "my-repo", 208 + }, 209 + } 210 + id := PipelineId{ 211 + Knot: "example.com", 212 + Rkey: "123123", 213 + } 214 + env := PipelineEnvVars(tr, id, true) 215 + 216 + // Dev mode should use http:// and replace localhost with host.docker.internal 217 + expectedURL := "http://host.docker.internal:3000/did:plc:user123/my-repo" 218 + if env["TANGLED_REPO_URL"] != expectedURL { 219 + t.Errorf("Expected TANGLED_REPO_URL='%s', got '%s'", expectedURL, env["TANGLED_REPO_URL"]) 220 + } 221 + } 222 + 223 + func TestPipelineEnvVars_NilTrigger(t *testing.T) { 224 + id := PipelineId{ 225 + Knot: "example.com", 226 + Rkey: "123123", 227 + } 228 + env := PipelineEnvVars(nil, id, false) 229 + 230 + if env != nil { 231 + t.Error("Expected nil env for nil trigger") 232 + } 233 + } 234 + 235 + func TestPipelineEnvVars_NilPushData(t *testing.T) { 236 + tr := &tangled.Pipeline_TriggerMetadata{ 237 + Kind: string(workflow.TriggerKindPush), 238 + Push: nil, 239 + Repo: &tangled.Pipeline_TriggerRepo{ 240 + Knot: "example.com", 241 + Did: "did:plc:user123", 242 + Repo: "my-repo", 243 + }, 244 + } 245 + id := PipelineId{ 246 + Knot: "example.com", 247 + Rkey: "123123", 248 + } 249 + env := PipelineEnvVars(tr, id, false) 250 + 251 + // Should still have repo variables 252 + if env["TANGLED_REPO_KNOT"] != "example.com" { 253 + t.Errorf("Expected TANGLED_REPO_KNOT='example.com', got '%s'", env["TANGLED_REPO_KNOT"]) 254 + } 255 + 256 + // Should not have ref/sha variables 257 + if _, ok := env["TANGLED_REF"]; ok { 258 + t.Error("Should not have TANGLED_REF when push data is nil") 259 + } 260 + }
+51
spindle/models/secret_mask.go
··· 1 + package models 2 + 3 + import ( 4 + "encoding/base64" 5 + "strings" 6 + ) 7 + 8 + // SecretMask replaces secret values in strings with "***". 9 + type SecretMask struct { 10 + replacer *strings.Replacer 11 + } 12 + 13 + // NewSecretMask creates a mask for the given secret values. 14 + // Also registers base64-encoded variants of each secret. 15 + func NewSecretMask(values []string) *SecretMask { 16 + var pairs []string 17 + 18 + for _, value := range values { 19 + if value == "" { 20 + continue 21 + } 22 + 23 + pairs = append(pairs, value, "***") 24 + 25 + b64 := base64.StdEncoding.EncodeToString([]byte(value)) 26 + if b64 != value { 27 + pairs = append(pairs, b64, "***") 28 + } 29 + 30 + b64NoPad := strings.TrimRight(b64, "=") 31 + if b64NoPad != b64 && b64NoPad != value { 32 + pairs = append(pairs, b64NoPad, "***") 33 + } 34 + } 35 + 36 + if len(pairs) == 0 { 37 + return nil 38 + } 39 + 40 + return &SecretMask{ 41 + replacer: strings.NewReplacer(pairs...), 42 + } 43 + } 44 + 45 + // Mask replaces all registered secret values with "***". 46 + func (m *SecretMask) Mask(input string) string { 47 + if m == nil || m.replacer == nil { 48 + return input 49 + } 50 + return m.replacer.Replace(input) 51 + }
+135
spindle/models/secret_mask_test.go
··· 1 + package models 2 + 3 + import ( 4 + "encoding/base64" 5 + "testing" 6 + ) 7 + 8 + func TestSecretMask_BasicMasking(t *testing.T) { 9 + mask := NewSecretMask([]string{"mysecret123"}) 10 + 11 + input := "The password is mysecret123 in this log" 12 + expected := "The password is *** in this log" 13 + 14 + result := mask.Mask(input) 15 + if result != expected { 16 + t.Errorf("expected %q, got %q", expected, result) 17 + } 18 + } 19 + 20 + func TestSecretMask_Base64Encoded(t *testing.T) { 21 + secret := "mysecret123" 22 + mask := NewSecretMask([]string{secret}) 23 + 24 + b64 := base64.StdEncoding.EncodeToString([]byte(secret)) 25 + input := "Encoded: " + b64 26 + expected := "Encoded: ***" 27 + 28 + result := mask.Mask(input) 29 + if result != expected { 30 + t.Errorf("expected %q, got %q", expected, result) 31 + } 32 + } 33 + 34 + func TestSecretMask_Base64NoPadding(t *testing.T) { 35 + // "test" encodes to "dGVzdA==" with padding 36 + secret := "test" 37 + mask := NewSecretMask([]string{secret}) 38 + 39 + b64NoPad := "dGVzdA" // base64 without padding 40 + input := "Token: " + b64NoPad 41 + expected := "Token: ***" 42 + 43 + result := mask.Mask(input) 44 + if result != expected { 45 + t.Errorf("expected %q, got %q", expected, result) 46 + } 47 + } 48 + 49 + func TestSecretMask_MultipleSecrets(t *testing.T) { 50 + mask := NewSecretMask([]string{"password1", "apikey123"}) 51 + 52 + input := "Using password1 and apikey123 for auth" 53 + expected := "Using *** and *** for auth" 54 + 55 + result := mask.Mask(input) 56 + if result != expected { 57 + t.Errorf("expected %q, got %q", expected, result) 58 + } 59 + } 60 + 61 + func TestSecretMask_MultipleOccurrences(t *testing.T) { 62 + mask := NewSecretMask([]string{"secret"}) 63 + 64 + input := "secret appears twice: secret" 65 + expected := "*** appears twice: ***" 66 + 67 + result := mask.Mask(input) 68 + if result != expected { 69 + t.Errorf("expected %q, got %q", expected, result) 70 + } 71 + } 72 + 73 + func TestSecretMask_ShortValues(t *testing.T) { 74 + mask := NewSecretMask([]string{"abc", "xy", ""}) 75 + 76 + if mask == nil { 77 + t.Fatal("expected non-nil mask") 78 + } 79 + 80 + input := "abc xy test" 81 + expected := "*** *** test" 82 + result := mask.Mask(input) 83 + if result != expected { 84 + t.Errorf("expected %q, got %q", expected, result) 85 + } 86 + } 87 + 88 + func TestSecretMask_NilMask(t *testing.T) { 89 + var mask *SecretMask 90 + 91 + input := "some input text" 92 + result := mask.Mask(input) 93 + if result != input { 94 + t.Errorf("expected %q, got %q", input, result) 95 + } 96 + } 97 + 98 + func TestSecretMask_EmptyInput(t *testing.T) { 99 + mask := NewSecretMask([]string{"secret"}) 100 + 101 + result := mask.Mask("") 102 + if result != "" { 103 + t.Errorf("expected empty string, got %q", result) 104 + } 105 + } 106 + 107 + func TestSecretMask_NoMatch(t *testing.T) { 108 + mask := NewSecretMask([]string{"secretvalue"}) 109 + 110 + input := "nothing to mask here" 111 + result := mask.Mask(input) 112 + if result != input { 113 + t.Errorf("expected %q, got %q", input, result) 114 + } 115 + } 116 + 117 + func TestSecretMask_EmptySecretsList(t *testing.T) { 118 + mask := NewSecretMask([]string{}) 119 + 120 + if mask != nil { 121 + t.Error("expected nil mask for empty secrets list") 122 + } 123 + } 124 + 125 + func TestSecretMask_EmptySecretsFiltered(t *testing.T) { 126 + mask := NewSecretMask([]string{"ab", "validpassword", "", "xyz"}) 127 + 128 + input := "Using validpassword here" 129 + expected := "Using *** here" 130 + 131 + result := mask.Mask(input) 132 + if result != expected { 133 + t.Errorf("expected %q, got %q", expected, result) 134 + } 135 + }
+1 -1
spindle/motd
··· 20 20 ** 21 21 ******** 22 22 23 - This is a spindle server. More info at https://tangled.sh/@tangled.sh/core/tree/master/docs/spindle 23 + This is a spindle server. More info at https://docs.tangled.org/spindles.html#spindles 24 24 25 25 Most API routes are under /xrpc/
+15 -7
spindle/secrets/openbao.go
··· 13 13 ) 14 14 15 15 type OpenBaoManager struct { 16 - client *vault.Client 17 - mountPath string 18 - logger *slog.Logger 16 + client *vault.Client 17 + mountPath string 18 + logger *slog.Logger 19 + connectionTimeout time.Duration 19 20 } 20 21 21 22 type OpenBaoManagerOpt func(*OpenBaoManager) ··· 26 27 } 27 28 } 28 29 30 + func WithConnectionTimeout(timeout time.Duration) OpenBaoManagerOpt { 31 + return func(v *OpenBaoManager) { 32 + v.connectionTimeout = timeout 33 + } 34 + } 35 + 29 36 // NewOpenBaoManager creates a new OpenBao manager that connects to a Bao Proxy 30 37 // The proxyAddress should point to the local Bao Proxy (e.g., "http://127.0.0.1:8200") 31 38 // The proxy handles all authentication automatically via Auto-Auth ··· 43 50 } 44 51 45 52 manager := &OpenBaoManager{ 46 - client: client, 47 - mountPath: "spindle", // default KV v2 mount path 48 - logger: logger, 53 + client: client, 54 + mountPath: "spindle", // default KV v2 mount path 55 + logger: logger, 56 + connectionTimeout: 10 * time.Second, // default connection timeout 49 57 } 50 58 51 59 for _, opt := range opts { ··· 62 70 63 71 // testConnection verifies that we can connect to the proxy 64 72 func (v *OpenBaoManager) testConnection() error { 65 - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 73 + ctx, cancel := context.WithTimeout(context.Background(), v.connectionTimeout) 66 74 defer cancel() 67 75 68 76 // try token self-lookup as a quick way to verify proxy works
+5 -2
spindle/secrets/openbao_test.go
··· 152 152 for _, tt := range tests { 153 153 t.Run(tt.name, func(t *testing.T) { 154 154 logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) 155 - manager, err := NewOpenBaoManager(tt.proxyAddr, logger, tt.opts...) 155 + // Use shorter timeout for tests to avoid long waits 156 + opts := append(tt.opts, WithConnectionTimeout(1*time.Second)) 157 + manager, err := NewOpenBaoManager(tt.proxyAddr, logger, opts...) 156 158 157 159 if tt.expectError { 158 160 assert.Error(t, err) ··· 596 598 597 599 // All these will fail because no real proxy is running 598 600 // but we can test that the configuration is properly accepted 599 - manager, err := NewOpenBaoManager(tt.proxyAddr, logger) 601 + // Use shorter timeout for tests to avoid long waits 602 + manager, err := NewOpenBaoManager(tt.proxyAddr, logger, WithConnectionTimeout(1*time.Second)) 600 603 assert.Error(t, err) // Expected because no real proxy 601 604 assert.Nil(t, manager) 602 605 assert.Contains(t, err.Error(), "failed to connect to bao proxy")
+128 -54
spindle/server.go
··· 6 6 "encoding/json" 7 7 "fmt" 8 8 "log/slog" 9 + "maps" 9 10 "net/http" 11 + "sync" 10 12 11 13 "github.com/go-chi/chi/v5" 12 14 "tangled.org/core/api/tangled" ··· 29 31 ) 30 32 31 33 //go:embed motd 32 - var motd []byte 34 + var defaultMotd []byte 33 35 34 36 const ( 35 37 rbacDomain = "thisserver" 36 38 ) 37 39 38 40 type Spindle struct { 39 - jc *jetstream.JetstreamClient 40 - db *db.DB 41 - e *rbac.Enforcer 42 - l *slog.Logger 43 - n *notifier.Notifier 44 - engs map[string]models.Engine 45 - jq *queue.Queue 46 - cfg *config.Config 47 - ks *eventconsumer.Consumer 48 - res *idresolver.Resolver 49 - vault secrets.Manager 41 + jc *jetstream.JetstreamClient 42 + db *db.DB 43 + e *rbac.Enforcer 44 + l *slog.Logger 45 + n *notifier.Notifier 46 + engs map[string]models.Engine 47 + jq *queue.Queue 48 + cfg *config.Config 49 + ks *eventconsumer.Consumer 50 + res *idresolver.Resolver 51 + vault secrets.Manager 52 + motd []byte 53 + motdMu sync.RWMutex 50 54 } 51 55 52 - func Run(ctx context.Context) error { 56 + // New creates a new Spindle server with the provided configuration and engines. 57 + func New(ctx context.Context, cfg *config.Config, engines map[string]models.Engine) (*Spindle, error) { 53 58 logger := log.FromContext(ctx) 54 59 55 - cfg, err := config.Load(ctx) 56 - if err != nil { 57 - return fmt.Errorf("failed to load config: %w", err) 58 - } 59 - 60 60 d, err := db.Make(cfg.Server.DBPath) 61 61 if err != nil { 62 - return fmt.Errorf("failed to setup db: %w", err) 62 + return nil, fmt.Errorf("failed to setup db: %w", err) 63 63 } 64 64 65 65 e, err := rbac.NewEnforcer(cfg.Server.DBPath) 66 66 if err != nil { 67 - return fmt.Errorf("failed to setup rbac enforcer: %w", err) 67 + return nil, fmt.Errorf("failed to setup rbac enforcer: %w", err) 68 68 } 69 69 e.E.EnableAutoSave(true) 70 70 ··· 74 74 switch cfg.Server.Secrets.Provider { 75 75 case "openbao": 76 76 if cfg.Server.Secrets.OpenBao.ProxyAddr == "" { 77 - return fmt.Errorf("openbao proxy address is required when using openbao secrets provider") 77 + return nil, fmt.Errorf("openbao proxy address is required when using openbao secrets provider") 78 78 } 79 79 vault, err = secrets.NewOpenBaoManager( 80 80 cfg.Server.Secrets.OpenBao.ProxyAddr, ··· 82 82 secrets.WithMountPath(cfg.Server.Secrets.OpenBao.Mount), 83 83 ) 84 84 if err != nil { 85 - return fmt.Errorf("failed to setup openbao secrets provider: %w", err) 85 + return nil, fmt.Errorf("failed to setup openbao secrets provider: %w", err) 86 86 } 87 87 logger.Info("using openbao secrets provider", "proxy_address", cfg.Server.Secrets.OpenBao.ProxyAddr, "mount", cfg.Server.Secrets.OpenBao.Mount) 88 88 case "sqlite", "": 89 89 vault, err = secrets.NewSQLiteManager(cfg.Server.DBPath, secrets.WithTableName("secrets")) 90 90 if err != nil { 91 - return fmt.Errorf("failed to setup sqlite secrets provider: %w", err) 91 + return nil, fmt.Errorf("failed to setup sqlite secrets provider: %w", err) 92 92 } 93 93 logger.Info("using sqlite secrets provider", "path", cfg.Server.DBPath) 94 94 default: 95 - return fmt.Errorf("unknown secrets provider: %s", cfg.Server.Secrets.Provider) 96 - } 97 - 98 - nixeryEng, err := nixery.New(ctx, cfg) 99 - if err != nil { 100 - return err 95 + return nil, fmt.Errorf("unknown secrets provider: %s", cfg.Server.Secrets.Provider) 101 96 } 102 97 103 98 jq := queue.NewQueue(cfg.Server.QueueSize, cfg.Server.MaxJobCount) ··· 110 105 } 111 106 jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, log.SubLogger(logger, "jetstream"), d, true, true) 112 107 if err != nil { 113 - return fmt.Errorf("failed to setup jetstream client: %w", err) 108 + return nil, fmt.Errorf("failed to setup jetstream client: %w", err) 114 109 } 115 110 jc.AddDid(cfg.Server.Owner) 116 111 117 112 // Check if the spindle knows about any Dids; 118 113 dids, err := d.GetAllDids() 119 114 if err != nil { 120 - return fmt.Errorf("failed to get all dids: %w", err) 115 + return nil, fmt.Errorf("failed to get all dids: %w", err) 121 116 } 122 117 for _, d := range dids { 123 118 jc.AddDid(d) 124 119 } 125 120 126 - resolver := idresolver.DefaultResolver() 121 + resolver := idresolver.DefaultResolver(cfg.Server.PlcUrl) 127 122 128 - spindle := Spindle{ 123 + spindle := &Spindle{ 129 124 jc: jc, 130 125 e: e, 131 126 db: d, 132 127 l: logger, 133 128 n: &n, 134 - engs: map[string]models.Engine{"nixery": nixeryEng}, 129 + engs: engines, 135 130 jq: jq, 136 131 cfg: cfg, 137 132 res: resolver, 138 133 vault: vault, 134 + motd: defaultMotd, 139 135 } 140 136 141 137 err = e.AddSpindle(rbacDomain) 142 138 if err != nil { 143 - return fmt.Errorf("failed to set rbac domain: %w", err) 139 + return nil, fmt.Errorf("failed to set rbac domain: %w", err) 144 140 } 145 141 err = spindle.configureOwner() 146 142 if err != nil { 147 - return err 143 + return nil, err 148 144 } 149 145 logger.Info("owner set", "did", cfg.Server.Owner) 150 146 151 - // starts a job queue runner in the background 152 - jq.Start() 153 - defer jq.Stop() 154 - 155 - // Stop vault token renewal if it implements Stopper 156 - if stopper, ok := vault.(secrets.Stopper); ok { 157 - defer stopper.Stop() 158 - } 159 - 160 147 cursorStore, err := cursor.NewSQLiteStore(cfg.Server.DBPath) 161 148 if err != nil { 162 - return fmt.Errorf("failed to setup sqlite3 cursor store: %w", err) 149 + return nil, fmt.Errorf("failed to setup sqlite3 cursor store: %w", err) 163 150 } 164 151 165 152 err = jc.StartJetstream(ctx, spindle.ingest()) 166 153 if err != nil { 167 - return fmt.Errorf("failed to start jetstream consumer: %w", err) 154 + return nil, fmt.Errorf("failed to start jetstream consumer: %w", err) 168 155 } 169 156 170 157 // for each incoming sh.tangled.pipeline, we execute ··· 177 164 ccfg.CursorStore = cursorStore 178 165 knownKnots, err := d.Knots() 179 166 if err != nil { 180 - return err 167 + return nil, err 181 168 } 182 169 for _, knot := range knownKnots { 183 170 logger.Info("adding source start", "knot", knot) ··· 185 172 } 186 173 spindle.ks = eventconsumer.NewConsumer(*ccfg) 187 174 175 + return spindle, nil 176 + } 177 + 178 + // DB returns the database instance. 179 + func (s *Spindle) DB() *db.DB { 180 + return s.db 181 + } 182 + 183 + // Queue returns the job queue instance. 184 + func (s *Spindle) Queue() *queue.Queue { 185 + return s.jq 186 + } 187 + 188 + // Engines returns the map of available engines. 189 + func (s *Spindle) Engines() map[string]models.Engine { 190 + return s.engs 191 + } 192 + 193 + // Vault returns the secrets manager instance. 194 + func (s *Spindle) Vault() secrets.Manager { 195 + return s.vault 196 + } 197 + 198 + // Notifier returns the notifier instance. 199 + func (s *Spindle) Notifier() *notifier.Notifier { 200 + return s.n 201 + } 202 + 203 + // Enforcer returns the RBAC enforcer instance. 204 + func (s *Spindle) Enforcer() *rbac.Enforcer { 205 + return s.e 206 + } 207 + 208 + // SetMotdContent sets custom MOTD content, replacing the embedded default. 209 + func (s *Spindle) SetMotdContent(content []byte) { 210 + s.motdMu.Lock() 211 + defer s.motdMu.Unlock() 212 + s.motd = content 213 + } 214 + 215 + // GetMotdContent returns the current MOTD content. 216 + func (s *Spindle) GetMotdContent() []byte { 217 + s.motdMu.RLock() 218 + defer s.motdMu.RUnlock() 219 + return s.motd 220 + } 221 + 222 + // Start starts the Spindle server (blocking). 223 + func (s *Spindle) Start(ctx context.Context) error { 224 + // starts a job queue runner in the background 225 + s.jq.Start() 226 + defer s.jq.Stop() 227 + 228 + // Stop vault token renewal if it implements Stopper 229 + if stopper, ok := s.vault.(secrets.Stopper); ok { 230 + defer stopper.Stop() 231 + } 232 + 188 233 go func() { 189 - logger.Info("starting knot event consumer") 190 - spindle.ks.Start(ctx) 234 + s.l.Info("starting knot event consumer") 235 + s.ks.Start(ctx) 191 236 }() 192 237 193 - logger.Info("starting spindle server", "address", cfg.Server.ListenAddr) 194 - logger.Error("server error", "error", http.ListenAndServe(cfg.Server.ListenAddr, spindle.Router())) 238 + s.l.Info("starting spindle server", "address", s.cfg.Server.ListenAddr) 239 + return http.ListenAndServe(s.cfg.Server.ListenAddr, s.Router()) 240 + } 241 + 242 + func Run(ctx context.Context) error { 243 + cfg, err := config.Load(ctx) 244 + if err != nil { 245 + return fmt.Errorf("failed to load config: %w", err) 246 + } 247 + 248 + nixeryEng, err := nixery.New(ctx, cfg) 249 + if err != nil { 250 + return err 251 + } 252 + 253 + s, err := New(ctx, cfg, map[string]models.Engine{ 254 + "nixery": nixeryEng, 255 + }) 256 + if err != nil { 257 + return err 258 + } 195 259 196 - return nil 260 + return s.Start(ctx) 197 261 } 198 262 199 263 func (s *Spindle) Router() http.Handler { 200 264 mux := chi.NewRouter() 201 265 202 266 mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 203 - w.Write(motd) 267 + w.Write(s.GetMotdContent()) 204 268 }) 205 269 mux.HandleFunc("/events", s.Events) 206 270 mux.HandleFunc("/logs/{knot}/{rkey}/{name}", s.Logs) ··· 266 330 267 331 workflows := make(map[models.Engine][]models.Workflow) 268 332 333 + // Build pipeline environment variables once for all workflows 334 + pipelineEnv := models.PipelineEnvVars(tpl.TriggerMetadata, pipelineId, s.cfg.Server.Dev) 335 + 269 336 for _, w := range tpl.Workflows { 270 337 if w != nil { 271 338 if _, ok := s.engs[w.Engine]; !ok { ··· 290 357 if err != nil { 291 358 return err 292 359 } 360 + 361 + // inject TANGLED_* env vars after InitWorkflow 362 + // This prevents user-defined env vars from overriding them 363 + if ewf.Environment == nil { 364 + ewf.Environment = make(map[string]string) 365 + } 366 + maps.Copy(ewf.Environment, pipelineEnv) 293 367 294 368 workflows[eng] = append(workflows[eng], *ewf) 295 369
+5
spindle/stream.go
··· 213 213 if err := conn.WriteMessage(websocket.TextMessage, []byte(line.Text)); err != nil { 214 214 return fmt.Errorf("failed to write to websocket: %w", err) 215 215 } 216 + case <-time.After(30 * time.Second): 217 + // send a keep-alive 218 + if err := conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(time.Second)); err != nil { 219 + return fmt.Errorf("failed to write control: %w", err) 220 + } 216 221 } 217 222 } 218 223 }
+1 -1
tailwind.config.js
··· 2 2 const colors = require("tailwindcss/colors"); 3 3 4 4 module.exports = { 5 - content: ["./appview/pages/templates/**/*.html", "./appview/pages/chroma.go"], 5 + content: ["./appview/pages/templates/**/*.html", "./appview/pages/chroma.go", "./docs/*.html"], 6 6 darkMode: "media", 7 7 theme: { 8 8 container: {
+199
types/commit.go
··· 1 + package types 2 + 3 + import ( 4 + "bytes" 5 + "encoding/json" 6 + "fmt" 7 + "maps" 8 + "regexp" 9 + "strings" 10 + 11 + "github.com/go-git/go-git/v5/plumbing" 12 + "github.com/go-git/go-git/v5/plumbing/object" 13 + ) 14 + 15 + type Commit struct { 16 + // hash of the commit object. 17 + Hash plumbing.Hash `json:"hash,omitempty"` 18 + 19 + // author is the original author of the commit. 20 + Author object.Signature `json:"author"` 21 + 22 + // committer is the one performing the commit, might be different from author. 23 + Committer object.Signature `json:"committer"` 24 + 25 + // message is the commit message, contains arbitrary text. 26 + Message string `json:"message"` 27 + 28 + // treehash is the hash of the root tree of the commit. 29 + Tree string `json:"tree"` 30 + 31 + // parents are the hashes of the parent commits of the commit. 32 + ParentHashes []plumbing.Hash `json:"parent_hashes,omitempty"` 33 + 34 + // pgpsignature is the pgp signature of the commit. 35 + PGPSignature string `json:"pgp_signature,omitempty"` 36 + 37 + // mergetag is the embedded tag object when a merge commit is created by 38 + // merging a signed tag. 39 + MergeTag string `json:"merge_tag,omitempty"` 40 + 41 + // changeid is a unique identifier for the change (e.g., gerrit change-id). 42 + ChangeId string `json:"change_id,omitempty"` 43 + 44 + // extraheaders contains additional headers not captured by other fields. 45 + ExtraHeaders map[string][]byte `json:"extra_headers,omitempty"` 46 + 47 + // deprecated: kept for backwards compatibility with old json format. 48 + This string `json:"this,omitempty"` 49 + 50 + // deprecated: kept for backwards compatibility with old json format. 51 + Parent string `json:"parent,omitempty"` 52 + } 53 + 54 + // types.Commit is an unify two commit structs: 55 + // - git.object.Commit from 56 + // - types.NiceDiff.commit 57 + // 58 + // to do this in backwards compatible fashion, we define the base struct 59 + // to use the same fields as NiceDiff.Commit, and then we also unmarshal 60 + // the struct fields from go-git structs, this custom unmarshal makes sense 61 + // of both representations and unifies them to have maximal data in either 62 + // form. 63 + func (c *Commit) UnmarshalJSON(data []byte) error { 64 + type Alias Commit 65 + 66 + aux := &struct { 67 + *object.Commit 68 + *Alias 69 + }{ 70 + Alias: (*Alias)(c), 71 + } 72 + 73 + if err := json.Unmarshal(data, aux); err != nil { 74 + return err 75 + } 76 + 77 + c.FromGoGitCommit(aux.Commit) 78 + 79 + return nil 80 + } 81 + 82 + // fill in as much of Commit as possible from the given go-git commit 83 + func (c *Commit) FromGoGitCommit(gc *object.Commit) { 84 + if gc == nil { 85 + return 86 + } 87 + 88 + if c.Hash.IsZero() { 89 + c.Hash = gc.Hash 90 + } 91 + if c.This == "" { 92 + c.This = gc.Hash.String() 93 + } 94 + if isEmptySignature(c.Author) { 95 + c.Author = gc.Author 96 + } 97 + if isEmptySignature(c.Committer) { 98 + c.Committer = gc.Committer 99 + } 100 + if c.Message == "" { 101 + c.Message = gc.Message 102 + } 103 + if c.Tree == "" { 104 + c.Tree = gc.TreeHash.String() 105 + } 106 + if c.PGPSignature == "" { 107 + c.PGPSignature = gc.PGPSignature 108 + } 109 + if c.MergeTag == "" { 110 + c.MergeTag = gc.MergeTag 111 + } 112 + 113 + if len(c.ParentHashes) == 0 { 114 + c.ParentHashes = gc.ParentHashes 115 + } 116 + if c.Parent == "" && len(gc.ParentHashes) > 0 { 117 + c.Parent = gc.ParentHashes[0].String() 118 + } 119 + 120 + if len(c.ExtraHeaders) == 0 { 121 + c.ExtraHeaders = make(map[string][]byte) 122 + maps.Copy(c.ExtraHeaders, gc.ExtraHeaders) 123 + } 124 + 125 + if c.ChangeId == "" { 126 + if v, ok := gc.ExtraHeaders["change-id"]; ok { 127 + c.ChangeId = string(v) 128 + } 129 + } 130 + } 131 + 132 + func isEmptySignature(s object.Signature) bool { 133 + return s.Email == "" && s.Name == "" && s.When.IsZero() 134 + } 135 + 136 + // produce a verifiable payload from this commit's metadata 137 + func (c *Commit) Payload() string { 138 + author := bytes.NewBuffer([]byte{}) 139 + c.Author.Encode(author) 140 + 141 + committer := bytes.NewBuffer([]byte{}) 142 + c.Committer.Encode(committer) 143 + 144 + payload := strings.Builder{} 145 + 146 + fmt.Fprintf(&payload, "tree %s\n", c.Tree) 147 + 148 + if len(c.ParentHashes) > 0 { 149 + for _, p := range c.ParentHashes { 150 + fmt.Fprintf(&payload, "parent %s\n", p.String()) 151 + } 152 + } else { 153 + // present for backwards compatibility 154 + fmt.Fprintf(&payload, "parent %s\n", c.Parent) 155 + } 156 + 157 + fmt.Fprintf(&payload, "author %s\n", author.String()) 158 + fmt.Fprintf(&payload, "committer %s\n", committer.String()) 159 + 160 + if c.ChangeId != "" { 161 + fmt.Fprintf(&payload, "change-id %s\n", c.ChangeId) 162 + } else if v, ok := c.ExtraHeaders["change-id"]; ok { 163 + fmt.Fprintf(&payload, "change-id %s\n", string(v)) 164 + } 165 + 166 + fmt.Fprintf(&payload, "\n%s", c.Message) 167 + 168 + return payload.String() 169 + } 170 + 171 + var ( 172 + coAuthorRegex = regexp.MustCompile(`(?im)^Co-authored-by:\s*(.+?)\s*<([^>]+)>`) 173 + ) 174 + 175 + func (commit Commit) CoAuthors() []object.Signature { 176 + var coAuthors []object.Signature 177 + seen := make(map[string]bool) 178 + matches := coAuthorRegex.FindAllStringSubmatch(commit.Message, -1) 179 + 180 + for _, match := range matches { 181 + if len(match) >= 3 { 182 + name := strings.TrimSpace(match[1]) 183 + email := strings.TrimSpace(match[2]) 184 + 185 + if seen[email] { 186 + continue 187 + } 188 + seen[email] = true 189 + 190 + coAuthors = append(coAuthors, object.Signature{ 191 + Name: name, 192 + Email: email, 193 + When: commit.Committer.When, 194 + }) 195 + } 196 + } 197 + 198 + return coAuthors 199 + }
+5 -12
types/diff.go
··· 2 2 3 3 import ( 4 4 "github.com/bluekeyes/go-gitdiff/gitdiff" 5 - "github.com/go-git/go-git/v5/plumbing/object" 6 5 ) 7 6 8 7 type DiffOpts struct { ··· 43 42 44 43 // A nicer git diff representation. 45 44 type NiceDiff struct { 46 - Commit struct { 47 - Message string `json:"message"` 48 - Author object.Signature `json:"author"` 49 - This string `json:"this"` 50 - Parent string `json:"parent"` 51 - PGPSignature string `json:"pgp_signature"` 52 - Committer object.Signature `json:"committer"` 53 - Tree string `json:"tree"` 54 - ChangedId string `json:"change_id"` 55 - } `json:"commit"` 56 - Stat struct { 45 + Commit Commit `json:"commit"` 46 + Stat struct { 57 47 FilesChanged int `json:"files_changed"` 58 48 Insertions int `json:"insertions"` 59 49 Deletions int `json:"deletions"` ··· 84 74 85 75 // used by html elements as a unique ID for hrefs 86 76 func (d *Diff) Id() string { 77 + if d.IsDelete { 78 + return d.Name.Old 79 + } 87 80 return d.Name.New 88 81 } 89 82
+112
types/diff_test.go
··· 1 + package types 2 + 3 + import "testing" 4 + 5 + func TestDiffId(t *testing.T) { 6 + tests := []struct { 7 + name string 8 + diff Diff 9 + expected string 10 + }{ 11 + { 12 + name: "regular file uses new name", 13 + diff: Diff{ 14 + Name: struct { 15 + Old string `json:"old"` 16 + New string `json:"new"` 17 + }{Old: "", New: "src/main.go"}, 18 + }, 19 + expected: "src/main.go", 20 + }, 21 + { 22 + name: "new file uses new name", 23 + diff: Diff{ 24 + Name: struct { 25 + Old string `json:"old"` 26 + New string `json:"new"` 27 + }{Old: "", New: "src/new.go"}, 28 + IsNew: true, 29 + }, 30 + expected: "src/new.go", 31 + }, 32 + { 33 + name: "deleted file uses old name", 34 + diff: Diff{ 35 + Name: struct { 36 + Old string `json:"old"` 37 + New string `json:"new"` 38 + }{Old: "src/deleted.go", New: ""}, 39 + IsDelete: true, 40 + }, 41 + expected: "src/deleted.go", 42 + }, 43 + { 44 + name: "renamed file uses new name", 45 + diff: Diff{ 46 + Name: struct { 47 + Old string `json:"old"` 48 + New string `json:"new"` 49 + }{Old: "src/old.go", New: "src/renamed.go"}, 50 + IsRename: true, 51 + }, 52 + expected: "src/renamed.go", 53 + }, 54 + } 55 + 56 + for _, tt := range tests { 57 + t.Run(tt.name, func(t *testing.T) { 58 + if got := tt.diff.Id(); got != tt.expected { 59 + t.Errorf("Diff.Id() = %q, want %q", got, tt.expected) 60 + } 61 + }) 62 + } 63 + } 64 + 65 + func TestChangedFilesMatchesDiffId(t *testing.T) { 66 + // ChangedFiles() must return values matching each Diff's Id() 67 + // so that sidebar links point to the correct anchors. 68 + // Tests existing, deleted, new, and renamed files. 69 + nd := NiceDiff{ 70 + Diff: []Diff{ 71 + { 72 + Name: struct { 73 + Old string `json:"old"` 74 + New string `json:"new"` 75 + }{Old: "", New: "src/modified.go"}, 76 + }, 77 + { 78 + Name: struct { 79 + Old string `json:"old"` 80 + New string `json:"new"` 81 + }{Old: "src/deleted.go", New: ""}, 82 + IsDelete: true, 83 + }, 84 + { 85 + Name: struct { 86 + Old string `json:"old"` 87 + New string `json:"new"` 88 + }{Old: "", New: "src/new.go"}, 89 + IsNew: true, 90 + }, 91 + { 92 + Name: struct { 93 + Old string `json:"old"` 94 + New string `json:"new"` 95 + }{Old: "src/old.go", New: "src/renamed.go"}, 96 + IsRename: true, 97 + }, 98 + }, 99 + } 100 + 101 + changedFiles := nd.ChangedFiles() 102 + 103 + if len(changedFiles) != len(nd.Diff) { 104 + t.Fatalf("ChangedFiles() returned %d items, want %d", len(changedFiles), len(nd.Diff)) 105 + } 106 + 107 + for i, diff := range nd.Diff { 108 + if changedFiles[i] != diff.Id() { 109 + t.Errorf("ChangedFiles()[%d] = %q, but Diff.Id() = %q", i, changedFiles[i], diff.Id()) 110 + } 111 + } 112 + }
+39 -18
types/repo.go
··· 1 1 package types 2 2 3 3 import ( 4 + "encoding/json" 5 + 4 6 "github.com/bluekeyes/go-gitdiff/gitdiff" 5 7 "github.com/go-git/go-git/v5/plumbing/object" 6 8 ) 7 9 8 10 type RepoIndexResponse struct { 9 - IsEmpty bool `json:"is_empty"` 10 - Ref string `json:"ref,omitempty"` 11 - Readme string `json:"readme,omitempty"` 12 - ReadmeFileName string `json:"readme_file_name,omitempty"` 13 - Commits []*object.Commit `json:"commits,omitempty"` 14 - Description string `json:"description,omitempty"` 15 - Files []NiceTree `json:"files,omitempty"` 16 - Branches []Branch `json:"branches,omitempty"` 17 - Tags []*TagReference `json:"tags,omitempty"` 18 - TotalCommits int `json:"total_commits,omitempty"` 11 + IsEmpty bool `json:"is_empty"` 12 + Ref string `json:"ref,omitempty"` 13 + Readme string `json:"readme,omitempty"` 14 + ReadmeFileName string `json:"readme_file_name,omitempty"` 15 + Commits []Commit `json:"commits,omitempty"` 16 + Description string `json:"description,omitempty"` 17 + Files []NiceTree `json:"files,omitempty"` 18 + Branches []Branch `json:"branches,omitempty"` 19 + Tags []*TagReference `json:"tags,omitempty"` 20 + TotalCommits int `json:"total_commits,omitempty"` 19 21 } 20 22 21 23 type RepoLogResponse struct { 22 - Commits []*object.Commit `json:"commits,omitempty"` 23 - Ref string `json:"ref,omitempty"` 24 - Description string `json:"description,omitempty"` 25 - Log bool `json:"log,omitempty"` 26 - Total int `json:"total,omitempty"` 27 - Page int `json:"page,omitempty"` 28 - PerPage int `json:"per_page,omitempty"` 24 + Commits []Commit `json:"commits,omitempty"` 25 + Ref string `json:"ref,omitempty"` 26 + Description string `json:"description,omitempty"` 27 + Log bool `json:"log,omitempty"` 28 + Total int `json:"total,omitempty"` 29 + Page int `json:"page,omitempty"` 30 + PerPage int `json:"per_page,omitempty"` 29 31 } 30 32 31 33 type RepoCommitResponse struct { ··· 66 68 type Branch struct { 67 69 Reference `json:"reference"` 68 70 Commit *object.Commit `json:"commit,omitempty"` 69 - IsDefault bool `json:"is_deafult,omitempty"` 71 + IsDefault bool `json:"is_default,omitempty"` 72 + } 73 + 74 + func (b *Branch) UnmarshalJSON(data []byte) error { 75 + aux := &struct { 76 + Reference `json:"reference"` 77 + Commit *object.Commit `json:"commit,omitempty"` 78 + IsDefault bool `json:"is_default,omitempty"` 79 + MispelledIsDefault bool `json:"is_deafult,omitempty"` // mispelled name 80 + }{} 81 + 82 + if err := json.Unmarshal(data, aux); err != nil { 83 + return err 84 + } 85 + 86 + b.Reference = aux.Reference 87 + b.Commit = aux.Commit 88 + b.IsDefault = aux.IsDefault || aux.MispelledIsDefault // whichever was set 89 + 90 + return nil 70 91 } 71 92 72 93 type RepoTagsResponse struct {
+88 -5
types/tree.go
··· 1 1 package types 2 2 3 3 import ( 4 + "fmt" 5 + "os" 4 6 "time" 5 7 6 8 "github.com/go-git/go-git/v5/plumbing" 9 + "github.com/go-git/go-git/v5/plumbing/filemode" 7 10 ) 8 11 9 12 // A nicer git tree representation. 10 13 type NiceTree struct { 11 14 // Relative path 12 - Name string `json:"name"` 13 - Mode string `json:"mode"` 14 - Size int64 `json:"size"` 15 - IsFile bool `json:"is_file"` 16 - IsSubtree bool `json:"is_subtree"` 15 + Name string `json:"name"` 16 + Mode string `json:"mode"` 17 + Size int64 `json:"size"` 17 18 18 19 LastCommit *LastCommitInfo `json:"last_commit,omitempty"` 20 + } 21 + 22 + func (t *NiceTree) FileMode() (filemode.FileMode, error) { 23 + if numericMode, err := filemode.New(t.Mode); err == nil { 24 + return numericMode, nil 25 + } 26 + 27 + // TODO: this is here for backwards compat, can be removed in future versions 28 + osMode, err := parseModeString(t.Mode) 29 + if err != nil { 30 + return filemode.Empty, nil 31 + } 32 + 33 + conv, err := filemode.NewFromOSFileMode(osMode) 34 + if err != nil { 35 + return filemode.Empty, nil 36 + } 37 + 38 + return conv, nil 39 + } 40 + 41 + // ParseFileModeString parses a file mode string like "-rw-r--r--" 42 + // and returns an os.FileMode 43 + func parseModeString(modeStr string) (os.FileMode, error) { 44 + if len(modeStr) != 10 { 45 + return 0, fmt.Errorf("invalid mode string length: expected 10, got %d", len(modeStr)) 46 + } 47 + 48 + var mode os.FileMode 49 + 50 + // Parse file type (first character) 51 + switch modeStr[0] { 52 + case 'd': 53 + mode |= os.ModeDir 54 + case 'l': 55 + mode |= os.ModeSymlink 56 + case '-': 57 + // regular file 58 + default: 59 + return 0, fmt.Errorf("unknown file type: %c", modeStr[0]) 60 + } 61 + 62 + // parse permissions for owner, group, and other 63 + perms := modeStr[1:] 64 + shifts := []int{6, 3, 0} // bit shifts for owner, group, other 65 + 66 + for i := range 3 { 67 + offset := i * 3 68 + shift := shifts[i] 69 + 70 + if perms[offset] == 'r' { 71 + mode |= os.FileMode(4 << shift) 72 + } 73 + if perms[offset+1] == 'w' { 74 + mode |= os.FileMode(2 << shift) 75 + } 76 + if perms[offset+2] == 'x' { 77 + mode |= os.FileMode(1 << shift) 78 + } 79 + } 80 + 81 + return mode, nil 82 + } 83 + 84 + func (t *NiceTree) IsFile() bool { 85 + m, err := t.FileMode() 86 + 87 + if err != nil { 88 + return false 89 + } 90 + 91 + return m.IsFile() 92 + } 93 + 94 + func (t *NiceTree) IsSubmodule() bool { 95 + m, err := t.FileMode() 96 + 97 + if err != nil { 98 + return false 99 + } 100 + 101 + return m == filemode.Submodule 19 102 } 20 103 21 104 type LastCommitInfo struct {
+9 -1
workflow/compile.go
··· 113 113 func (compiler *Compiler) compileWorkflow(w Workflow) *tangled.Pipeline_Workflow { 114 114 cw := &tangled.Pipeline_Workflow{} 115 115 116 - if !w.Match(compiler.Trigger) { 116 + matched, err := w.Match(compiler.Trigger) 117 + if err != nil { 118 + compiler.Diagnostics.AddError( 119 + w.Name, 120 + fmt.Errorf("failed to execute workflow: %w", err), 121 + ) 122 + return nil 123 + } 124 + if !matched { 117 125 compiler.Diagnostics.AddWarning( 118 126 w.Name, 119 127 WorkflowSkipped,
+125
workflow/compile_test.go
··· 95 95 assert.Len(t, c.Diagnostics.Errors, 1) 96 96 assert.Equal(t, MissingEngine, c.Diagnostics.Errors[0].Error) 97 97 } 98 + 99 + func TestCompileWorkflow_MultipleBranchAndTag(t *testing.T) { 100 + wf := Workflow{ 101 + Name: ".tangled/workflows/branch_and_tag.yml", 102 + When: []Constraint{ 103 + { 104 + Event: []string{"push"}, 105 + Branch: []string{"main", "develop"}, 106 + Tag: []string{"v*"}, 107 + }, 108 + }, 109 + Engine: "nixery", 110 + } 111 + 112 + tests := []struct { 113 + name string 114 + trigger tangled.Pipeline_TriggerMetadata 115 + shouldMatch bool 116 + expectedCount int 117 + }{ 118 + { 119 + name: "matches main branch", 120 + trigger: tangled.Pipeline_TriggerMetadata{ 121 + Kind: string(TriggerKindPush), 122 + Push: &tangled.Pipeline_PushTriggerData{ 123 + Ref: "refs/heads/main", 124 + OldSha: strings.Repeat("0", 40), 125 + NewSha: strings.Repeat("f", 40), 126 + }, 127 + }, 128 + shouldMatch: true, 129 + expectedCount: 1, 130 + }, 131 + { 132 + name: "matches develop branch", 133 + trigger: tangled.Pipeline_TriggerMetadata{ 134 + Kind: string(TriggerKindPush), 135 + Push: &tangled.Pipeline_PushTriggerData{ 136 + Ref: "refs/heads/develop", 137 + OldSha: strings.Repeat("0", 40), 138 + NewSha: strings.Repeat("f", 40), 139 + }, 140 + }, 141 + shouldMatch: true, 142 + expectedCount: 1, 143 + }, 144 + { 145 + name: "matches v* tag pattern", 146 + trigger: tangled.Pipeline_TriggerMetadata{ 147 + Kind: string(TriggerKindPush), 148 + Push: &tangled.Pipeline_PushTriggerData{ 149 + Ref: "refs/tags/v1.0.0", 150 + OldSha: strings.Repeat("0", 40), 151 + NewSha: strings.Repeat("f", 40), 152 + }, 153 + }, 154 + shouldMatch: true, 155 + expectedCount: 1, 156 + }, 157 + { 158 + name: "matches v* tag pattern with different version", 159 + trigger: tangled.Pipeline_TriggerMetadata{ 160 + Kind: string(TriggerKindPush), 161 + Push: &tangled.Pipeline_PushTriggerData{ 162 + Ref: "refs/tags/v2.5.3", 163 + OldSha: strings.Repeat("0", 40), 164 + NewSha: strings.Repeat("f", 40), 165 + }, 166 + }, 167 + shouldMatch: true, 168 + expectedCount: 1, 169 + }, 170 + { 171 + name: "does not match master branch", 172 + trigger: tangled.Pipeline_TriggerMetadata{ 173 + Kind: string(TriggerKindPush), 174 + Push: &tangled.Pipeline_PushTriggerData{ 175 + Ref: "refs/heads/master", 176 + OldSha: strings.Repeat("0", 40), 177 + NewSha: strings.Repeat("f", 40), 178 + }, 179 + }, 180 + shouldMatch: false, 181 + expectedCount: 0, 182 + }, 183 + { 184 + name: "does not match non-v tag", 185 + trigger: tangled.Pipeline_TriggerMetadata{ 186 + Kind: string(TriggerKindPush), 187 + Push: &tangled.Pipeline_PushTriggerData{ 188 + Ref: "refs/tags/release-1.0", 189 + OldSha: strings.Repeat("0", 40), 190 + NewSha: strings.Repeat("f", 40), 191 + }, 192 + }, 193 + shouldMatch: false, 194 + expectedCount: 0, 195 + }, 196 + { 197 + name: "does not match feature branch", 198 + trigger: tangled.Pipeline_TriggerMetadata{ 199 + Kind: string(TriggerKindPush), 200 + Push: &tangled.Pipeline_PushTriggerData{ 201 + Ref: "refs/heads/feature/new-feature", 202 + OldSha: strings.Repeat("0", 40), 203 + NewSha: strings.Repeat("f", 40), 204 + }, 205 + }, 206 + shouldMatch: false, 207 + expectedCount: 0, 208 + }, 209 + } 210 + 211 + for _, tt := range tests { 212 + t.Run(tt.name, func(t *testing.T) { 213 + c := Compiler{Trigger: tt.trigger} 214 + cp := c.Compile([]Workflow{wf}) 215 + 216 + assert.Len(t, cp.Workflows, tt.expectedCount) 217 + if tt.shouldMatch { 218 + assert.Equal(t, wf.Name, cp.Workflows[0].Name) 219 + } 220 + }) 221 + } 222 + }
+61 -19
workflow/def.go
··· 8 8 9 9 "tangled.org/core/api/tangled" 10 10 11 + "github.com/bmatcuk/doublestar/v4" 11 12 "github.com/go-git/go-git/v5/plumbing" 12 13 "gopkg.in/yaml.v3" 13 14 ) ··· 33 34 34 35 Constraint struct { 35 36 Event StringList `yaml:"event"` 36 - Branch StringList `yaml:"branch"` // this is optional, and only applied on "push" events 37 + Branch StringList `yaml:"branch"` // required for pull_request; for push, either branch or tag must be specified 38 + Tag StringList `yaml:"tag"` // optional; only applies to push events 37 39 } 38 40 39 41 CloneOpts struct { ··· 59 61 return strings.ReplaceAll(string(t), "_", " ") 60 62 } 61 63 64 + // matchesPattern checks if a name matches any of the given patterns. 65 + // Patterns can be exact matches or glob patterns using * and **. 66 + // * matches any sequence of non-separator characters 67 + // ** matches any sequence of characters including separators 68 + func matchesPattern(name string, patterns []string) (bool, error) { 69 + for _, pattern := range patterns { 70 + matched, err := doublestar.Match(pattern, name) 71 + if err != nil { 72 + return false, err 73 + } 74 + if matched { 75 + return true, nil 76 + } 77 + } 78 + return false, nil 79 + } 80 + 62 81 func FromFile(name string, contents []byte) (Workflow, error) { 63 82 var wf Workflow 64 83 ··· 74 93 } 75 94 76 95 // if any of the constraints on a workflow is true, return true 77 - func (w *Workflow) Match(trigger tangled.Pipeline_TriggerMetadata) bool { 96 + func (w *Workflow) Match(trigger tangled.Pipeline_TriggerMetadata) (bool, error) { 78 97 // manual triggers always run the workflow 79 98 if trigger.Manual != nil { 80 - return true 99 + return true, nil 81 100 } 82 101 83 102 // if not manual, run through the constraint list and see if any one matches 84 103 for _, c := range w.When { 85 - if c.Match(trigger) { 86 - return true 104 + matched, err := c.Match(trigger) 105 + if err != nil { 106 + return false, err 107 + } 108 + if matched { 109 + return true, nil 87 110 } 88 111 } 89 112 90 113 // no constraints, always run this workflow 91 114 if len(w.When) == 0 { 92 - return true 115 + return true, nil 93 116 } 94 117 95 - return false 118 + return false, nil 96 119 } 97 120 98 - func (c *Constraint) Match(trigger tangled.Pipeline_TriggerMetadata) bool { 121 + func (c *Constraint) Match(trigger tangled.Pipeline_TriggerMetadata) (bool, error) { 99 122 match := true 100 123 101 124 // manual triggers always pass this constraint 102 125 if trigger.Manual != nil { 103 - return true 126 + return true, nil 104 127 } 105 128 106 129 // apply event constraints ··· 108 131 109 132 // apply branch constraints for PRs 110 133 if trigger.PullRequest != nil { 111 - match = match && c.MatchBranch(trigger.PullRequest.TargetBranch) 134 + matched, err := c.MatchBranch(trigger.PullRequest.TargetBranch) 135 + if err != nil { 136 + return false, err 137 + } 138 + match = match && matched 112 139 } 113 140 114 141 // apply ref constraints for pushes 115 142 if trigger.Push != nil { 116 - match = match && c.MatchRef(trigger.Push.Ref) 143 + matched, err := c.MatchRef(trigger.Push.Ref) 144 + if err != nil { 145 + return false, err 146 + } 147 + match = match && matched 117 148 } 118 149 119 - return match 120 - } 121 - 122 - func (c *Constraint) MatchBranch(branch string) bool { 123 - return slices.Contains(c.Branch, branch) 150 + return match, nil 124 151 } 125 152 126 - func (c *Constraint) MatchRef(ref string) bool { 153 + func (c *Constraint) MatchRef(ref string) (bool, error) { 127 154 refName := plumbing.ReferenceName(ref) 155 + shortName := refName.Short() 156 + 128 157 if refName.IsBranch() { 129 - return slices.Contains(c.Branch, refName.Short()) 158 + return c.MatchBranch(shortName) 130 159 } 131 - return false 160 + 161 + if refName.IsTag() { 162 + return c.MatchTag(shortName) 163 + } 164 + 165 + return false, nil 166 + } 167 + 168 + func (c *Constraint) MatchBranch(branch string) (bool, error) { 169 + return matchesPattern(branch, c.Branch) 170 + } 171 + 172 + func (c *Constraint) MatchTag(tag string) (bool, error) { 173 + return matchesPattern(tag, c.Tag) 132 174 } 133 175 134 176 func (c *Constraint) MatchEvent(event string) bool {
+284 -1
workflow/def_test.go
··· 6 6 "github.com/stretchr/testify/assert" 7 7 ) 8 8 9 - func TestUnmarshalWorkflow(t *testing.T) { 9 + func TestUnmarshalWorkflowWithBranch(t *testing.T) { 10 10 yamlData := ` 11 11 when: 12 12 - event: ["push", "pull_request"] ··· 38 38 39 39 assert.True(t, wf.CloneOpts.Skip, "Skip should be false") 40 40 } 41 + 42 + func TestUnmarshalWorkflowWithTags(t *testing.T) { 43 + yamlData := ` 44 + when: 45 + - event: ["push"] 46 + tag: ["v*", "release-*"]` 47 + 48 + wf, err := FromFile("test.yml", []byte(yamlData)) 49 + assert.NoError(t, err, "YAML should unmarshal without error") 50 + 51 + assert.Len(t, wf.When, 1, "Should have one constraint") 52 + assert.ElementsMatch(t, []string{"v*", "release-*"}, wf.When[0].Tag) 53 + assert.ElementsMatch(t, []string{"push"}, wf.When[0].Event) 54 + } 55 + 56 + func TestUnmarshalWorkflowWithBranchAndTag(t *testing.T) { 57 + yamlData := ` 58 + when: 59 + - event: ["push"] 60 + branch: ["main", "develop"] 61 + tag: ["v*"]` 62 + 63 + wf, err := FromFile("test.yml", []byte(yamlData)) 64 + assert.NoError(t, err, "YAML should unmarshal without error") 65 + 66 + assert.Len(t, wf.When, 1, "Should have one constraint") 67 + assert.ElementsMatch(t, []string{"main", "develop"}, wf.When[0].Branch) 68 + assert.ElementsMatch(t, []string{"v*"}, wf.When[0].Tag) 69 + } 70 + 71 + func TestMatchesPattern(t *testing.T) { 72 + tests := []struct { 73 + name string 74 + input string 75 + patterns []string 76 + expected bool 77 + }{ 78 + {"exact match", "main", []string{"main"}, true}, 79 + {"exact match in list", "develop", []string{"main", "develop"}, true}, 80 + {"no match", "feature", []string{"main", "develop"}, false}, 81 + {"wildcard prefix", "v1.0.0", []string{"v*"}, true}, 82 + {"wildcard suffix", "release-1.0", []string{"*-1.0"}, true}, 83 + {"wildcard middle", "feature-123-test", []string{"feature-*-test"}, true}, 84 + {"double star prefix", "release-1.0.0", []string{"release-**"}, true}, 85 + {"double star with slashes", "release/1.0/hotfix", []string{"release/**"}, true}, 86 + {"double star matches multiple levels", "foo/bar/baz/qux", []string{"foo/**"}, true}, 87 + {"double star no match", "feature/test", []string{"release/**"}, false}, 88 + {"no patterns matches nothing", "anything", []string{}, false}, 89 + {"pattern doesn't match", "v1.0.0", []string{"release-*"}, false}, 90 + {"complex pattern", "release/v1.2.3", []string{"release/*"}, true}, 91 + {"single star stops at slash", "release/1.0/hotfix", []string{"release/*"}, false}, 92 + } 93 + 94 + for _, tt := range tests { 95 + t.Run(tt.name, func(t *testing.T) { 96 + result, _ := matchesPattern(tt.input, tt.patterns) 97 + assert.Equal(t, tt.expected, result, "matchesPattern(%q, %v) should be %v", tt.input, tt.patterns, tt.expected) 98 + }) 99 + } 100 + } 101 + 102 + func TestConstraintMatchRef_Branches(t *testing.T) { 103 + tests := []struct { 104 + name string 105 + constraint Constraint 106 + ref string 107 + expected bool 108 + }{ 109 + { 110 + name: "exact branch match", 111 + constraint: Constraint{Branch: []string{"main"}}, 112 + ref: "refs/heads/main", 113 + expected: true, 114 + }, 115 + { 116 + name: "branch glob match", 117 + constraint: Constraint{Branch: []string{"feature-*"}}, 118 + ref: "refs/heads/feature-123", 119 + expected: true, 120 + }, 121 + { 122 + name: "branch no match", 123 + constraint: Constraint{Branch: []string{"main"}}, 124 + ref: "refs/heads/develop", 125 + expected: false, 126 + }, 127 + { 128 + name: "no constraints matches nothing", 129 + constraint: Constraint{}, 130 + ref: "refs/heads/anything", 131 + expected: false, 132 + }, 133 + } 134 + 135 + for _, tt := range tests { 136 + t.Run(tt.name, func(t *testing.T) { 137 + result, _ := tt.constraint.MatchRef(tt.ref) 138 + assert.Equal(t, tt.expected, result, "MatchRef should return %v for ref %q", tt.expected, tt.ref) 139 + }) 140 + } 141 + } 142 + 143 + func TestConstraintMatchRef_Tags(t *testing.T) { 144 + tests := []struct { 145 + name string 146 + constraint Constraint 147 + ref string 148 + expected bool 149 + }{ 150 + { 151 + name: "exact tag match", 152 + constraint: Constraint{Tag: []string{"v1.0.0"}}, 153 + ref: "refs/tags/v1.0.0", 154 + expected: true, 155 + }, 156 + { 157 + name: "tag glob match", 158 + constraint: Constraint{Tag: []string{"v*"}}, 159 + ref: "refs/tags/v1.2.3", 160 + expected: true, 161 + }, 162 + { 163 + name: "tag glob with pattern", 164 + constraint: Constraint{Tag: []string{"release-*"}}, 165 + ref: "refs/tags/release-2024", 166 + expected: true, 167 + }, 168 + { 169 + name: "tag no match", 170 + constraint: Constraint{Tag: []string{"v*"}}, 171 + ref: "refs/tags/release-1.0", 172 + expected: false, 173 + }, 174 + { 175 + name: "tag not matched when only branch constraint", 176 + constraint: Constraint{Branch: []string{"main"}}, 177 + ref: "refs/tags/v1.0.0", 178 + expected: false, 179 + }, 180 + } 181 + 182 + for _, tt := range tests { 183 + t.Run(tt.name, func(t *testing.T) { 184 + result, _ := tt.constraint.MatchRef(tt.ref) 185 + assert.Equal(t, tt.expected, result, "MatchRef should return %v for ref %q", tt.expected, tt.ref) 186 + }) 187 + } 188 + } 189 + 190 + func TestConstraintMatchRef_Combined(t *testing.T) { 191 + tests := []struct { 192 + name string 193 + constraint Constraint 194 + ref string 195 + expected bool 196 + }{ 197 + { 198 + name: "matches branch in combined constraint", 199 + constraint: Constraint{Branch: []string{"main"}, Tag: []string{"v*"}}, 200 + ref: "refs/heads/main", 201 + expected: true, 202 + }, 203 + { 204 + name: "matches tag in combined constraint", 205 + constraint: Constraint{Branch: []string{"main"}, Tag: []string{"v*"}}, 206 + ref: "refs/tags/v1.0.0", 207 + expected: true, 208 + }, 209 + { 210 + name: "no match in combined constraint", 211 + constraint: Constraint{Branch: []string{"main"}, Tag: []string{"v*"}}, 212 + ref: "refs/heads/develop", 213 + expected: false, 214 + }, 215 + { 216 + name: "glob patterns in combined constraint - branch", 217 + constraint: Constraint{Branch: []string{"release-*"}, Tag: []string{"v*"}}, 218 + ref: "refs/heads/release-2024", 219 + expected: true, 220 + }, 221 + { 222 + name: "glob patterns in combined constraint - tag", 223 + constraint: Constraint{Branch: []string{"release-*"}, Tag: []string{"v*"}}, 224 + ref: "refs/tags/v2.0.0", 225 + expected: true, 226 + }, 227 + } 228 + 229 + for _, tt := range tests { 230 + t.Run(tt.name, func(t *testing.T) { 231 + result, _ := tt.constraint.MatchRef(tt.ref) 232 + assert.Equal(t, tt.expected, result, "MatchRef should return %v for ref %q", tt.expected, tt.ref) 233 + }) 234 + } 235 + } 236 + 237 + func TestConstraintMatchBranch_GlobPatterns(t *testing.T) { 238 + tests := []struct { 239 + name string 240 + constraint Constraint 241 + branch string 242 + expected bool 243 + }{ 244 + { 245 + name: "exact match", 246 + constraint: Constraint{Branch: []string{"main"}}, 247 + branch: "main", 248 + expected: true, 249 + }, 250 + { 251 + name: "glob match", 252 + constraint: Constraint{Branch: []string{"feature-*"}}, 253 + branch: "feature-123", 254 + expected: true, 255 + }, 256 + { 257 + name: "no match", 258 + constraint: Constraint{Branch: []string{"main"}}, 259 + branch: "develop", 260 + expected: false, 261 + }, 262 + { 263 + name: "multiple patterns with match", 264 + constraint: Constraint{Branch: []string{"main", "release-*"}}, 265 + branch: "release-1.0", 266 + expected: true, 267 + }, 268 + } 269 + 270 + for _, tt := range tests { 271 + t.Run(tt.name, func(t *testing.T) { 272 + result, _ := tt.constraint.MatchBranch(tt.branch) 273 + assert.Equal(t, tt.expected, result, "MatchBranch should return %v for branch %q", tt.expected, tt.branch) 274 + }) 275 + } 276 + } 277 + 278 + func TestConstraintMatchTag_GlobPatterns(t *testing.T) { 279 + tests := []struct { 280 + name string 281 + constraint Constraint 282 + tag string 283 + expected bool 284 + }{ 285 + { 286 + name: "exact match", 287 + constraint: Constraint{Tag: []string{"v1.0.0"}}, 288 + tag: "v1.0.0", 289 + expected: true, 290 + }, 291 + { 292 + name: "glob match", 293 + constraint: Constraint{Tag: []string{"v*"}}, 294 + tag: "v2.3.4", 295 + expected: true, 296 + }, 297 + { 298 + name: "no match", 299 + constraint: Constraint{Tag: []string{"v*"}}, 300 + tag: "release-1.0", 301 + expected: false, 302 + }, 303 + { 304 + name: "multiple patterns with match", 305 + constraint: Constraint{Tag: []string{"v*", "release-*"}}, 306 + tag: "release-2024", 307 + expected: true, 308 + }, 309 + { 310 + name: "empty tag list matches nothing", 311 + constraint: Constraint{Tag: []string{}}, 312 + tag: "v1.0.0", 313 + expected: false, 314 + }, 315 + } 316 + 317 + for _, tt := range tests { 318 + t.Run(tt.name, func(t *testing.T) { 319 + result, _ := tt.constraint.MatchTag(tt.tag) 320 + assert.Equal(t, tt.expected, result, "MatchTag should return %v for tag %q", tt.expected, tt.tag) 321 + }) 322 + } 323 + }