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

Compare changes

Choose any two refs to compare.

Changed files
+7319 -3216
.air
api
appview
config
db
email
indexer
issues
knots
middleware
models
notify
oauth
pages
pipelines
pulls
refresolver
repo
reporesolver
settings
spindles
state
strings
docs
guard
idresolver
jetstream
knotserver
lexicons
nix
rbac
spindle
types
+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
+649 -8
api/tangled/cbor_gen.go
··· 6938 6938 } 6939 6939 6940 6940 cw := cbg.NewCborWriter(w) 6941 - fieldCount := 5 6941 + fieldCount := 7 6942 6942 6943 6943 if t.Body == nil { 6944 + fieldCount-- 6945 + } 6946 + 6947 + if t.Mentions == nil { 6948 + fieldCount-- 6949 + } 6950 + 6951 + if t.References == nil { 6944 6952 fieldCount-- 6945 6953 } 6946 6954 ··· 7045 7053 return err 7046 7054 } 7047 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 + 7048 7092 // t.CreatedAt (string) (string) 7049 7093 if len("createdAt") > 1000000 { 7050 7094 return xerrors.Errorf("Value in field \"createdAt\" was too long") ··· 7067 7111 if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 7068 7112 return err 7069 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 + } 7070 7150 return nil 7071 7151 } 7072 7152 ··· 7095 7175 7096 7176 n := extra 7097 7177 7098 - nameBuf := make([]byte, 9) 7178 + nameBuf := make([]byte, 10) 7099 7179 for i := uint64(0); i < n; i++ { 7100 7180 nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 7101 7181 if err != nil { ··· 7164 7244 } 7165 7245 7166 7246 t.Title = string(sval) 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 + } 7167 7287 } 7168 7288 // t.CreatedAt (string) (string) 7169 7289 case "createdAt": ··· 7176 7296 7177 7297 t.CreatedAt = string(sval) 7178 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 + } 7179 7339 7180 7340 default: 7181 7341 // Field doesn't exist on this type, so ignore it ··· 7194 7354 } 7195 7355 7196 7356 cw := cbg.NewCborWriter(w) 7197 - fieldCount := 5 7357 + fieldCount := 7 7358 + 7359 + if t.Mentions == nil { 7360 + fieldCount-- 7361 + } 7362 + 7363 + if t.References == nil { 7364 + fieldCount-- 7365 + } 7198 7366 7199 7367 if t.ReplyTo == nil { 7200 7368 fieldCount-- ··· 7301 7469 } 7302 7470 } 7303 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 + 7304 7508 // t.CreatedAt (string) (string) 7305 7509 if len("createdAt") > 1000000 { 7306 7510 return xerrors.Errorf("Value in field \"createdAt\" was too long") ··· 7323 7527 if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 7324 7528 return err 7325 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 + } 7326 7566 return nil 7327 7567 } 7328 7568 ··· 7351 7591 7352 7592 n := extra 7353 7593 7354 - nameBuf := make([]byte, 9) 7594 + nameBuf := make([]byte, 10) 7355 7595 for i := uint64(0); i < n; i++ { 7356 7596 nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 7357 7597 if err != nil { ··· 7419 7659 } 7420 7660 7421 7661 t.ReplyTo = (*string)(&sval) 7662 + } 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 + 7422 7702 } 7423 7703 } 7424 7704 // t.CreatedAt (string) (string) ··· 7431 7711 } 7432 7712 7433 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 + } 7434 7754 } 7435 7755 7436 7756 default: ··· 7614 7934 } 7615 7935 7616 7936 cw := cbg.NewCborWriter(w) 7617 - fieldCount := 7 7937 + fieldCount := 9 7618 7938 7619 7939 if t.Body == nil { 7940 + fieldCount-- 7941 + } 7942 + 7943 + if t.Mentions == nil { 7944 + fieldCount-- 7945 + } 7946 + 7947 + if t.References == nil { 7620 7948 fieldCount-- 7621 7949 } 7622 7950 ··· 7760 8088 return err 7761 8089 } 7762 8090 8091 + // t.Mentions ([]string) (slice) 8092 + if t.Mentions != nil { 8093 + 8094 + if len("mentions") > 1000000 { 8095 + return xerrors.Errorf("Value in field \"mentions\" was too long") 8096 + } 8097 + 8098 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("mentions"))); err != nil { 8099 + return err 8100 + } 8101 + if _, err := cw.WriteString(string("mentions")); err != nil { 8102 + return err 8103 + } 8104 + 8105 + if len(t.Mentions) > 8192 { 8106 + return xerrors.Errorf("Slice value in field t.Mentions was too long") 8107 + } 8108 + 8109 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Mentions))); err != nil { 8110 + return err 8111 + } 8112 + for _, v := range t.Mentions { 8113 + if len(v) > 1000000 { 8114 + return xerrors.Errorf("Value in field v was too long") 8115 + } 8116 + 8117 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 8118 + return err 8119 + } 8120 + if _, err := cw.WriteString(string(v)); err != nil { 8121 + return err 8122 + } 8123 + 8124 + } 8125 + } 8126 + 7763 8127 // t.CreatedAt (string) (string) 7764 8128 if len("createdAt") > 1000000 { 7765 8129 return xerrors.Errorf("Value in field \"createdAt\" was too long") ··· 7782 8146 if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 7783 8147 return err 7784 8148 } 8149 + 8150 + // t.References ([]string) (slice) 8151 + if t.References != nil { 8152 + 8153 + if len("references") > 1000000 { 8154 + return xerrors.Errorf("Value in field \"references\" was too long") 8155 + } 8156 + 8157 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("references"))); err != nil { 8158 + return err 8159 + } 8160 + if _, err := cw.WriteString(string("references")); err != nil { 8161 + return err 8162 + } 8163 + 8164 + if len(t.References) > 8192 { 8165 + return xerrors.Errorf("Slice value in field t.References was too long") 8166 + } 8167 + 8168 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.References))); err != nil { 8169 + return err 8170 + } 8171 + for _, v := range t.References { 8172 + if len(v) > 1000000 { 8173 + return xerrors.Errorf("Value in field v was too long") 8174 + } 8175 + 8176 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 8177 + return err 8178 + } 8179 + if _, err := cw.WriteString(string(v)); err != nil { 8180 + return err 8181 + } 8182 + 8183 + } 8184 + } 7785 8185 return nil 7786 8186 } 7787 8187 ··· 7810 8210 7811 8211 n := extra 7812 8212 7813 - nameBuf := make([]byte, 9) 8213 + nameBuf := make([]byte, 10) 7814 8214 for i := uint64(0); i < n; i++ { 7815 8215 nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 7816 8216 if err != nil { ··· 7919 8319 } 7920 8320 } 7921 8321 8322 + } 8323 + // t.Mentions ([]string) (slice) 8324 + case "mentions": 8325 + 8326 + maj, extra, err = cr.ReadHeader() 8327 + if err != nil { 8328 + return err 8329 + } 8330 + 8331 + if extra > 8192 { 8332 + return fmt.Errorf("t.Mentions: array too large (%d)", extra) 8333 + } 8334 + 8335 + if maj != cbg.MajArray { 8336 + return fmt.Errorf("expected cbor array") 8337 + } 8338 + 8339 + if extra > 0 { 8340 + t.Mentions = make([]string, extra) 8341 + } 8342 + 8343 + for i := 0; i < int(extra); i++ { 8344 + { 8345 + var maj byte 8346 + var extra uint64 8347 + var err error 8348 + _ = maj 8349 + _ = extra 8350 + _ = err 8351 + 8352 + { 8353 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8354 + if err != nil { 8355 + return err 8356 + } 8357 + 8358 + t.Mentions[i] = string(sval) 8359 + } 8360 + 8361 + } 7922 8362 } 7923 8363 // t.CreatedAt (string) (string) 7924 8364 case "createdAt": ··· 7931 8371 7932 8372 t.CreatedAt = string(sval) 7933 8373 } 8374 + // t.References ([]string) (slice) 8375 + case "references": 8376 + 8377 + maj, extra, err = cr.ReadHeader() 8378 + if err != nil { 8379 + return err 8380 + } 8381 + 8382 + if extra > 8192 { 8383 + return fmt.Errorf("t.References: array too large (%d)", extra) 8384 + } 8385 + 8386 + if maj != cbg.MajArray { 8387 + return fmt.Errorf("expected cbor array") 8388 + } 8389 + 8390 + if extra > 0 { 8391 + t.References = make([]string, extra) 8392 + } 8393 + 8394 + for i := 0; i < int(extra); i++ { 8395 + { 8396 + var maj byte 8397 + var extra uint64 8398 + var err error 8399 + _ = maj 8400 + _ = extra 8401 + _ = err 8402 + 8403 + { 8404 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8405 + if err != nil { 8406 + return err 8407 + } 8408 + 8409 + t.References[i] = string(sval) 8410 + } 8411 + 8412 + } 8413 + } 7934 8414 7935 8415 default: 7936 8416 // Field doesn't exist on this type, so ignore it ··· 7949 8429 } 7950 8430 7951 8431 cw := cbg.NewCborWriter(w) 8432 + fieldCount := 6 7952 8433 7953 - if _, err := cw.Write([]byte{164}); err != nil { 8434 + if t.Mentions == nil { 8435 + fieldCount-- 8436 + } 8437 + 8438 + if t.References == nil { 8439 + fieldCount-- 8440 + } 8441 + 8442 + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 7954 8443 return err 7955 8444 } 7956 8445 ··· 8019 8508 return err 8020 8509 } 8021 8510 8511 + // t.Mentions ([]string) (slice) 8512 + if t.Mentions != nil { 8513 + 8514 + if len("mentions") > 1000000 { 8515 + return xerrors.Errorf("Value in field \"mentions\" was too long") 8516 + } 8517 + 8518 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("mentions"))); err != nil { 8519 + return err 8520 + } 8521 + if _, err := cw.WriteString(string("mentions")); err != nil { 8522 + return err 8523 + } 8524 + 8525 + if len(t.Mentions) > 8192 { 8526 + return xerrors.Errorf("Slice value in field t.Mentions was too long") 8527 + } 8528 + 8529 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Mentions))); err != nil { 8530 + return err 8531 + } 8532 + for _, v := range t.Mentions { 8533 + if len(v) > 1000000 { 8534 + return xerrors.Errorf("Value in field v was too long") 8535 + } 8536 + 8537 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 8538 + return err 8539 + } 8540 + if _, err := cw.WriteString(string(v)); err != nil { 8541 + return err 8542 + } 8543 + 8544 + } 8545 + } 8546 + 8022 8547 // t.CreatedAt (string) (string) 8023 8548 if len("createdAt") > 1000000 { 8024 8549 return xerrors.Errorf("Value in field \"createdAt\" was too long") ··· 8040 8565 } 8041 8566 if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 8042 8567 return err 8568 + } 8569 + 8570 + // t.References ([]string) (slice) 8571 + if t.References != nil { 8572 + 8573 + if len("references") > 1000000 { 8574 + return xerrors.Errorf("Value in field \"references\" was too long") 8575 + } 8576 + 8577 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("references"))); err != nil { 8578 + return err 8579 + } 8580 + if _, err := cw.WriteString(string("references")); err != nil { 8581 + return err 8582 + } 8583 + 8584 + if len(t.References) > 8192 { 8585 + return xerrors.Errorf("Slice value in field t.References was too long") 8586 + } 8587 + 8588 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.References))); err != nil { 8589 + return err 8590 + } 8591 + for _, v := range t.References { 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 + } 8043 8604 } 8044 8605 return nil 8045 8606 } ··· 8069 8630 8070 8631 n := extra 8071 8632 8072 - nameBuf := make([]byte, 9) 8633 + nameBuf := make([]byte, 10) 8073 8634 for i := uint64(0); i < n; i++ { 8074 8635 nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 8075 8636 if err != nil { ··· 8118 8679 8119 8680 t.LexiconTypeID = string(sval) 8120 8681 } 8682 + // t.Mentions ([]string) (slice) 8683 + case "mentions": 8684 + 8685 + maj, extra, err = cr.ReadHeader() 8686 + if err != nil { 8687 + return err 8688 + } 8689 + 8690 + if extra > 8192 { 8691 + return fmt.Errorf("t.Mentions: array too large (%d)", extra) 8692 + } 8693 + 8694 + if maj != cbg.MajArray { 8695 + return fmt.Errorf("expected cbor array") 8696 + } 8697 + 8698 + if extra > 0 { 8699 + t.Mentions = make([]string, extra) 8700 + } 8701 + 8702 + for i := 0; i < int(extra); i++ { 8703 + { 8704 + var maj byte 8705 + var extra uint64 8706 + var err error 8707 + _ = maj 8708 + _ = extra 8709 + _ = err 8710 + 8711 + { 8712 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8713 + if err != nil { 8714 + return err 8715 + } 8716 + 8717 + t.Mentions[i] = string(sval) 8718 + } 8719 + 8720 + } 8721 + } 8121 8722 // t.CreatedAt (string) (string) 8122 8723 case "createdAt": 8123 8724 ··· 8128 8729 } 8129 8730 8130 8731 t.CreatedAt = string(sval) 8732 + } 8733 + // t.References ([]string) (slice) 8734 + case "references": 8735 + 8736 + maj, extra, err = cr.ReadHeader() 8737 + if err != nil { 8738 + return err 8739 + } 8740 + 8741 + if extra > 8192 { 8742 + return fmt.Errorf("t.References: array too large (%d)", extra) 8743 + } 8744 + 8745 + if maj != cbg.MajArray { 8746 + return fmt.Errorf("expected cbor array") 8747 + } 8748 + 8749 + if extra > 0 { 8750 + t.References = make([]string, extra) 8751 + } 8752 + 8753 + for i := 0; i < int(extra); i++ { 8754 + { 8755 + var maj byte 8756 + var extra uint64 8757 + var err error 8758 + _ = maj 8759 + _ = extra 8760 + _ = err 8761 + 8762 + { 8763 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8764 + if err != nil { 8765 + return err 8766 + } 8767 + 8768 + t.References[i] = string(sval) 8769 + } 8770 + 8771 + } 8131 8772 } 8132 8773 8133 8774 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 }
+2
api/tangled/repopull.go
··· 20 20 LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull" cborgen:"$type,const=sh.tangled.repo.pull"` 21 21 Body *string `json:"body,omitempty" cborgen:"body,omitempty"` 22 22 CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 + Mentions []string `json:"mentions,omitempty" cborgen:"mentions,omitempty"` 23 24 Patch string `json:"patch" cborgen:"patch"` 25 + References []string `json:"references,omitempty" cborgen:"references,omitempty"` 24 26 Source *RepoPull_Source `json:"source,omitempty" cborgen:"source,omitempty"` 25 27 Target *RepoPull_Target `json:"target" cborgen:"target"` 26 28 Title string `json:"title" cborgen:"title"`
-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"`
+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) {
+55 -2
appview/db/db.go
··· 561 561 email_notifications integer not null default 0 562 562 ); 563 563 564 + create table if not exists reference_links ( 565 + id integer primary key autoincrement, 566 + from_at text not null, 567 + to_at text not null, 568 + unique (from_at, to_at) 569 + ); 570 + 564 571 create table if not exists migrations ( 565 572 id integer primary key autoincrement, 566 573 name text unique ··· 569 576 -- indexes for better performance 570 577 create index if not exists idx_notifications_recipient_created on notifications(recipient_did, created desc); 571 578 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); 579 + create index if not exists idx_references_from_at on reference_links(from_at); 580 + create index if not exists idx_references_to_at on reference_links(to_at); 574 581 `) 575 582 if err != nil { 576 583 return nil, err ··· 1117 1124 _, err := tx.Exec(` 1118 1125 alter table repos add column website text; 1119 1126 alter table repos add column topics text; 1127 + `) 1128 + return err 1129 + }) 1130 + 1131 + runMigration(conn, logger, "add-usermentioned-preference", func(tx *sql.Tx) error { 1132 + _, err := tx.Exec(` 1133 + alter table notification_preferences add column user_mentioned integer not null default 1; 1134 + `) 1135 + return err 1136 + }) 1137 + 1138 + // remove the foreign key constraints from stars. 1139 + runMigration(conn, logger, "generalize-stars-subject", func(tx *sql.Tx) error { 1140 + _, err := tx.Exec(` 1141 + create table stars_new ( 1142 + id integer primary key autoincrement, 1143 + did text not null, 1144 + rkey text not null, 1145 + 1146 + subject_at text not null, 1147 + 1148 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 1149 + unique(did, rkey), 1150 + unique(did, subject_at) 1151 + ); 1152 + 1153 + insert into stars_new ( 1154 + id, 1155 + did, 1156 + rkey, 1157 + subject_at, 1158 + created 1159 + ) 1160 + select 1161 + id, 1162 + starred_by_did, 1163 + rkey, 1164 + repo_at, 1165 + created 1166 + from stars; 1167 + 1168 + drop table stars; 1169 + alter table stars_new rename to stars; 1170 + 1171 + create index if not exists idx_stars_created on stars(created); 1172 + create index if not exists idx_stars_subject_at_created on stars(subject_at, created); 1120 1173 `) 1121 1174 return err 1122 1175 })
+73 -18
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" 15 16 ) ··· 69 70 returning rowid, issue_id 70 71 `, issue.RepoAt, issue.Did, issue.Rkey, newIssueId, issue.Title, issue.Body) 71 72 72 - return row.Scan(&issue.Id, &issue.IssueId) 73 + err = row.Scan(&issue.Id, &issue.IssueId) 74 + if err != nil { 75 + return fmt.Errorf("scan row: %w", err) 76 + } 77 + 78 + if err := putReferences(tx, issue.AtUri(), issue.References); err != nil { 79 + return fmt.Errorf("put reference_links: %w", err) 80 + } 81 + return nil 73 82 } 74 83 75 84 func updateIssue(tx *sql.Tx, issue *models.Issue) error { ··· 79 88 set title = ?, body = ?, edited = ? 80 89 where did = ? and rkey = ? 81 90 `, issue.Title, issue.Body, time.Now().Format(time.RFC3339), issue.Did, issue.Rkey) 82 - return err 91 + if err != nil { 92 + return err 93 + } 94 + 95 + if err := putReferences(tx, issue.AtUri(), issue.References); err != nil { 96 + return fmt.Errorf("put reference_links: %w", err) 97 + } 98 + return nil 83 99 } 84 100 85 101 func GetIssuesPaginated(e Execer, page pagination.Page, filters ...filter) ([]models.Issue, error) { ··· 234 250 } 235 251 } 236 252 253 + // collect references for each issue 254 + allReferencs, err := GetReferencesAll(e, FilterIn("from_at", issueAts)) 255 + if err != nil { 256 + return nil, fmt.Errorf("failed to query reference_links: %w", err) 257 + } 258 + for issueAt, references := range allReferencs { 259 + if issue, ok := issueMap[issueAt.String()]; ok { 260 + issue.References = references 261 + } 262 + } 263 + 237 264 var issues []models.Issue 238 265 for _, i := range issueMap { 239 266 issues = append(issues, *i) ··· 323 350 return ids, nil 324 351 } 325 352 326 - func AddIssueComment(e Execer, c models.IssueComment) (int64, error) { 327 - result, err := e.Exec( 353 + func AddIssueComment(tx *sql.Tx, c models.IssueComment) (int64, error) { 354 + result, err := tx.Exec( 328 355 `insert into issue_comments ( 329 356 did, 330 357 rkey, ··· 363 390 return 0, err 364 391 } 365 392 393 + if err := putReferences(tx, c.AtUri(), c.References); err != nil { 394 + return 0, fmt.Errorf("put reference_links: %w", err) 395 + } 396 + 366 397 return id, nil 367 398 } 368 399 ··· 386 417 } 387 418 388 419 func GetIssueComments(e Execer, filters ...filter) ([]models.IssueComment, error) { 389 - var comments []models.IssueComment 420 + commentMap := make(map[string]*models.IssueComment) 390 421 391 422 var conditions []string 392 423 var args []any ··· 465 496 comment.ReplyTo = &replyTo.V 466 497 } 467 498 468 - comments = append(comments, comment) 499 + atUri := comment.AtUri().String() 500 + commentMap[atUri] = &comment 469 501 } 470 502 471 503 if err = rows.Err(); err != nil { 472 504 return nil, err 473 505 } 474 506 507 + // collect references for each comments 508 + commentAts := slices.Collect(maps.Keys(commentMap)) 509 + allReferencs, err := GetReferencesAll(e, FilterIn("from_at", commentAts)) 510 + if err != nil { 511 + return nil, fmt.Errorf("failed to query reference_links: %w", err) 512 + } 513 + for commentAt, references := range allReferencs { 514 + if comment, ok := commentMap[commentAt.String()]; ok { 515 + comment.References = references 516 + } 517 + } 518 + 519 + var comments []models.IssueComment 520 + for _, c := range commentMap { 521 + comments = append(comments, *c) 522 + } 523 + 524 + sort.Slice(comments, func(i, j int) bool { 525 + return comments[i].Created.After(comments[j].Created) 526 + }) 527 + 475 528 return comments, nil 476 529 } 477 530 478 - func DeleteIssues(e Execer, filters ...filter) error { 479 - var conditions []string 480 - var args []any 481 - for _, filter := range filters { 482 - conditions = append(conditions, filter.Condition()) 483 - args = append(args, filter.Arg()...) 531 + func DeleteIssues(tx *sql.Tx, did, rkey string) error { 532 + _, err := tx.Exec( 533 + `delete from issues 534 + where did = ? and rkey = ?`, 535 + did, 536 + rkey, 537 + ) 538 + if err != nil { 539 + return fmt.Errorf("delete issue: %w", err) 484 540 } 485 541 486 - whereClause := "" 487 - if conditions != nil { 488 - whereClause = " where " + strings.Join(conditions, " and ") 542 + uri := syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", did, tangled.RepoIssueNSID, rkey)) 543 + err = deleteReferences(tx, uri) 544 + if err != nil { 545 + return fmt.Errorf("delete reference_links: %w", err) 489 546 } 490 547 491 - query := fmt.Sprintf(`delete from issues %s`, whereClause) 492 - _, err := e.Exec(query, args...) 493 - return err 548 + return nil 494 549 } 495 550 496 551 func CloseIssues(e Execer, filters ...filter) error {
+6 -2
appview/db/notifications.go
··· 400 400 pull_created, 401 401 pull_commented, 402 402 followed, 403 + user_mentioned, 403 404 pull_merged, 404 405 issue_closed, 405 406 email_notifications ··· 425 426 &prefs.PullCreated, 426 427 &prefs.PullCommented, 427 428 &prefs.Followed, 429 + &prefs.UserMentioned, 428 430 &prefs.PullMerged, 429 431 &prefs.IssueClosed, 430 432 &prefs.EmailNotifications, ··· 446 448 query := ` 447 449 INSERT OR REPLACE INTO notification_preferences 448 450 (user_did, repo_starred, issue_created, issue_commented, pull_created, 449 - pull_commented, followed, pull_merged, issue_closed, email_notifications) 450 - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 451 + pull_commented, followed, user_mentioned, pull_merged, issue_closed, 452 + email_notifications) 453 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 451 454 ` 452 455 453 456 result, err := d.DB.ExecContext(ctx, query, ··· 458 461 prefs.PullCreated, 459 462 prefs.PullCommented, 460 463 prefs.Followed, 464 + prefs.UserMentioned, 461 465 prefs.PullMerged, 462 466 prefs.IssueClosed, 463 467 prefs.EmailNotifications,
+4 -2
appview/db/pipeline.go
··· 168 168 169 169 // this is a mega query, but the most useful one: 170 170 // get N pipelines, for each one get the latest status of its N workflows 171 - func GetPipelineStatuses(e Execer, filters ...filter) ([]models.Pipeline, error) { 171 + func GetPipelineStatuses(e Execer, limit int, filters ...filter) ([]models.Pipeline, error) { 172 172 var conditions []string 173 173 var args []any 174 174 for _, filter := range filters { ··· 205 205 join 206 206 triggers t ON p.trigger_id = t.id 207 207 %s 208 - `, whereClause) 208 + order by p.created desc 209 + limit %d 210 + `, whereClause, limit) 209 211 210 212 rows, err := e.Query(query, args...) 211 213 if err != nil {
+50 -6
appview/db/pulls.go
··· 93 93 insert into pull_submissions (pull_at, round_number, patch, combined, source_rev) 94 94 values (?, ?, ?, ?, ?) 95 95 `, pull.AtUri(), 0, pull.Submissions[0].Patch, pull.Submissions[0].Combined, pull.Submissions[0].SourceRev) 96 - return err 96 + if err != nil { 97 + return err 98 + } 99 + 100 + if err := putReferences(tx, pull.AtUri(), pull.References); err != nil { 101 + return fmt.Errorf("put reference_links: %w", err) 102 + } 103 + 104 + return nil 97 105 } 98 106 99 107 func GetPullAt(e Execer, repoAt syntax.ATURI, pullId int) (syntax.ATURI, error) { ··· 266 274 } 267 275 } 268 276 277 + allReferences, err := GetReferencesAll(e, FilterIn("from_at", pullAts)) 278 + if err != nil { 279 + return nil, fmt.Errorf("failed to query reference_links: %w", err) 280 + } 281 + for pullAt, references := range allReferences { 282 + if pull, ok := pulls[pullAt]; ok { 283 + pull.References = references 284 + } 285 + } 286 + 269 287 orderedByPullId := []*models.Pull{} 270 288 for _, p := range pulls { 271 289 orderedByPullId = append(orderedByPullId, p) ··· 432 450 submissionIds := slices.Collect(maps.Keys(submissionMap)) 433 451 comments, err := GetPullComments(e, FilterIn("submission_id", submissionIds)) 434 452 if err != nil { 435 - return nil, err 453 + return nil, fmt.Errorf("failed to get pull comments: %w", err) 436 454 } 437 455 for _, comment := range comments { 438 456 if submission, ok := submissionMap[comment.SubmissionId]; ok { ··· 492 510 } 493 511 defer rows.Close() 494 512 495 - var comments []models.PullComment 513 + commentMap := make(map[string]*models.PullComment) 496 514 for rows.Next() { 497 515 var comment models.PullComment 498 516 var createdAt string ··· 514 532 comment.Created = t 515 533 } 516 534 517 - comments = append(comments, comment) 535 + atUri := comment.AtUri().String() 536 + commentMap[atUri] = &comment 518 537 } 519 538 520 539 if err := rows.Err(); err != nil { 521 540 return nil, err 522 541 } 523 542 543 + // collect references for each comments 544 + commentAts := slices.Collect(maps.Keys(commentMap)) 545 + allReferencs, err := GetReferencesAll(e, FilterIn("from_at", commentAts)) 546 + if err != nil { 547 + return nil, fmt.Errorf("failed to query reference_links: %w", err) 548 + } 549 + for commentAt, references := range allReferencs { 550 + if comment, ok := commentMap[commentAt.String()]; ok { 551 + comment.References = references 552 + } 553 + } 554 + 555 + var comments []models.PullComment 556 + for _, c := range commentMap { 557 + comments = append(comments, *c) 558 + } 559 + 560 + sort.Slice(comments, func(i, j int) bool { 561 + return comments[i].Created.Before(comments[j].Created) 562 + }) 563 + 524 564 return comments, nil 525 565 } 526 566 ··· 600 640 return pulls, nil 601 641 } 602 642 603 - func NewPullComment(e Execer, comment *models.PullComment) (int64, error) { 643 + func NewPullComment(tx *sql.Tx, comment *models.PullComment) (int64, error) { 604 644 query := `insert into pull_comments (owner_did, repo_at, submission_id, comment_at, pull_id, body) values (?, ?, ?, ?, ?, ?)` 605 - res, err := e.Exec( 645 + res, err := tx.Exec( 606 646 query, 607 647 comment.OwnerDid, 608 648 comment.RepoAt, ··· 618 658 i, err := res.LastInsertId() 619 659 if err != nil { 620 660 return 0, err 661 + } 662 + 663 + if err := putReferences(tx, comment.AtUri(), comment.References); err != nil { 664 + return 0, fmt.Errorf("put reference_links: %w", err) 621 665 } 622 666 623 667 return i, nil
+462
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 + ) 12 + 13 + // ValidateReferenceLinks resolves refLinks to Issue/PR/IssueComment/PullComment ATURIs. 14 + // It will ignore missing refLinks. 15 + func ValidateReferenceLinks(e Execer, refLinks []models.ReferenceLink) ([]syntax.ATURI, error) { 16 + var ( 17 + issueRefs []models.ReferenceLink 18 + pullRefs []models.ReferenceLink 19 + ) 20 + for _, ref := range refLinks { 21 + switch ref.Kind { 22 + case models.RefKindIssue: 23 + issueRefs = append(issueRefs, ref) 24 + case models.RefKindPull: 25 + pullRefs = append(pullRefs, ref) 26 + } 27 + } 28 + issueUris, err := findIssueReferences(e, issueRefs) 29 + if err != nil { 30 + return nil, fmt.Errorf("find issue references: %w", err) 31 + } 32 + pullUris, err := findPullReferences(e, pullRefs) 33 + if err != nil { 34 + return nil, fmt.Errorf("find pull references: %w", err) 35 + } 36 + 37 + return append(issueUris, pullUris...), nil 38 + } 39 + 40 + func findIssueReferences(e Execer, refLinks []models.ReferenceLink) ([]syntax.ATURI, error) { 41 + if len(refLinks) == 0 { 42 + return nil, nil 43 + } 44 + vals := make([]string, len(refLinks)) 45 + args := make([]any, 0, len(refLinks)*4) 46 + for i, ref := range refLinks { 47 + vals[i] = "(?, ?, ?, ?)" 48 + args = append(args, ref.Handle, ref.Repo, ref.SubjectId, ref.CommentId) 49 + } 50 + query := fmt.Sprintf( 51 + `with input(owner_did, name, issue_id, comment_id) as ( 52 + values %s 53 + ) 54 + select 55 + i.did, i.rkey, 56 + c.did, c.rkey 57 + from input inp 58 + join repos r 59 + on r.did = inp.owner_did 60 + and r.name = inp.name 61 + join issues i 62 + on i.repo_at = r.at_uri 63 + and i.issue_id = inp.issue_id 64 + left join issue_comments c 65 + on inp.comment_id is not null 66 + and c.issue_at = i.at_uri 67 + and c.id = inp.comment_id 68 + `, 69 + strings.Join(vals, ","), 70 + ) 71 + rows, err := e.Query(query, args...) 72 + if err != nil { 73 + return nil, err 74 + } 75 + defer rows.Close() 76 + 77 + var uris []syntax.ATURI 78 + 79 + for rows.Next() { 80 + // Scan rows 81 + var issueOwner, issueRkey string 82 + var commentOwner, commentRkey sql.NullString 83 + var uri syntax.ATURI 84 + if err := rows.Scan(&issueOwner, &issueRkey, &commentOwner, &commentRkey); err != nil { 85 + return nil, err 86 + } 87 + if commentOwner.Valid && commentRkey.Valid { 88 + uri = syntax.ATURI(fmt.Sprintf( 89 + "at://%s/%s/%s", 90 + commentOwner.String, 91 + tangled.RepoIssueCommentNSID, 92 + commentRkey.String, 93 + )) 94 + } else { 95 + uri = syntax.ATURI(fmt.Sprintf( 96 + "at://%s/%s/%s", 97 + issueOwner, 98 + tangled.RepoIssueNSID, 99 + issueRkey, 100 + )) 101 + } 102 + uris = append(uris, uri) 103 + } 104 + if err := rows.Err(); err != nil { 105 + return nil, fmt.Errorf("iterate rows: %w", err) 106 + } 107 + 108 + return uris, nil 109 + } 110 + 111 + func findPullReferences(e Execer, refLinks []models.ReferenceLink) ([]syntax.ATURI, error) { 112 + if len(refLinks) == 0 { 113 + return nil, nil 114 + } 115 + vals := make([]string, len(refLinks)) 116 + args := make([]any, 0, len(refLinks)*4) 117 + for i, ref := range refLinks { 118 + vals[i] = "(?, ?, ?, ?)" 119 + args = append(args, ref.Handle, ref.Repo, ref.SubjectId, ref.CommentId) 120 + } 121 + query := fmt.Sprintf( 122 + `with input(owner_did, name, pull_id, comment_id) as ( 123 + values %s 124 + ) 125 + select 126 + p.owner_did, p.rkey, 127 + c.comment_at 128 + from input inp 129 + join repos r 130 + on r.did = inp.owner_did 131 + and r.name = inp.name 132 + join pulls p 133 + on p.repo_at = r.at_uri 134 + and p.pull_id = inp.pull_id 135 + left join pull_comments c 136 + on inp.comment_id is not null 137 + and c.repo_at = r.at_uri and c.pull_id = p.pull_id 138 + and c.id = inp.comment_id 139 + `, 140 + strings.Join(vals, ","), 141 + ) 142 + rows, err := e.Query(query, args...) 143 + if err != nil { 144 + return nil, err 145 + } 146 + defer rows.Close() 147 + 148 + var uris []syntax.ATURI 149 + 150 + for rows.Next() { 151 + // Scan rows 152 + var pullOwner, pullRkey string 153 + var commentUri sql.NullString 154 + var uri syntax.ATURI 155 + if err := rows.Scan(&pullOwner, &pullRkey, &commentUri); err != nil { 156 + return nil, err 157 + } 158 + if commentUri.Valid { 159 + // no-op 160 + uri = syntax.ATURI(commentUri.String) 161 + } else { 162 + uri = syntax.ATURI(fmt.Sprintf( 163 + "at://%s/%s/%s", 164 + pullOwner, 165 + tangled.RepoPullNSID, 166 + pullRkey, 167 + )) 168 + } 169 + uris = append(uris, uri) 170 + } 171 + return uris, nil 172 + } 173 + 174 + func putReferences(tx *sql.Tx, fromAt syntax.ATURI, references []syntax.ATURI) error { 175 + err := deleteReferences(tx, fromAt) 176 + if err != nil { 177 + return fmt.Errorf("delete old reference_links: %w", err) 178 + } 179 + if len(references) == 0 { 180 + return nil 181 + } 182 + 183 + values := make([]string, 0, len(references)) 184 + args := make([]any, 0, len(references)*2) 185 + for _, ref := range references { 186 + values = append(values, "(?, ?)") 187 + args = append(args, fromAt, ref) 188 + } 189 + _, err = tx.Exec( 190 + fmt.Sprintf( 191 + `insert into reference_links (from_at, to_at) 192 + values %s`, 193 + strings.Join(values, ","), 194 + ), 195 + args..., 196 + ) 197 + if err != nil { 198 + return fmt.Errorf("insert new reference_links: %w", err) 199 + } 200 + return nil 201 + } 202 + 203 + func deleteReferences(tx *sql.Tx, fromAt syntax.ATURI) error { 204 + _, err := tx.Exec(`delete from reference_links where from_at = ?`, fromAt) 205 + return err 206 + } 207 + 208 + func GetReferencesAll(e Execer, filters ...filter) (map[syntax.ATURI][]syntax.ATURI, error) { 209 + var ( 210 + conditions []string 211 + args []any 212 + ) 213 + for _, filter := range filters { 214 + conditions = append(conditions, filter.Condition()) 215 + args = append(args, filter.Arg()...) 216 + } 217 + 218 + whereClause := "" 219 + if conditions != nil { 220 + whereClause = " where " + strings.Join(conditions, " and ") 221 + } 222 + 223 + rows, err := e.Query( 224 + fmt.Sprintf( 225 + `select from_at, to_at from reference_links %s`, 226 + whereClause, 227 + ), 228 + args..., 229 + ) 230 + if err != nil { 231 + return nil, fmt.Errorf("query reference_links: %w", err) 232 + } 233 + defer rows.Close() 234 + 235 + result := make(map[syntax.ATURI][]syntax.ATURI) 236 + 237 + for rows.Next() { 238 + var from, to syntax.ATURI 239 + if err := rows.Scan(&from, &to); err != nil { 240 + return nil, fmt.Errorf("scan row: %w", err) 241 + } 242 + 243 + result[from] = append(result[from], to) 244 + } 245 + if err := rows.Err(); err != nil { 246 + return nil, fmt.Errorf("iterate rows: %w", err) 247 + } 248 + 249 + return result, nil 250 + } 251 + 252 + func GetBacklinks(e Execer, target syntax.ATURI) ([]models.RichReferenceLink, error) { 253 + rows, err := e.Query( 254 + `select from_at from reference_links 255 + where to_at = ?`, 256 + target, 257 + ) 258 + if err != nil { 259 + return nil, fmt.Errorf("query backlinks: %w", err) 260 + } 261 + defer rows.Close() 262 + 263 + var ( 264 + backlinks []models.RichReferenceLink 265 + backlinksMap = make(map[string][]syntax.ATURI) 266 + ) 267 + for rows.Next() { 268 + var from syntax.ATURI 269 + if err := rows.Scan(&from); err != nil { 270 + return nil, fmt.Errorf("scan row: %w", err) 271 + } 272 + nsid := from.Collection().String() 273 + backlinksMap[nsid] = append(backlinksMap[nsid], from) 274 + } 275 + if err := rows.Err(); err != nil { 276 + return nil, fmt.Errorf("iterate rows: %w", err) 277 + } 278 + 279 + var ls []models.RichReferenceLink 280 + ls, err = getIssueBacklinks(e, backlinksMap[tangled.RepoIssueNSID]) 281 + if err != nil { 282 + return nil, fmt.Errorf("get issue backlinks: %w", err) 283 + } 284 + backlinks = append(backlinks, ls...) 285 + ls, err = getIssueCommentBacklinks(e, backlinksMap[tangled.RepoIssueCommentNSID]) 286 + if err != nil { 287 + return nil, fmt.Errorf("get issue_comment backlinks: %w", err) 288 + } 289 + backlinks = append(backlinks, ls...) 290 + ls, err = getPullBacklinks(e, backlinksMap[tangled.RepoPullNSID]) 291 + if err != nil { 292 + return nil, fmt.Errorf("get pull backlinks: %w", err) 293 + } 294 + backlinks = append(backlinks, ls...) 295 + ls, err = getPullCommentBacklinks(e, backlinksMap[tangled.RepoPullCommentNSID]) 296 + if err != nil { 297 + return nil, fmt.Errorf("get pull_comment backlinks: %w", err) 298 + } 299 + backlinks = append(backlinks, ls...) 300 + 301 + return backlinks, nil 302 + } 303 + 304 + func getIssueBacklinks(e Execer, aturis []syntax.ATURI) ([]models.RichReferenceLink, error) { 305 + if len(aturis) == 0 { 306 + return nil, nil 307 + } 308 + vals := make([]string, len(aturis)) 309 + args := make([]any, 0, len(aturis)*2) 310 + for i, aturi := range aturis { 311 + vals[i] = "(?, ?)" 312 + did := aturi.Authority().String() 313 + rkey := aturi.RecordKey().String() 314 + args = append(args, did, rkey) 315 + } 316 + rows, err := e.Query( 317 + fmt.Sprintf( 318 + `select r.did, r.name, i.issue_id, i.title, i.open 319 + from issues i 320 + join repos r 321 + on r.at_uri = i.repo_at 322 + where (i.did, i.rkey) in (%s)`, 323 + strings.Join(vals, ","), 324 + ), 325 + args..., 326 + ) 327 + if err != nil { 328 + return nil, err 329 + } 330 + defer rows.Close() 331 + var refLinks []models.RichReferenceLink 332 + for rows.Next() { 333 + var l models.RichReferenceLink 334 + l.Kind = models.RefKindIssue 335 + if err := rows.Scan(&l.Handle, &l.Repo, &l.SubjectId, &l.Title, &l.State); err != nil { 336 + return nil, err 337 + } 338 + refLinks = append(refLinks, l) 339 + } 340 + if err := rows.Err(); err != nil { 341 + return nil, fmt.Errorf("iterate rows: %w", err) 342 + } 343 + return refLinks, nil 344 + } 345 + 346 + func getIssueCommentBacklinks(e Execer, aturis []syntax.ATURI) ([]models.RichReferenceLink, error) { 347 + if len(aturis) == 0 { 348 + return nil, nil 349 + } 350 + filter := FilterIn("c.at_uri", aturis) 351 + rows, err := e.Query( 352 + fmt.Sprintf( 353 + `select r.did, r.name, i.issue_id, c.id, i.title, i.open 354 + from issue_comments c 355 + join issues i 356 + on i.at_uri = c.issue_at 357 + join repos r 358 + on r.at_uri = i.repo_at 359 + where %s`, 360 + filter.Condition(), 361 + ), 362 + filter.Arg()..., 363 + ) 364 + if err != nil { 365 + return nil, err 366 + } 367 + defer rows.Close() 368 + var refLinks []models.RichReferenceLink 369 + for rows.Next() { 370 + var l models.RichReferenceLink 371 + l.Kind = models.RefKindIssue 372 + l.CommentId = new(int) 373 + if err := rows.Scan(&l.Handle, &l.Repo, &l.SubjectId, l.CommentId, &l.Title, &l.State); err != nil { 374 + return nil, err 375 + } 376 + refLinks = append(refLinks, l) 377 + } 378 + if err := rows.Err(); err != nil { 379 + return nil, fmt.Errorf("iterate rows: %w", err) 380 + } 381 + return refLinks, nil 382 + } 383 + 384 + func getPullBacklinks(e Execer, aturis []syntax.ATURI) ([]models.RichReferenceLink, error) { 385 + if len(aturis) == 0 { 386 + return nil, nil 387 + } 388 + vals := make([]string, len(aturis)) 389 + args := make([]any, 0, len(aturis)*2) 390 + for i, aturi := range aturis { 391 + vals[i] = "(?, ?)" 392 + did := aturi.Authority().String() 393 + rkey := aturi.RecordKey().String() 394 + args = append(args, did, rkey) 395 + } 396 + rows, err := e.Query( 397 + fmt.Sprintf( 398 + `select r.did, r.name, p.pull_id, p.title, p.state 399 + from pulls p 400 + join repos r 401 + on r.at_uri = p.repo_at 402 + where (p.owner_did, p.rkey) in (%s)`, 403 + strings.Join(vals, ","), 404 + ), 405 + args..., 406 + ) 407 + if err != nil { 408 + return nil, err 409 + } 410 + defer rows.Close() 411 + var refLinks []models.RichReferenceLink 412 + for rows.Next() { 413 + var l models.RichReferenceLink 414 + l.Kind = models.RefKindPull 415 + if err := rows.Scan(&l.Handle, &l.Repo, &l.SubjectId, &l.Title, &l.State); err != nil { 416 + return nil, err 417 + } 418 + refLinks = append(refLinks, l) 419 + } 420 + if err := rows.Err(); err != nil { 421 + return nil, fmt.Errorf("iterate rows: %w", err) 422 + } 423 + return refLinks, nil 424 + } 425 + 426 + func getPullCommentBacklinks(e Execer, aturis []syntax.ATURI) ([]models.RichReferenceLink, error) { 427 + if len(aturis) == 0 { 428 + return nil, nil 429 + } 430 + filter := FilterIn("c.comment_at", aturis) 431 + rows, err := e.Query( 432 + fmt.Sprintf( 433 + `select r.did, r.name, p.pull_id, c.id, p.title, p.state 434 + from repos r 435 + join pulls p 436 + on r.at_uri = p.repo_at 437 + join pull_comments c 438 + on r.at_uri = c.repo_at and p.pull_id = c.pull_id 439 + where %s`, 440 + filter.Condition(), 441 + ), 442 + filter.Arg()..., 443 + ) 444 + if err != nil { 445 + return nil, err 446 + } 447 + defer rows.Close() 448 + var refLinks []models.RichReferenceLink 449 + for rows.Next() { 450 + var l models.RichReferenceLink 451 + l.Kind = models.RefKindPull 452 + l.CommentId = new(int) 453 + if err := rows.Scan(&l.Handle, &l.Repo, &l.SubjectId, l.CommentId, &l.Title, &l.State); err != nil { 454 + return nil, err 455 + } 456 + refLinks = append(refLinks, l) 457 + } 458 + if err := rows.Err(); err != nil { 459 + return nil, fmt.Errorf("iterate rows: %w", err) 460 + } 461 + return refLinks, nil 462 + }
+14 -31
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" 16 14 ) 17 - 18 - type Repo struct { 19 - Id int64 20 - Did string 21 - Name string 22 - Knot string 23 - Rkey string 24 - Created time.Time 25 - Description string 26 - Spindle string 27 - 28 - // optionally, populate this when querying for reverse mappings 29 - RepoStats *models.RepoStats 30 - 31 - // optional 32 - Source string 33 - } 34 - 35 - func (r Repo) RepoAt() syntax.ATURI { 36 - return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", r.Did, tangled.RepoNSID, r.Rkey)) 37 - } 38 - 39 - func (r Repo) DidSlashRepo() string { 40 - p, _ := securejoin.SecureJoin(r.Did, r.Name) 41 - return p 42 - } 43 15 44 16 func GetRepos(e Execer, limit int, filters ...filter) ([]models.Repo, error) { 45 17 repoMap := make(map[syntax.ATURI]*models.Repo) ··· 208 180 209 181 starCountQuery := fmt.Sprintf( 210 182 `select 211 - repo_at, count(1) 183 + subject_at, count(1) 212 184 from stars 213 - where repo_at in (%s) 214 - group by repo_at`, 185 + where subject_at in (%s) 186 + group by subject_at`, 215 187 inClause, 216 188 ) 217 189 rows, err = e.Query(starCountQuery, args...) ··· 437 409 return "", err 438 410 } 439 411 return nullableSource.String, nil 412 + } 413 + 414 + func GetRepoSourceRepo(e Execer, repoAt syntax.ATURI) (*models.Repo, error) { 415 + source, err := GetRepoSource(e, repoAt) 416 + if source == "" || errors.Is(err, sql.ErrNoRows) { 417 + return nil, nil 418 + } 419 + if err != nil { 420 + return nil, err 421 + } 422 + return GetRepoByAtUri(e, source) 440 423 } 441 424 442 425 func GetForksByDid(e Execer, did string) ([]models.Repo, error) {
+39 -99
appview/db/star.go
··· 14 14 ) 15 15 16 16 func AddStar(e Execer, star *models.Star) error { 17 - query := `insert or ignore into stars (starred_by_did, repo_at, rkey) values (?, ?, ?)` 17 + query := `insert or ignore into stars (did, subject_at, rkey) values (?, ?, ?)` 18 18 _, err := e.Exec( 19 19 query, 20 - star.StarredByDid, 20 + star.Did, 21 21 star.RepoAt.String(), 22 22 star.Rkey, 23 23 ) ··· 25 25 } 26 26 27 27 // Get a star record 28 - func GetStar(e Execer, starredByDid string, repoAt syntax.ATURI) (*models.Star, error) { 28 + func GetStar(e Execer, did string, subjectAt syntax.ATURI) (*models.Star, error) { 29 29 query := ` 30 - select starred_by_did, repo_at, created, rkey 30 + select did, subject_at, created, rkey 31 31 from stars 32 - where starred_by_did = ? and repo_at = ?` 33 - row := e.QueryRow(query, starredByDid, repoAt) 32 + where did = ? and subject_at = ?` 33 + row := e.QueryRow(query, did, subjectAt) 34 34 35 35 var star models.Star 36 36 var created string 37 - err := row.Scan(&star.StarredByDid, &star.RepoAt, &created, &star.Rkey) 37 + err := row.Scan(&star.Did, &star.RepoAt, &created, &star.Rkey) 38 38 if err != nil { 39 39 return nil, err 40 40 } ··· 51 51 } 52 52 53 53 // 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) 54 + func DeleteStar(e Execer, did string, subjectAt syntax.ATURI) error { 55 + _, err := e.Exec(`delete from stars where did = ? and subject_at = ?`, did, subjectAt) 56 56 return err 57 57 } 58 58 59 59 // 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) 60 + func DeleteStarByRkey(e Execer, did string, rkey string) error { 61 + _, err := e.Exec(`delete from stars where did = ? and rkey = ?`, did, rkey) 62 62 return err 63 63 } 64 64 65 - func GetStarCount(e Execer, repoAt syntax.ATURI) (int, error) { 65 + func GetStarCount(e Execer, subjectAt syntax.ATURI) (int, error) { 66 66 stars := 0 67 67 err := e.QueryRow( 68 - `select count(starred_by_did) from stars where repo_at = ?`, repoAt).Scan(&stars) 68 + `select count(did) from stars where subject_at = ?`, subjectAt).Scan(&stars) 69 69 if err != nil { 70 70 return 0, err 71 71 } ··· 89 89 } 90 90 91 91 query := fmt.Sprintf(` 92 - SELECT repo_at 92 + SELECT subject_at 93 93 FROM stars 94 - WHERE starred_by_did = ? AND repo_at IN (%s) 94 + WHERE did = ? AND subject_at IN (%s) 95 95 `, strings.Join(placeholders, ",")) 96 96 97 97 rows, err := e.Query(query, args...) ··· 118 118 return result, nil 119 119 } 120 120 121 - func GetStarStatus(e Execer, userDid string, repoAt syntax.ATURI) bool { 122 - statuses, err := getStarStatuses(e, userDid, []syntax.ATURI{repoAt}) 121 + func GetStarStatus(e Execer, userDid string, subjectAt syntax.ATURI) bool { 122 + statuses, err := getStarStatuses(e, userDid, []syntax.ATURI{subjectAt}) 123 123 if err != nil { 124 124 return false 125 125 } 126 - return statuses[repoAt.String()] 126 + return statuses[subjectAt.String()] 127 127 } 128 128 129 129 // 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) 130 + func GetStarStatuses(e Execer, userDid string, subjectAts []syntax.ATURI) (map[string]bool, error) { 131 + return getStarStatuses(e, userDid, subjectAts) 132 132 } 133 - func GetStars(e Execer, limit int, filters ...filter) ([]models.Star, error) { 133 + 134 + // GetRepoStars return a list of stars each holding target repository. 135 + // If there isn't known repo with starred at-uri, those stars will be ignored. 136 + func GetRepoStars(e Execer, limit int, filters ...filter) ([]models.RepoStar, error) { 134 137 var conditions []string 135 138 var args []any 136 139 for _, filter := range filters { ··· 149 152 } 150 153 151 154 repoQuery := fmt.Sprintf( 152 - `select starred_by_did, repo_at, created, rkey 155 + `select did, subject_at, created, rkey 153 156 from stars 154 157 %s 155 158 order by created desc ··· 166 169 for rows.Next() { 167 170 var star models.Star 168 171 var created string 169 - err := rows.Scan(&star.StarredByDid, &star.RepoAt, &created, &star.Rkey) 172 + err := rows.Scan(&star.Did, &star.RepoAt, &created, &star.Rkey) 170 173 if err != nil { 171 174 return nil, err 172 175 } ··· 197 200 return nil, err 198 201 } 199 202 203 + var repoStars []models.RepoStar 200 204 for _, r := range repos { 201 205 if stars, ok := starMap[string(r.RepoAt())]; ok { 202 - for i := range stars { 203 - stars[i].Repo = &r 206 + for _, star := range stars { 207 + repoStars = append(repoStars, models.RepoStar{ 208 + Star: star, 209 + Repo: &r, 210 + }) 204 211 } 205 212 } 206 213 } 207 214 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 { 215 + slices.SortFunc(repoStars, func(a, b models.RepoStar) int { 214 216 if a.Created.After(b.Created) { 215 217 return -1 216 218 } ··· 220 222 return 0 221 223 }) 222 224 223 - return stars, nil 225 + return repoStars, nil 224 226 } 225 227 226 228 func CountStars(e Execer, filters ...filter) (int64, error) { ··· 247 249 return count, nil 248 250 } 249 251 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 252 // GetTopStarredReposLastWeek returns the top 8 most starred repositories from the last week 313 253 func GetTopStarredReposLastWeek(e Execer) ([]models.Repo, error) { 314 254 // first, get the top repo URIs by star count from the last week 315 255 query := ` 316 256 with recent_starred_repos as ( 317 - select distinct repo_at 257 + select distinct subject_at 318 258 from stars 319 259 where created >= datetime('now', '-7 days') 320 260 ), 321 261 repo_star_counts as ( 322 262 select 323 - s.repo_at, 263 + s.subject_at, 324 264 count(*) as stars_gained_last_week 325 265 from stars s 326 - join recent_starred_repos rsr on s.repo_at = rsr.repo_at 266 + join recent_starred_repos rsr on s.subject_at = rsr.subject_at 327 267 where s.created >= datetime('now', '-7 days') 328 - group by s.repo_at 268 + group by s.subject_at 329 269 ) 330 - select rsc.repo_at 270 + select rsc.subject_at 331 271 from repo_star_counts rsc 332 272 order by rsc.stars_gained_last_week desc 333 273 limit 8
+3 -13
appview/db/timeline.go
··· 146 146 func getTimelineStars(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) { 147 147 filters := make([]filter, 0) 148 148 if userIsFollowing != nil { 149 - filters = append(filters, FilterIn("starred_by_did", userIsFollowing)) 149 + filters = append(filters, FilterIn("did", userIsFollowing)) 150 150 } 151 151 152 - stars, err := GetStars(e, limit, filters...) 152 + stars, err := GetRepoStars(e, limit, filters...) 153 153 if err != nil { 154 154 return nil, err 155 155 } 156 156 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 157 var repos []models.Repo 168 158 for _, s := range stars { 169 159 repos = append(repos, *s.Repo) ··· 179 169 isStarred, starCount := getRepoStarInfo(s.Repo, starStatuses) 180 170 181 171 events = append(events, models.TimelineEvent{ 182 - Star: &s, 172 + RepoStar: &s, 183 173 EventAt: s.Created, 184 174 IsStarred: isStarred, 185 175 StarCount: starCount,
+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 + }
+3 -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) {
+1 -1
appview/indexer/notifier.go
··· 11 11 12 12 var _ notify.Notifier = &Indexer{} 13 13 14 - func (ix *Indexer) NewIssue(ctx context.Context, issue *models.Issue) { 14 + func (ix *Indexer) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) { 15 15 l := log.FromContext(ctx).With("notifier", "indexer", "issue", issue) 16 16 l.Debug("indexing new issue") 17 17 err := ix.Issues.Index(ctx, *issue)
+3 -1
appview/indexer/pulls/indexer.go
··· 55 55 log.Fatalln("failed to populate pull indexer", err) 56 56 } 57 57 } 58 - l.Info("Initialized the pull indexer") 58 + 59 + count, _ := ix.indexer.DocCount() 60 + l.Info("Initialized the pull indexer", "docCount", count) 59 61 } 60 62 61 63 func generatePullIndexMapping() (mapping.IndexMapping, error) {
+25 -8
appview/ingester.go
··· 121 121 return err 122 122 } 123 123 err = db.AddStar(i.Db, &models.Star{ 124 - StarredByDid: did, 125 - RepoAt: subjectUri, 126 - Rkey: e.Commit.RKey, 124 + Did: did, 125 + RepoAt: subjectUri, 126 + Rkey: e.Commit.RKey, 127 127 }) 128 128 case jmodels.CommitOperationDelete: 129 129 err = db.DeleteStarByRkey(i.Db, did, e.Commit.RKey) ··· 841 841 return nil 842 842 843 843 case jmodels.CommitOperationDelete: 844 + tx, err := ddb.BeginTx(ctx, nil) 845 + if err != nil { 846 + l.Error("failed to begin transaction", "err", err) 847 + return err 848 + } 849 + defer tx.Rollback() 850 + 844 851 if err := db.DeleteIssues( 845 - ddb, 846 - db.FilterEq("did", did), 847 - db.FilterEq("rkey", rkey), 852 + tx, 853 + did, 854 + rkey, 848 855 ); err != nil { 849 856 l.Error("failed to delete", "err", err) 850 857 return fmt.Errorf("failed to delete issue record: %w", err) 858 + } 859 + if err := tx.Commit(); err != nil { 860 + l.Error("failed to commit txn", "err", err) 861 + return err 851 862 } 852 863 853 864 return nil ··· 888 899 return fmt.Errorf("failed to validate comment: %w", err) 889 900 } 890 901 891 - _, err = db.AddIssueComment(ddb, *comment) 902 + tx, err := ddb.Begin() 903 + if err != nil { 904 + return fmt.Errorf("failed to start transaction: %w", err) 905 + } 906 + defer tx.Rollback() 907 + 908 + _, err = db.AddIssueComment(tx, *comment) 892 909 if err != nil { 893 910 return fmt.Errorf("failed to create issue comment: %w", err) 894 911 } 895 912 896 - return nil 913 + return tx.Commit() 897 914 898 915 case jmodels.CommitOperationDelete: 899 916 if err := db.DeleteIssueComments(
+147 -101
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" ··· 24 23 "tangled.org/core/appview/notify" 25 24 "tangled.org/core/appview/oauth" 26 25 "tangled.org/core/appview/pages" 26 + "tangled.org/core/appview/pages/repoinfo" 27 27 "tangled.org/core/appview/pagination" 28 + "tangled.org/core/appview/refresolver" 28 29 "tangled.org/core/appview/reporesolver" 29 30 "tangled.org/core/appview/validator" 30 31 "tangled.org/core/idresolver" 32 + "tangled.org/core/rbac" 31 33 "tangled.org/core/tid" 32 34 ) 33 35 34 36 type Issues struct { 35 37 oauth *oauth.OAuth 36 38 repoResolver *reporesolver.RepoResolver 39 + enforcer *rbac.Enforcer 37 40 pages *pages.Pages 38 41 idResolver *idresolver.Resolver 42 + refResolver *refresolver.Resolver 39 43 db *db.DB 40 44 config *config.Config 41 45 notifier notify.Notifier ··· 47 51 func New( 48 52 oauth *oauth.OAuth, 49 53 repoResolver *reporesolver.RepoResolver, 54 + enforcer *rbac.Enforcer, 50 55 pages *pages.Pages, 51 56 idResolver *idresolver.Resolver, 57 + refResolver *refresolver.Resolver, 52 58 db *db.DB, 53 59 config *config.Config, 54 60 notifier notify.Notifier, ··· 59 65 return &Issues{ 60 66 oauth: oauth, 61 67 repoResolver: repoResolver, 68 + enforcer: enforcer, 62 69 pages: pages, 63 70 idResolver: idResolver, 71 + refResolver: refResolver, 64 72 db: db, 65 73 config: config, 66 74 notifier: notifier, ··· 96 104 userReactions = db.GetReactionStatusMap(rp.db, user.Did, issue.AtUri()) 97 105 } 98 106 107 + backlinks, err := db.GetBacklinks(rp.db, issue.AtUri()) 108 + if err != nil { 109 + l.Error("failed to fetch backlinks", "err", err) 110 + rp.pages.Error503(w) 111 + return 112 + } 113 + 99 114 labelDefs, err := db.GetLabelDefinitions( 100 115 rp.db, 101 - db.FilterIn("at_uri", f.Repo.Labels), 116 + db.FilterIn("at_uri", f.Labels), 102 117 db.FilterContains("scope", tangled.RepoIssueNSID), 103 118 ) 104 119 if err != nil { ··· 114 129 115 130 rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{ 116 131 LoggedInUser: user, 117 - RepoInfo: f.RepoInfo(user), 132 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 118 133 Issue: issue, 119 134 CommentList: issue.CommentList(), 135 + Backlinks: backlinks, 120 136 OrderedReactionKinds: models.OrderedReactionKinds, 121 137 Reactions: reactionMap, 122 138 UserReacted: userReactions, ··· 127 143 func (rp *Issues) EditIssue(w http.ResponseWriter, r *http.Request) { 128 144 l := rp.logger.With("handler", "EditIssue") 129 145 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 146 136 147 issue, ok := r.Context().Value("issue").(*models.Issue) 137 148 if !ok { ··· 144 155 case http.MethodGet: 145 156 rp.pages.EditIssueFragment(w, pages.EditIssueParams{ 146 157 LoggedInUser: user, 147 - RepoInfo: f.RepoInfo(user), 158 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 148 159 Issue: issue, 149 160 }) 150 161 case http.MethodPost: ··· 152 163 newIssue := issue 153 164 newIssue.Title = r.FormValue("title") 154 165 newIssue.Body = r.FormValue("body") 166 + newIssue.Mentions, newIssue.References = rp.refResolver.Resolve(r.Context(), newIssue.Body) 155 167 156 168 if err := rp.validator.ValidateIssue(newIssue); err != nil { 157 169 l.Error("validation error", "err", err) ··· 221 233 l := rp.logger.With("handler", "DeleteIssue") 222 234 noticeId := "issue-actions-error" 223 235 224 - user := rp.oauth.GetUser(r) 225 - 226 236 f, err := rp.repoResolver.Resolve(r) 227 237 if err != nil { 228 238 l.Error("failed to get repo and knot", "err", err) ··· 237 247 } 238 248 l = l.With("did", issue.Did, "rkey", issue.Rkey) 239 249 250 + tx, err := rp.db.Begin() 251 + if err != nil { 252 + l.Error("failed to start transaction", "err", err) 253 + rp.pages.Notice(w, "issue-comment", "Failed to create comment, try again later.") 254 + return 255 + } 256 + defer tx.Rollback() 257 + 240 258 // delete from PDS 241 259 client, err := rp.oauth.AuthorizedClient(r) 242 260 if err != nil { ··· 257 275 } 258 276 259 277 // delete from db 260 - if err := db.DeleteIssues(rp.db, db.FilterEq("id", issue.Id)); err != nil { 278 + if err := db.DeleteIssues(tx, issue.Did, issue.Rkey); err != nil { 261 279 l.Error("failed to delete issue", "err", err) 262 280 rp.pages.Notice(w, noticeId, "Failed to delete issue.") 263 281 return 264 282 } 283 + tx.Commit() 265 284 266 285 rp.notifier.DeleteIssue(r.Context(), issue) 267 286 268 287 // return to all issues page 269 - rp.pages.HxRedirect(w, "/"+f.RepoInfo(user).FullName()+"/issues") 288 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 289 + rp.pages.HxRedirect(w, "/"+ownerSlashRepo+"/issues") 270 290 } 271 291 272 292 func (rp *Issues) CloseIssue(w http.ResponseWriter, r *http.Request) { ··· 285 305 return 286 306 } 287 307 288 - collaborators, err := f.Collaborators(r.Context()) 289 - if err != nil { 290 - l.Error("failed to fetch repo collaborators", "err", err) 291 - } 292 - isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 293 - return user.Did == collab.Did 294 - }) 308 + roles := repoinfo.RolesInRepo{Roles: rp.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())} 309 + isRepoOwner := roles.IsOwner() 310 + isCollaborator := roles.IsCollaborator() 295 311 isIssueOwner := user.Did == issue.Did 296 312 297 313 // TODO: make this more granular 298 - if isIssueOwner || isCollaborator { 314 + if isIssueOwner || isRepoOwner || isCollaborator { 299 315 err = db.CloseIssues( 300 316 rp.db, 301 317 db.FilterEq("id", issue.Id), ··· 311 327 // notify about the issue closure 312 328 rp.notifier.NewIssueState(r.Context(), syntax.DID(user.Did), issue) 313 329 314 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 330 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 331 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId)) 315 332 return 316 333 } else { 317 334 l.Error("user is not permitted to close issue") ··· 336 353 return 337 354 } 338 355 339 - collaborators, err := f.Collaborators(r.Context()) 340 - if err != nil { 341 - l.Error("failed to fetch repo collaborators", "err", err) 342 - } 343 - isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 344 - return user.Did == collab.Did 345 - }) 356 + roles := repoinfo.RolesInRepo{Roles: rp.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())} 357 + isRepoOwner := roles.IsOwner() 358 + isCollaborator := roles.IsCollaborator() 346 359 isIssueOwner := user.Did == issue.Did 347 360 348 - if isCollaborator || isIssueOwner { 361 + if isCollaborator || isRepoOwner || isIssueOwner { 349 362 err := db.ReopenIssues( 350 363 rp.db, 351 364 db.FilterEq("id", issue.Id), ··· 361 374 // notify about the issue reopen 362 375 rp.notifier.NewIssueState(r.Context(), syntax.DID(user.Did), issue) 363 376 364 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 377 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 378 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId)) 365 379 return 366 380 } else { 367 381 l.Error("user is not the owner of the repo") ··· 397 411 if replyToUri != "" { 398 412 replyTo = &replyToUri 399 413 } 414 + 415 + mentions, references := rp.refResolver.Resolve(r.Context(), body) 400 416 401 417 comment := models.IssueComment{ 402 - Did: user.Did, 403 - Rkey: tid.TID(), 404 - IssueAt: issue.AtUri().String(), 405 - ReplyTo: replyTo, 406 - Body: body, 407 - Created: time.Now(), 418 + Did: user.Did, 419 + Rkey: tid.TID(), 420 + IssueAt: issue.AtUri().String(), 421 + ReplyTo: replyTo, 422 + Body: body, 423 + Created: time.Now(), 424 + Mentions: mentions, 425 + References: references, 408 426 } 409 427 if err = rp.validator.ValidateIssueComment(&comment); err != nil { 410 428 l.Error("failed to validate comment", "err", err) ··· 441 459 } 442 460 }() 443 461 444 - commentId, err := db.AddIssueComment(rp.db, comment) 462 + tx, err := rp.db.Begin() 463 + if err != nil { 464 + l.Error("failed to start transaction", "err", err) 465 + rp.pages.Notice(w, "issue-comment", "Failed to create comment, try again later.") 466 + return 467 + } 468 + defer tx.Rollback() 469 + 470 + commentId, err := db.AddIssueComment(tx, comment) 445 471 if err != nil { 446 472 l.Error("failed to create comment", "err", err) 447 473 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 448 474 return 449 475 } 476 + err = tx.Commit() 477 + if err != nil { 478 + l.Error("failed to commit transaction", "err", err) 479 + rp.pages.Notice(w, "issue-comment", "Failed to create comment, try again later.") 480 + return 481 + } 450 482 451 483 // reset atUri to make rollback a no-op 452 484 atUri = "" 453 485 454 486 // notify about the new comment 455 487 comment.Id = commentId 456 - rp.notifier.NewIssueComment(r.Context(), &comment) 488 + 489 + rp.notifier.NewIssueComment(r.Context(), &comment, mentions) 457 490 458 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issue.IssueId, commentId)) 491 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 492 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", ownerSlashRepo, issue.IssueId, commentId)) 459 493 } 460 494 461 495 func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) { 462 496 l := rp.logger.With("handler", "IssueComment") 463 497 user := rp.oauth.GetUser(r) 464 - f, err := rp.repoResolver.Resolve(r) 465 - if err != nil { 466 - l.Error("failed to get repo and knot", "err", err) 467 - return 468 - } 469 498 470 499 issue, ok := r.Context().Value("issue").(*models.Issue) 471 500 if !ok { ··· 493 522 494 523 rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 495 524 LoggedInUser: user, 496 - RepoInfo: f.RepoInfo(user), 525 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 497 526 Issue: issue, 498 527 Comment: &comment, 499 528 }) ··· 502 531 func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) { 503 532 l := rp.logger.With("handler", "EditIssueComment") 504 533 user := rp.oauth.GetUser(r) 505 - f, err := rp.repoResolver.Resolve(r) 506 - if err != nil { 507 - l.Error("failed to get repo and knot", "err", err) 508 - return 509 - } 510 534 511 535 issue, ok := r.Context().Value("issue").(*models.Issue) 512 536 if !ok { ··· 542 566 case http.MethodGet: 543 567 rp.pages.EditIssueCommentFragment(w, pages.EditIssueCommentParams{ 544 568 LoggedInUser: user, 545 - RepoInfo: f.RepoInfo(user), 569 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 546 570 Issue: issue, 547 571 Comment: &comment, 548 572 }) ··· 560 584 newComment := comment 561 585 newComment.Body = newBody 562 586 newComment.Edited = &now 587 + newComment.Mentions, newComment.References = rp.refResolver.Resolve(r.Context(), newBody) 588 + 563 589 record := newComment.AsRecord() 564 590 565 - _, err = db.AddIssueComment(rp.db, newComment) 591 + tx, err := rp.db.Begin() 592 + if err != nil { 593 + l.Error("failed to start transaction", "err", err) 594 + rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 595 + return 596 + } 597 + defer tx.Rollback() 598 + 599 + _, err = db.AddIssueComment(tx, newComment) 566 600 if err != nil { 567 601 l.Error("failed to perferom update-description query", "err", err) 568 602 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 569 603 return 570 604 } 605 + tx.Commit() 571 606 572 607 // rkey is optional, it was introduced later 573 608 if newComment.Rkey != "" { ··· 596 631 // return new comment body with htmx 597 632 rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 598 633 LoggedInUser: user, 599 - RepoInfo: f.RepoInfo(user), 634 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 600 635 Issue: issue, 601 636 Comment: &newComment, 602 637 }) ··· 606 641 func (rp *Issues) ReplyIssueCommentPlaceholder(w http.ResponseWriter, r *http.Request) { 607 642 l := rp.logger.With("handler", "ReplyIssueCommentPlaceholder") 608 643 user := rp.oauth.GetUser(r) 609 - f, err := rp.repoResolver.Resolve(r) 610 - if err != nil { 611 - l.Error("failed to get repo and knot", "err", err) 612 - return 613 - } 614 644 615 645 issue, ok := r.Context().Value("issue").(*models.Issue) 616 646 if !ok { ··· 638 668 639 669 rp.pages.ReplyIssueCommentPlaceholderFragment(w, pages.ReplyIssueCommentPlaceholderParams{ 640 670 LoggedInUser: user, 641 - RepoInfo: f.RepoInfo(user), 671 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 642 672 Issue: issue, 643 673 Comment: &comment, 644 674 }) ··· 647 677 func (rp *Issues) ReplyIssueComment(w http.ResponseWriter, r *http.Request) { 648 678 l := rp.logger.With("handler", "ReplyIssueComment") 649 679 user := rp.oauth.GetUser(r) 650 - f, err := rp.repoResolver.Resolve(r) 651 - if err != nil { 652 - l.Error("failed to get repo and knot", "err", err) 653 - return 654 - } 655 680 656 681 issue, ok := r.Context().Value("issue").(*models.Issue) 657 682 if !ok { ··· 679 704 680 705 rp.pages.ReplyIssueCommentFragment(w, pages.ReplyIssueCommentParams{ 681 706 LoggedInUser: user, 682 - RepoInfo: f.RepoInfo(user), 707 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 683 708 Issue: issue, 684 709 Comment: &comment, 685 710 }) ··· 688 713 func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { 689 714 l := rp.logger.With("handler", "DeleteIssueComment") 690 715 user := rp.oauth.GetUser(r) 691 - f, err := rp.repoResolver.Resolve(r) 692 - if err != nil { 693 - l.Error("failed to get repo and knot", "err", err) 694 - return 695 - } 696 716 697 717 issue, ok := r.Context().Value("issue").(*models.Issue) 698 718 if !ok { ··· 763 783 // htmx fragment of comment after deletion 764 784 rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 765 785 LoggedInUser: user, 766 - RepoInfo: f.RepoInfo(user), 786 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 767 787 Issue: issue, 768 788 Comment: &comment, 769 789 }) ··· 793 813 return 794 814 } 795 815 816 + totalIssues := 0 817 + if isOpen { 818 + totalIssues = f.RepoStats.IssueCount.Open 819 + } else { 820 + totalIssues = f.RepoStats.IssueCount.Closed 821 + } 822 + 796 823 keyword := params.Get("q") 797 824 798 - var ids []int64 825 + var issues []models.Issue 799 826 searchOpts := models.IssueSearchOptions{ 800 827 Keyword: keyword, 801 828 RepoAt: f.RepoAt().String(), ··· 808 835 l.Error("failed to search for issues", "err", err) 809 836 return 810 837 } 811 - ids = res.Hits 812 - l.Debug("searched issues with indexer", "count", len(ids)) 813 - } else { 814 - ids, err = db.GetIssueIDs(rp.db, searchOpts) 838 + l.Debug("searched issues with indexer", "count", len(res.Hits)) 839 + totalIssues = int(res.Total) 840 + 841 + issues, err = db.GetIssues( 842 + rp.db, 843 + db.FilterIn("id", res.Hits), 844 + ) 815 845 if err != nil { 816 - l.Error("failed to search for issues", "err", err) 846 + l.Error("failed to get issues", "err", err) 847 + rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 817 848 return 818 849 } 819 - l.Debug("indexed all issues from the db", "count", len(ids)) 820 - } 821 850 822 - issues, err := db.GetIssues( 823 - rp.db, 824 - db.FilterIn("id", ids), 825 - ) 826 - if err != nil { 827 - l.Error("failed to get issues", "err", err) 828 - rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 829 - return 851 + } else { 852 + openInt := 0 853 + if isOpen { 854 + openInt = 1 855 + } 856 + issues, err = db.GetIssuesPaginated( 857 + rp.db, 858 + page, 859 + db.FilterEq("repo_at", f.RepoAt()), 860 + db.FilterEq("open", openInt), 861 + ) 862 + if err != nil { 863 + l.Error("failed to get issues", "err", err) 864 + rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 865 + return 866 + } 830 867 } 831 868 832 869 labelDefs, err := db.GetLabelDefinitions( 833 870 rp.db, 834 - db.FilterIn("at_uri", f.Repo.Labels), 871 + db.FilterIn("at_uri", f.Labels), 835 872 db.FilterContains("scope", tangled.RepoIssueNSID), 836 873 ) 837 874 if err != nil { ··· 847 884 848 885 rp.pages.RepoIssues(w, pages.RepoIssuesParams{ 849 886 LoggedInUser: rp.oauth.GetUser(r), 850 - RepoInfo: f.RepoInfo(user), 887 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 851 888 Issues: issues, 889 + IssueCount: totalIssues, 852 890 LabelDefs: defs, 853 891 FilteringByOpen: isOpen, 854 892 FilterQuery: keyword, ··· 870 908 case http.MethodGet: 871 909 rp.pages.RepoNewIssue(w, pages.RepoNewIssueParams{ 872 910 LoggedInUser: user, 873 - RepoInfo: f.RepoInfo(user), 911 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 874 912 }) 875 913 case http.MethodPost: 914 + body := r.FormValue("body") 915 + mentions, references := rp.refResolver.Resolve(r.Context(), body) 916 + 876 917 issue := &models.Issue{ 877 - RepoAt: f.RepoAt(), 878 - Rkey: tid.TID(), 879 - Title: r.FormValue("title"), 880 - Body: r.FormValue("body"), 881 - Open: true, 882 - Did: user.Did, 883 - Created: time.Now(), 884 - Repo: &f.Repo, 918 + RepoAt: f.RepoAt(), 919 + Rkey: tid.TID(), 920 + Title: r.FormValue("title"), 921 + Body: body, 922 + Open: true, 923 + Did: user.Did, 924 + Created: time.Now(), 925 + Mentions: mentions, 926 + References: references, 927 + Repo: f, 885 928 } 886 929 887 930 if err := rp.validator.ValidateIssue(issue); err != nil { ··· 948 991 949 992 // everything is successful, do not rollback the atproto record 950 993 atUri = "" 951 - rp.notifier.NewIssue(r.Context(), issue) 952 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 994 + 995 + rp.notifier.NewIssue(r.Context(), issue, mentions) 996 + 997 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 998 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId)) 953 999 return 954 1000 } 955 1001 }
+3 -3
appview/issues/opengraph.go
··· 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)
+18 -1
appview/knots/knots.go
··· 39 39 Knotstream *eventconsumer.Consumer 40 40 } 41 41 42 + type tab = map[string]any 43 + 44 + var ( 45 + knotsTabs []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 + 42 55 func (k *Knots) Router() http.Handler { 43 56 r := chi.NewRouter() 44 57 ··· 70 83 k.Pages.Knots(w, pages.KnotsParams{ 71 84 LoggedInUser: user, 72 85 Registrations: registrations, 86 + Tabs: knotsTabs, 87 + Tab: "knots", 73 88 }) 74 89 } 75 90 ··· 132 147 Members: members, 133 148 Repos: repoMap, 134 149 IsOwner: true, 150 + Tabs: knotsTabs, 151 + Tab: "knots", 135 152 }) 136 153 } 137 154 ··· 596 613 } 597 614 598 615 // success 599 - k.Pages.HxRedirect(w, fmt.Sprintf("/knots/%s", domain)) 616 + k.Pages.HxRedirect(w, fmt.Sprintf("/settings/knots/%s", domain)) 600 617 } 601 618 602 619 func (k *Knots) removeMember(w http.ResponseWriter, r *http.Request) {
+4 -2
appview/middleware/middleware.go
··· 164 164 ok, err := mw.enforcer.E.Enforce(actor.Did, f.Knot, f.DidSlashRepo(), requiredPerm) 165 165 if err != nil || !ok { 166 166 // 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()) 167 + log.Printf("%s does not have perms of a %s in repo %s", actor.Did, requiredPerm, f.DidSlashRepo()) 168 168 http.Error(w, "Forbiden", http.StatusUnauthorized) 169 169 return 170 170 } ··· 206 206 return func(next http.Handler) http.Handler { 207 207 return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 208 208 repoName := chi.URLParam(req, "repo") 209 + repoName = strings.TrimSuffix(repoName, ".git") 210 + 209 211 id, ok := req.Context().Value("resolvedId").(identity.Identity) 210 212 if !ok { 211 213 log.Println("malformed middleware") ··· 325 327 return 326 328 } 327 329 328 - fullName := f.OwnerHandle() + "/" + f.Name 330 + fullName := reporesolver.GetBaseRepoPath(r, f) 329 331 330 332 if r.Header.Get("User-Agent") == "Go-http-client/1.1" { 331 333 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 {
+7
appview/models/notifications.go
··· 20 20 NotificationTypeIssueReopen NotificationType = "issue_reopen" 21 21 NotificationTypePullClosed NotificationType = "pull_closed" 22 22 NotificationTypePullReopen NotificationType = "pull_reopen" 23 + NotificationTypeUserMentioned NotificationType = "user_mentioned" 23 24 ) 24 25 25 26 type Notification struct { ··· 63 64 return "git-pull-request-create" 64 65 case NotificationTypeFollowed: 65 66 return "user-plus" 67 + case NotificationTypeUserMentioned: 68 + return "at-sign" 66 69 default: 67 70 return "" 68 71 } ··· 84 87 PullCreated bool 85 88 PullCommented bool 86 89 Followed bool 90 + UserMentioned bool 87 91 PullMerged bool 88 92 IssueClosed bool 89 93 EmailNotifications bool ··· 113 117 return prefs.PullCreated // same pref for now 114 118 case NotificationTypeFollowed: 115 119 return prefs.Followed 120 + case NotificationTypeUserMentioned: 121 + return prefs.UserMentioned 116 122 default: 117 123 return false 118 124 } ··· 127 133 PullCreated: true, 128 134 PullCommented: true, 129 135 Followed: true, 136 + UserMentioned: true, 130 137 PullMerged: true, 131 138 IssueClosed: true, 132 139 EmailNotifications: false,
+3 -1
appview/models/profile.go
··· 111 111 } 112 112 113 113 type ByMonth struct { 114 + Commits int 114 115 RepoEvents []RepoEvent 115 116 IssueEvents IssueEvents 116 117 PullEvents PullEvents ··· 119 120 func (b ByMonth) IsEmpty() bool { 120 121 return len(b.RepoEvents) == 0 && 121 122 len(b.IssueEvents.Items) == 0 && 122 - len(b.PullEvents.Items) == 0 123 + len(b.PullEvents.Items) == 0 && 124 + b.Commits == 0 123 125 } 124 126 125 127 type IssueEvents struct {
+41 -3
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 ··· 92 94 source.Repo = &s 93 95 } 94 96 } 97 + mentions := make([]string, len(p.Mentions)) 98 + for i, did := range p.Mentions { 99 + mentions[i] = string(did) 100 + } 101 + references := make([]string, len(p.References)) 102 + for i, uri := range p.References { 103 + references[i] = string(uri) 104 + } 95 105 96 106 record := tangled.RepoPull{ 97 - Title: p.Title, 98 - Body: &p.Body, 99 - CreatedAt: p.Created.Format(time.RFC3339), 107 + Title: p.Title, 108 + Body: &p.Body, 109 + Mentions: mentions, 110 + References: references, 111 + CreatedAt: p.Created.Format(time.RFC3339), 100 112 Target: &tangled.RepoPull_Target{ 101 113 Repo: p.RepoAt.String(), 102 114 Branch: p.TargetBranch, ··· 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 } 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 + // } 153 191 154 192 func (p *Pull) LastRoundNumber() int { 155 193 return len(p.Submissions) - 1
+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 + }
+47
appview/models/repo.go
··· 104 104 Repo *Repo 105 105 Issues []Issue 106 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 + }
+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
+48 -8
appview/notify/db/db.go
··· 7 7 "slices" 8 8 9 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 + "tangled.org/core/api/tangled" 10 11 "tangled.org/core/appview/db" 11 12 "tangled.org/core/appview/models" 12 13 "tangled.org/core/appview/notify" 13 14 "tangled.org/core/idresolver" 15 + ) 16 + 17 + const ( 18 + maxMentions = 5 14 19 ) 15 20 16 21 type databaseNotifier struct { ··· 32 37 } 33 38 34 39 func (n *databaseNotifier) NewStar(ctx context.Context, star *models.Star) { 40 + if star.RepoAt.Collection().String() != tangled.RepoNSID { 41 + // skip string stars for now 42 + return 43 + } 35 44 var err error 36 45 repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(star.RepoAt))) 37 46 if err != nil { ··· 39 48 return 40 49 } 41 50 42 - actorDid := syntax.DID(star.StarredByDid) 51 + actorDid := syntax.DID(star.Did) 43 52 recipients := []syntax.DID{syntax.DID(repo.Did)} 44 53 eventType := models.NotificationTypeRepoStarred 45 54 entityType := "repo" ··· 64 73 // no-op 65 74 } 66 75 67 - func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue) { 76 + func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) { 68 77 69 78 // build the recipients list 70 79 // - owner of the repo ··· 81 90 } 82 91 83 92 actorDid := syntax.DID(issue.Did) 84 - eventType := models.NotificationTypeIssueCreated 85 93 entityType := "issue" 86 94 entityId := issue.AtUri().String() 87 95 repoId := &issue.Repo.Id ··· 91 99 n.notifyEvent( 92 100 actorDid, 93 101 recipients, 94 - eventType, 102 + models.NotificationTypeIssueCreated, 103 + entityType, 104 + entityId, 105 + repoId, 106 + issueId, 107 + pullId, 108 + ) 109 + n.notifyEvent( 110 + actorDid, 111 + mentions, 112 + models.NotificationTypeUserMentioned, 95 113 entityType, 96 114 entityId, 97 115 repoId, ··· 100 118 ) 101 119 } 102 120 103 - func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) { 121 + func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { 104 122 issues, err := db.GetIssues(n.db, db.FilterEq("at_uri", comment.IssueAt)) 105 123 if err != nil { 106 124 log.Printf("NewIssueComment: failed to get issues: %v", err) ··· 132 150 } 133 151 134 152 actorDid := syntax.DID(comment.Did) 135 - eventType := models.NotificationTypeIssueCommented 136 153 entityType := "issue" 137 154 entityId := issue.AtUri().String() 138 155 repoId := &issue.Repo.Id ··· 142 159 n.notifyEvent( 143 160 actorDid, 144 161 recipients, 145 - eventType, 162 + models.NotificationTypeIssueCommented, 163 + entityType, 164 + entityId, 165 + repoId, 166 + issueId, 167 + pullId, 168 + ) 169 + n.notifyEvent( 170 + actorDid, 171 + mentions, 172 + models.NotificationTypeUserMentioned, 146 173 entityType, 147 174 entityId, 148 175 repoId, ··· 221 248 ) 222 249 } 223 250 224 - func (n *databaseNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) { 251 + func (n *databaseNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) { 225 252 pull, err := db.GetPull(n.db, 226 253 syntax.ATURI(comment.RepoAt), 227 254 comment.PullId, ··· 259 286 actorDid, 260 287 recipients, 261 288 eventType, 289 + entityType, 290 + entityId, 291 + repoId, 292 + issueId, 293 + pullId, 294 + ) 295 + n.notifyEvent( 296 + actorDid, 297 + mentions, 298 + models.NotificationTypeUserMentioned, 262 299 entityType, 263 300 entityId, 264 301 repoId, ··· 393 430 issueId *int64, 394 431 pullId *int64, 395 432 ) { 433 + if eventType == models.NotificationTypeUserMentioned && len(recipients) > maxMentions { 434 + recipients = recipients[:maxMentions] 435 + } 396 436 recipientSet := make(map[syntax.DID]struct{}) 397 437 for _, did := range recipients { 398 438 // everybody except actor themselves
+6 -6
appview/notify/merged_notifier.go
··· 54 54 m.fanout("DeleteStar", ctx, star) 55 55 } 56 56 57 - func (m *mergedNotifier) NewIssue(ctx context.Context, issue *models.Issue) { 58 - m.fanout("NewIssue", ctx, issue) 57 + func (m *mergedNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) { 58 + m.fanout("NewIssue", ctx, issue, mentions) 59 59 } 60 60 61 - func (m *mergedNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) { 62 - m.fanout("NewIssueComment", ctx, comment) 61 + func (m *mergedNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { 62 + m.fanout("NewIssueComment", ctx, comment, mentions) 63 63 } 64 64 65 65 func (m *mergedNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) { ··· 82 82 m.fanout("NewPull", ctx, pull) 83 83 } 84 84 85 - func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) { 86 - m.fanout("NewPullComment", ctx, comment) 85 + func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) { 86 + m.fanout("NewPullComment", ctx, comment, mentions) 87 87 } 88 88 89 89 func (m *mergedNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) {
+9 -7
appview/notify/notifier.go
··· 13 13 NewStar(ctx context.Context, star *models.Star) 14 14 DeleteStar(ctx context.Context, star *models.Star) 15 15 16 - NewIssue(ctx context.Context, issue *models.Issue) 17 - NewIssueComment(ctx context.Context, comment *models.IssueComment) 16 + NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) 17 + NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) 18 18 NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) 19 19 DeleteIssue(ctx context.Context, issue *models.Issue) 20 20 ··· 22 22 DeleteFollow(ctx context.Context, follow *models.Follow) 23 23 24 24 NewPull(ctx context.Context, pull *models.Pull) 25 - NewPullComment(ctx context.Context, comment *models.PullComment) 25 + NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) 26 26 NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) 27 27 28 28 UpdateProfile(ctx context.Context, profile *models.Profile) ··· 42 42 func (m *BaseNotifier) NewStar(ctx context.Context, star *models.Star) {} 43 43 func (m *BaseNotifier) DeleteStar(ctx context.Context, star *models.Star) {} 44 44 45 - func (m *BaseNotifier) NewIssue(ctx context.Context, issue *models.Issue) {} 46 - func (m *BaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) {} 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 + } 47 48 func (m *BaseNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) {} 48 49 func (m *BaseNotifier) DeleteIssue(ctx context.Context, issue *models.Issue) {} 49 50 50 51 func (m *BaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) {} 51 52 func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {} 52 53 53 - func (m *BaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {} 54 - func (m *BaseNotifier) NewPullComment(ctx context.Context, models *models.PullComment) {} 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 + } 55 57 func (m *BaseNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) {} 56 58 57 59 func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {}
+10 -7
appview/notify/posthog/notifier.go
··· 37 37 38 38 func (n *posthogNotifier) NewStar(ctx context.Context, star *models.Star) { 39 39 err := n.client.Enqueue(posthog.Capture{ 40 - DistinctId: star.StarredByDid, 40 + DistinctId: star.Did, 41 41 Event: "star", 42 42 Properties: posthog.Properties{"repo_at": star.RepoAt.String()}, 43 43 }) ··· 48 48 49 49 func (n *posthogNotifier) DeleteStar(ctx context.Context, star *models.Star) { 50 50 err := n.client.Enqueue(posthog.Capture{ 51 - DistinctId: star.StarredByDid, 51 + DistinctId: star.Did, 52 52 Event: "unstar", 53 53 Properties: posthog.Properties{"repo_at": star.RepoAt.String()}, 54 54 }) ··· 57 57 } 58 58 } 59 59 60 - func (n *posthogNotifier) NewIssue(ctx context.Context, issue *models.Issue) { 60 + func (n *posthogNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) { 61 61 err := n.client.Enqueue(posthog.Capture{ 62 62 DistinctId: issue.Did, 63 63 Event: "new_issue", 64 64 Properties: posthog.Properties{ 65 65 "repo_at": issue.RepoAt.String(), 66 66 "issue_id": issue.IssueId, 67 + "mentions": mentions, 67 68 }, 68 69 }) 69 70 if err != nil { ··· 85 86 } 86 87 } 87 88 88 - func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) { 89 + func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) { 89 90 err := n.client.Enqueue(posthog.Capture{ 90 91 DistinctId: comment.OwnerDid, 91 92 Event: "new_pull_comment", 92 93 Properties: posthog.Properties{ 93 - "repo_at": comment.RepoAt, 94 - "pull_id": comment.PullId, 94 + "repo_at": comment.RepoAt, 95 + "pull_id": comment.PullId, 96 + "mentions": mentions, 95 97 }, 96 98 }) 97 99 if err != nil { ··· 178 180 } 179 181 } 180 182 181 - func (n *posthogNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) { 183 + func (n *posthogNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { 182 184 err := n.client.Enqueue(posthog.Capture{ 183 185 DistinctId: comment.Did, 184 186 Event: "new_issue_comment", 185 187 Properties: posthog.Properties{ 186 188 "issue_at": comment.IssueAt, 189 + "mentions": mentions, 187 190 }, 188 191 }) 189 192 if err != nil {
+19 -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 ··· 198 202 exp int64 199 203 lxm string 200 204 dev bool 205 + timeout time.Duration 201 206 } 202 207 203 208 type ServiceClientOpt func(*ServiceClientOpts) 209 + 210 + func DefaultServiceClientOpts() ServiceClientOpts { 211 + return ServiceClientOpts{ 212 + timeout: time.Second * 5, 213 + } 214 + } 204 215 205 216 func WithService(service string) ServiceClientOpt { 206 217 return func(s *ServiceClientOpts) { ··· 229 240 } 230 241 } 231 242 243 + func WithTimeout(timeout time.Duration) ServiceClientOpt { 244 + return func(s *ServiceClientOpts) { 245 + s.timeout = timeout 246 + } 247 + } 248 + 232 249 func (s *ServiceClientOpts) Audience() string { 233 250 return fmt.Sprintf("did:web:%s", s.service) 234 251 } ··· 243 260 } 244 261 245 262 func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*xrpc.Client, error) { 246 - opts := ServiceClientOpts{} 263 + opts := DefaultServiceClientOpts() 247 264 for _, o := range os { 248 265 o(&opts) 249 266 } ··· 270 287 }, 271 288 Host: opts.Host(), 272 289 Client: &http.Client{ 273 - Timeout: time.Second * 5, 290 + Timeout: opts.timeout, 274 291 }, 275 292 }, nil 276 293 }
+80 -9
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 20 - "github.com/bluesky-social/indigo/atproto/syntax" 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" 21 25 "github.com/dustin/go-humanize" 22 26 "github.com/go-enry/go-enry/v2" 27 + "github.com/yuin/goldmark" 23 28 "tangled.org/core/appview/filetree" 29 + "tangled.org/core/appview/models" 24 30 "tangled.org/core/appview/pages/markup" 25 31 "tangled.org/core/crypto" 26 32 ) ··· 66 72 67 73 return identity.Handle.String() 68 74 }, 75 + "ownerSlashRepo": func(repo *models.Repo) string { 76 + ownerId, err := p.resolver.ResolveIdent(context.Background(), repo.Did) 77 + if err != nil { 78 + return repo.DidSlashRepo() 79 + } 80 + handle := ownerId.Handle 81 + if handle != "" && !handle.IsInvalidHandle() { 82 + return string(handle) + "/" + repo.Name 83 + } 84 + return repo.DidSlashRepo() 85 + }, 69 86 "truncateAt30": func(s string) string { 70 87 if len(s) <= 30 { 71 88 return s ··· 94 111 "sub": func(a, b int) int { 95 112 return a - b 96 113 }, 114 + "mul": func(a, b int) int { 115 + return a * b 116 + }, 117 + "div": func(a, b int) int { 118 + return a / b 119 + }, 120 + "mod": func(a, b int) int { 121 + return a % b 122 + }, 97 123 "f64": func(a int) float64 { 98 124 return float64(a) 99 125 }, ··· 125 151 } 126 152 127 153 return b 128 - }, 129 - "didOrHandle": func(did, handle string) string { 130 - if handle != "" && handle != syntax.HandleInvalid.String() { 131 - return handle 132 - } else { 133 - return did 134 - } 135 154 }, 136 155 "assoc": func(values ...string) ([][]string, error) { 137 156 if len(values)%2 != 0 { ··· 242 261 }, 243 262 "description": func(text string) template.HTML { 244 263 p.rctx.RendererType = markup.RendererTypeDefault 245 - htmlString := p.rctx.RenderMarkdown(text) 264 + htmlString := p.rctx.RenderMarkdownWith(text, goldmark.New()) 246 265 sanitized := p.rctx.SanitizeDescription(htmlString) 247 266 return template.HTML(sanitized) 248 267 }, 268 + "readme": func(text string) template.HTML { 269 + p.rctx.RendererType = markup.RendererTypeRepoMarkdown 270 + htmlString := p.rctx.RenderMarkdown(text) 271 + sanitized := p.rctx.SanitizeDefault(htmlString) 272 + return template.HTML(sanitized) 273 + }, 274 + "code": func(content, path string) string { 275 + var style *chroma.Style = styles.Get("catpuccin-latte") 276 + formatter := chromahtml.New( 277 + chromahtml.InlineCode(false), 278 + chromahtml.WithLineNumbers(true), 279 + chromahtml.WithLinkableLineNumbers(true, "L"), 280 + chromahtml.Standalone(false), 281 + chromahtml.WithClasses(true), 282 + ) 283 + 284 + lexer := lexers.Get(filepath.Base(path)) 285 + if lexer == nil { 286 + lexer = lexers.Fallback 287 + } 288 + 289 + iterator, err := lexer.Tokenise(nil, content) 290 + if err != nil { 291 + p.logger.Error("chroma tokenize", "err", "err") 292 + return "" 293 + } 294 + 295 + var code bytes.Buffer 296 + err = formatter.Format(&code, style, iterator) 297 + if err != nil { 298 + p.logger.Error("chroma format", "err", "err") 299 + return "" 300 + } 301 + 302 + return code.String() 303 + }, 249 304 "trimUriScheme": func(text string) string { 250 305 text = strings.TrimPrefix(text, "https://") 251 306 text = strings.TrimPrefix(text, "http://") ··· 328 383 } 329 384 } 330 385 386 + func (p *Pages) resolveDid(did string) string { 387 + identity, err := p.resolver.ResolveIdent(context.Background(), did) 388 + 389 + if err != nil { 390 + return did 391 + } 392 + 393 + if identity.Handle.IsInvalidHandle() { 394 + return "handle.invalid" 395 + } 396 + 397 + return identity.Handle.String() 398 + } 399 + 331 400 func (p *Pages) AvatarUrl(handle, size string) string { 332 401 handle = strings.TrimPrefix(handle, "@") 402 + 403 + handle = p.resolveDid(handle) 333 404 334 405 secret := p.avatar.SharedSecret 335 406 h := hmac.New(sha256.New, []byte(secret))
+111
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.-]+)(\b)`) 39 + 40 + type atParser struct{} 41 + 42 + // NewAtParser return a new InlineParser that parses 43 + // at expressions. 44 + func NewAtParser() parser.InlineParser { 45 + return &atParser{} 46 + } 47 + 48 + func (s *atParser) Trigger() []byte { 49 + return []byte{'@'} 50 + } 51 + 52 + func (s *atParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node { 53 + line, segment := block.PeekLine() 54 + m := atRegexp.FindSubmatchIndex(line) 55 + if m == nil { 56 + return nil 57 + } 58 + atSegment := text.NewSegment(segment.Start, segment.Start+m[1]) 59 + block.Advance(m[1]) 60 + node := &AtNode{} 61 + node.AppendChild(node, ast.NewTextSegment(atSegment)) 62 + node.Handle = string(atSegment.Value(block.Source())[1:]) 63 + return node 64 + } 65 + 66 + // atHtmlRenderer is a renderer.NodeRenderer implementation that 67 + // renders At nodes. 68 + type atHtmlRenderer struct { 69 + html.Config 70 + } 71 + 72 + // NewAtHTMLRenderer returns a new AtHTMLRenderer. 73 + func NewAtHTMLRenderer(opts ...html.Option) renderer.NodeRenderer { 74 + r := &atHtmlRenderer{ 75 + Config: html.NewConfig(), 76 + } 77 + for _, opt := range opts { 78 + opt.SetHTMLOption(&r.Config) 79 + } 80 + return r 81 + } 82 + 83 + // RegisterFuncs implements renderer.NodeRenderer.RegisterFuncs. 84 + func (r *atHtmlRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { 85 + reg.Register(KindAt, r.renderAt) 86 + } 87 + 88 + func (r *atHtmlRenderer) renderAt(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) { 89 + if entering { 90 + w.WriteString(`<a href="/@`) 91 + w.WriteString(n.(*AtNode).Handle) 92 + w.WriteString(`" class="mention font-bold">`) 93 + } else { 94 + w.WriteString("</a>") 95 + } 96 + return ast.WalkContinue, nil 97 + } 98 + 99 + type atExt struct{} 100 + 101 + // At is an extension that allow you to use at expression like '@user.bsky.social' . 102 + var AtExt = &atExt{} 103 + 104 + func (e *atExt) Extend(m goldmark.Markdown) { 105 + m.Parser().AddOptions(parser.WithInlineParsers( 106 + util.Prioritized(NewAtParser(), 500), 107 + )) 108 + m.Renderer().AddOptions(renderer.WithNodeRenderers( 109 + util.Prioritized(NewAtHTMLRenderer(), 500), 110 + )) 111 + }
+11 -2
appview/pages/markup/markdown.go
··· 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, ··· 66 67 ), 67 68 treeblood.MathML(), 68 69 callout.CalloutExtention, 70 + textension.AtExt, 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,
+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
+38 -114
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" ··· 29 28 "tangled.org/core/patchutil" 30 29 "tangled.org/core/types" 31 30 32 - "github.com/alecthomas/chroma/v2" 33 - chromahtml "github.com/alecthomas/chroma/v2/formatters/html" 34 - "github.com/alecthomas/chroma/v2/lexers" 35 - "github.com/alecthomas/chroma/v2/styles" 36 31 "github.com/bluesky-social/indigo/atproto/identity" 37 32 "github.com/bluesky-social/indigo/atproto/syntax" 38 33 "github.com/go-git/go-git/v5/plumbing" ··· 412 407 type KnotsParams struct { 413 408 LoggedInUser *oauth.User 414 409 Registrations []models.Registration 410 + Tabs []map[string]any 411 + Tab string 415 412 } 416 413 417 414 func (p *Pages) Knots(w io.Writer, params KnotsParams) error { ··· 424 421 Members []string 425 422 Repos map[string][]models.Repo 426 423 IsOwner bool 424 + Tabs []map[string]any 425 + Tab string 427 426 } 428 427 429 428 func (p *Pages) Knot(w io.Writer, params KnotParams) error { ··· 441 440 type SpindlesParams struct { 442 441 LoggedInUser *oauth.User 443 442 Spindles []models.Spindle 443 + Tabs []map[string]any 444 + Tab string 444 445 } 445 446 446 447 func (p *Pages) Spindles(w io.Writer, params SpindlesParams) error { ··· 449 450 450 451 type SpindleListingParams struct { 451 452 models.Spindle 453 + Tabs []map[string]any 454 + Tab string 452 455 } 453 456 454 457 func (p *Pages) SpindleListing(w io.Writer, params SpindleListingParams) error { ··· 460 463 Spindle models.Spindle 461 464 Members []string 462 465 Repos map[string][]models.Repo 466 + Tabs []map[string]any 467 + Tab string 463 468 } 464 469 465 470 func (p *Pages) SpindleDashboard(w io.Writer, params SpindleDashboardParams) error { ··· 487 492 488 493 type ProfileCard struct { 489 494 UserDid string 490 - UserHandle string 491 495 FollowStatus models.FollowStatus 492 496 Punchcard *models.Punchcard 493 497 Profile *models.Profile ··· 630 634 return p.executePlain("user/fragments/editPins", w, params) 631 635 } 632 636 633 - type RepoStarFragmentParams struct { 637 + type StarBtnFragmentParams struct { 634 638 IsStarred bool 635 - RepoAt syntax.ATURI 636 - Stats models.RepoStats 639 + SubjectAt syntax.ATURI 640 + StarCount int 637 641 } 638 642 639 - func (p *Pages) RepoStarFragment(w io.Writer, params RepoStarFragmentParams) error { 640 - return p.executePlain("repo/fragments/repoStar", w, params) 643 + func (p *Pages) StarBtnFragment(w io.Writer, params StarBtnFragmentParams) error { 644 + return p.executePlain("fragments/starBtn", w, params) 641 645 } 642 646 643 647 type RepoIndexParams struct { ··· 744 748 func (r RepoTreeParams) TreeStats() RepoTreeStats { 745 749 numFolders, numFiles := 0, 0 746 750 for _, f := range r.Files { 747 - if !f.IsFile { 751 + if !f.IsFile() { 748 752 numFolders += 1 749 - } else if f.IsFile { 753 + } else if f.IsFile() { 750 754 numFiles += 1 751 755 } 752 756 } ··· 817 821 } 818 822 819 823 type RepoBlobParams struct { 820 - LoggedInUser *oauth.User 821 - RepoInfo repoinfo.RepoInfo 822 - Active string 823 - Unsupported bool 824 - IsImage bool 825 - IsVideo bool 826 - ContentSrc string 827 - BreadCrumbs [][]string 828 - ShowRendered bool 829 - RenderToggle bool 830 - RenderedContents template.HTML 824 + LoggedInUser *oauth.User 825 + RepoInfo repoinfo.RepoInfo 826 + Active string 827 + BreadCrumbs [][]string 828 + BlobView models.BlobView 831 829 *tangled.RepoBlob_Output 832 - // Computed fields for template compatibility 833 - Contents string 834 - Lines int 835 - SizeHint uint64 836 - IsBinary bool 837 830 } 838 831 839 832 func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error { 840 - var style *chroma.Style = styles.Get("catpuccin-latte") 841 - 842 - if params.ShowRendered { 843 - switch markup.GetFormat(params.Path) { 844 - case markup.FormatMarkdown: 845 - p.rctx.RepoInfo = params.RepoInfo 846 - p.rctx.RendererType = markup.RendererTypeRepoMarkdown 847 - htmlString := p.rctx.RenderMarkdown(params.Contents) 848 - sanitized := p.rctx.SanitizeDefault(htmlString) 849 - params.RenderedContents = template.HTML(sanitized) 850 - } 851 - } 852 - 853 - c := params.Contents 854 - formatter := chromahtml.New( 855 - chromahtml.InlineCode(false), 856 - chromahtml.WithLineNumbers(true), 857 - chromahtml.WithLinkableLineNumbers(true, "L"), 858 - chromahtml.Standalone(false), 859 - chromahtml.WithClasses(true), 860 - ) 861 - 862 - lexer := lexers.Get(filepath.Base(params.Path)) 863 - if lexer == nil { 864 - lexer = lexers.Fallback 865 - } 866 - 867 - iterator, err := lexer.Tokenise(nil, c) 868 - if err != nil { 869 - return fmt.Errorf("chroma tokenize: %w", err) 833 + switch params.BlobView.ContentType { 834 + case models.BlobContentTypeMarkup: 835 + p.rctx.RepoInfo = params.RepoInfo 870 836 } 871 837 872 - var code bytes.Buffer 873 - err = formatter.Format(&code, style, iterator) 874 - if err != nil { 875 - return fmt.Errorf("chroma format: %w", err) 876 - } 877 - 878 - params.Contents = code.String() 879 838 params.Active = "overview" 880 839 return p.executeRepo("repo/blob", w, params) 881 840 } 882 841 883 842 type Collaborator struct { 884 - Did string 885 - Handle string 886 - Role string 843 + Did string 844 + Role string 887 845 } 888 846 889 847 type RepoSettingsParams struct { ··· 958 916 RepoInfo repoinfo.RepoInfo 959 917 Active string 960 918 Issues []models.Issue 919 + IssueCount int 961 920 LabelDefs map[string]*models.LabelDefinition 962 921 Page pagination.Page 963 922 FilteringByOpen bool ··· 975 934 Active string 976 935 Issue *models.Issue 977 936 CommentList []models.CommentListItem 937 + Backlinks []models.RichReferenceLink 978 938 LabelDefs map[string]*models.LabelDefinition 979 939 980 940 OrderedReactionKinds []models.ReactionKind ··· 1128 1088 Pull *models.Pull 1129 1089 Stack models.Stack 1130 1090 AbandonedPulls []*models.Pull 1091 + Backlinks []models.RichReferenceLink 1131 1092 BranchDeleteStatus *models.BranchDeleteStatus 1132 1093 MergeCheck types.MergeCheckResponse 1133 1094 ResubmitCheck ResubmitResult ··· 1299 1260 return p.executePlain("repo/fragments/compareAllowPull", w, params) 1300 1261 } 1301 1262 1302 - type RepoCompareDiffParams struct { 1303 - LoggedInUser *oauth.User 1304 - RepoInfo repoinfo.RepoInfo 1305 - Diff types.NiceDiff 1263 + type RepoCompareDiffFragmentParams struct { 1264 + Diff types.NiceDiff 1265 + DiffOpts types.DiffOpts 1306 1266 } 1307 1267 1308 - func (p *Pages) RepoCompareDiff(w io.Writer, params RepoCompareDiffParams) error { 1309 - return p.executePlain("repo/fragments/diff", w, []any{params.RepoInfo.FullName, &params.Diff}) 1268 + func (p *Pages) RepoCompareDiffFragment(w io.Writer, params RepoCompareDiffFragmentParams) error { 1269 + return p.executePlain("repo/fragments/diff", w, []any{&params.Diff, &params.DiffOpts}) 1310 1270 } 1311 1271 1312 1272 type LabelPanelParams struct { ··· 1426 1386 ShowRendered bool 1427 1387 RenderToggle bool 1428 1388 RenderedContents template.HTML 1429 - String models.String 1389 + String *models.String 1430 1390 Stats models.StringStats 1391 + IsStarred bool 1392 + StarCount int 1431 1393 Owner identity.Identity 1432 1394 } 1433 1395 1434 1396 func (p *Pages) SingleString(w io.Writer, params SingleStringParams) error { 1435 - var style *chroma.Style = styles.Get("catpuccin-latte") 1436 - 1437 - if params.ShowRendered { 1438 - switch markup.GetFormat(params.String.Filename) { 1439 - case markup.FormatMarkdown: 1440 - p.rctx.RendererType = markup.RendererTypeRepoMarkdown 1441 - htmlString := p.rctx.RenderMarkdown(params.String.Contents) 1442 - sanitized := p.rctx.SanitizeDefault(htmlString) 1443 - params.RenderedContents = template.HTML(sanitized) 1444 - } 1445 - } 1446 - 1447 - c := params.String.Contents 1448 - formatter := chromahtml.New( 1449 - chromahtml.InlineCode(false), 1450 - chromahtml.WithLineNumbers(true), 1451 - chromahtml.WithLinkableLineNumbers(true, "L"), 1452 - chromahtml.Standalone(false), 1453 - chromahtml.WithClasses(true), 1454 - ) 1455 - 1456 - lexer := lexers.Get(filepath.Base(params.String.Filename)) 1457 - if lexer == nil { 1458 - lexer = lexers.Fallback 1459 - } 1460 - 1461 - iterator, err := lexer.Tokenise(nil, c) 1462 - if err != nil { 1463 - return fmt.Errorf("chroma tokenize: %w", err) 1464 - } 1465 - 1466 - var code bytes.Buffer 1467 - err = formatter.Format(&code, style, iterator) 1468 - if err != nil { 1469 - return fmt.Errorf("chroma format: %w", err) 1470 - } 1471 - 1472 - params.String.Contents = code.String() 1473 1397 return p.execute("strings/string", w, params) 1474 1398 } 1475 1399
+25 -22
appview/pages/repoinfo/repoinfo.go
··· 1 1 package repoinfo 2 2 3 3 import ( 4 + "fmt" 4 5 "path" 5 6 "slices" 6 7 7 8 "github.com/bluesky-social/indigo/atproto/syntax" 9 + "tangled.org/core/api/tangled" 8 10 "tangled.org/core/appview/models" 9 11 "tangled.org/core/appview/state/userutil" 10 12 ) 11 13 12 - func (r RepoInfo) Owner() string { 14 + func (r RepoInfo) owner() string { 13 15 if r.OwnerHandle != "" { 14 16 return r.OwnerHandle 15 17 } else { ··· 18 20 } 19 21 20 22 func (r RepoInfo) FullName() string { 21 - return path.Join(r.Owner(), r.Name) 23 + return path.Join(r.owner(), r.Name) 22 24 } 23 25 24 - func (r RepoInfo) OwnerWithoutAt() string { 26 + func (r RepoInfo) ownerWithoutAt() string { 25 27 if r.OwnerHandle != "" { 26 28 return r.OwnerHandle 27 29 } else { ··· 30 32 } 31 33 32 34 func (r RepoInfo) FullNameWithoutAt() string { 33 - return path.Join(r.OwnerWithoutAt(), r.Name) 35 + return path.Join(r.ownerWithoutAt(), r.Name) 34 36 } 35 37 36 38 func (r RepoInfo) GetTabs() [][]string { ··· 48 50 return tabs 49 51 } 50 52 53 + func (r RepoInfo) RepoAt() syntax.ATURI { 54 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", r.OwnerDid, tangled.RepoNSID, r.Rkey)) 55 + } 56 + 51 57 type RepoInfo struct { 52 - Name string 53 - Rkey string 54 - OwnerDid string 55 - OwnerHandle string 56 - Description string 57 - Website string 58 - Topics []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:
+28
appview/pages/templates/fragments/starBtn.html
··· 1 + {{ define "fragments/starBtn" }} 2 + <button 3 + id="starBtn" 4 + class="btn disabled:opacity-50 disabled:cursor-not-allowed flex gap-2 items-center group" 5 + data-star-subject-at="{{ .SubjectAt }}" 6 + {{ if .IsStarred }} 7 + hx-delete="/star?subject={{ .SubjectAt }}&countHint={{ .StarCount }}" 8 + {{ else }} 9 + hx-post="/star?subject={{ .SubjectAt }}&countHint={{ .StarCount }}" 10 + {{ end }} 11 + 12 + hx-trigger="click" 13 + hx-target="this" 14 + hx-swap="outerHTML" 15 + hx-swap-oob='outerHTML:#starBtn[data-star-subject-at="{{ .SubjectAt }}"]' 16 + hx-disabled-elt="#starBtn" 17 + > 18 + {{ if .IsStarred }} 19 + {{ i "star" "w-4 h-4 fill-current" }} 20 + {{ else }} 21 + {{ i "star" "w-4 h-4" }} 22 + {{ end }} 23 + <span class="text-sm"> 24 + {{ .StarCount }} 25 + </span> 26 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 27 + </button> 28 + {{ 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 +
+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 -13
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 - autocapitalize="none" 34 - autocorrect="off" 35 - autocomplete="off" 36 - type="text" 37 - id="member-did-{{ .Id }}" 38 - name="member" 39 - required 40 - placeholder="foo.bsky.social" 41 - /> 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> 42 47 <div class="flex gap-2 pt-2"> 43 48 <button 44 49 type="button" ··· 57 62 </div> 58 63 <div id="add-member-error-{{ .Id }}" class="text-red-500 dark:text-red-400"></div> 59 64 </form> 60 - {{ 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://tangled.org/@tangled.org/core/blob/master/docs/spindle/hosting.md"> 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 }}
+1
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> 12 13 13 14 <!-- preconnect to image cdn --> 14 15 <link rel="preconnect" href="https://avatar.tangled.sh" />
-2
appview/pages/templates/layouts/fragments/topbar.html
··· 61 61 <a href="/{{ $user }}">profile</a> 62 62 <a href="/{{ $user }}?tab=repos">repositories</a> 63 63 <a href="/{{ $user }}?tab=strings">strings</a> 64 - <a href="/knots">knots</a> 65 - <a href="/spindles">spindles</a> 66 64 <a href="/settings">settings</a> 67 65 <a href="#" 68 66 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
+16 -10
appview/pages/templates/layouts/repobase.html
··· 2 2 3 3 {{ define "content" }} 4 4 <section id="repo-header" class="mb-4 p-2 dark:text-white"> 5 - {{ if .RepoInfo.Source }} 6 - <div class="flex items-center"> 7 - {{ i "git-fork" "w-3 h-3 mr-1 shrink-0" }} 8 - forked from 9 - {{ $sourceOwner := didOrHandle .RepoInfo.Source.Did .RepoInfo.SourceHandle }} 10 - <a class="ml-1 underline" href="/{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}">{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}</a> 11 - </div> 12 - {{ end }} 13 5 <div class="text-lg flex flex-col sm:flex-row items-start gap-4 justify-between"> 14 6 <!-- left items --> 15 7 <div class="flex flex-col gap-2"> ··· 19 11 <span class="select-none">/</span> 20 12 <a href="/{{ .RepoInfo.FullName }}" class="font-bold">{{ .RepoInfo.Name }}</a> 21 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 }} 22 25 23 26 <span class="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-gray-600 dark:text-gray-300"> 24 27 {{ if .RepoInfo.Description }} ··· 46 49 </div> 47 50 48 51 <div class="w-full sm:w-fit grid grid-cols-3 gap-2 z-auto"> 49 - {{ template "repo/fragments/repoStar" .RepoInfo }} 52 + {{ template "fragments/starBtn" 53 + (dict "SubjectAt" .RepoInfo.RepoAt 54 + "IsStarred" .RepoInfo.IsStarred 55 + "StarCount" .RepoInfo.Stats.StarCount) }} 50 56 <a 51 57 class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group" 52 58 hx-boost="true" ··· 104 110 </div> 105 111 </nav> 106 112 {{ block "repoContentLayout" . }} 107 - <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"> 108 114 {{ block "repoContent" . }}{{ end }} 109 115 </section> 110 116 {{ block "repoAfter" . }}{{ end }}
+2
appview/pages/templates/notifications/fragments/item.html
··· 54 54 reopened a pull request 55 55 {{ else if eq .Type "followed" }} 56 56 followed you 57 + {{ else if eq .Type "user_mentioned" }} 58 + mentioned you 57 59 {{ else }} 58 60 {{ end }} 59 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 }}
+1 -1
appview/pages/templates/repo/commit.html
··· 111 111 {{ end }} 112 112 113 113 {{ define "contentAfter" }} 114 - {{ template "repo/fragments/diff" (list .RepoInfo.FullName .Diff .DiffOpts) }} 114 + {{ template "repo/fragments/diff" (list .Diff .DiffOpts) }} 115 115 {{end}} 116 116 117 117 {{ 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" }}
+1 -1
appview/pages/templates/repo/empty.html
··· 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 | stripPort }}:{{ .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" "w-4 h-4" }} 18 + </span> 19 + {{ else if eq .Kind.String "issues" }} 20 + <span class="text-green-600 dark:text-green-500"> 21 + {{ i "circle-dot" "w-4 h-4" }} 22 + </span> 23 + {{ else if .State.IsOpen }} 24 + <span class="text-green-600 dark:text-green-500"> 25 + {{ i "git-pull-request" "w-4 h-4" }} 26 + </span> 27 + {{ else if .State.IsMerged }} 28 + <span class="text-purple-600 dark:text-purple-500"> 29 + {{ i "git-merge" "w-4 h-4" }} 30 + </span> 31 + {{ else }} 32 + <span class="text-gray-600 dark:text-gray-300"> 33 + {{ i "git-pull-request-closed" "w-4 h-4" }} 34 + </span> 35 + {{ end }} 36 + <a href="{{ . }}"><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 }}
+3 -2
appview/pages/templates/repo/fragments/cloneDropdown.html
··· 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 | stripPort }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}" 52 - >git@{{ $knot | stripPort }}:{{ .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"
+2 -3
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 }}
+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" }}
-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 }}
+9 -2
appview/pages/templates/repo/index.html
··· 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" }} ··· 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" }}
+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 }}
+3
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) }} 23 26 {{ template "repo/fragments/externalLinkPanel" $.Issue.AtUri }} 24 27 </div> 25 28 </div>
+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 }}
+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 }}
+3 -3
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">{{ template "stepHeader" . }}</div> 6 - <div class="hidden group-open:flex items-center gap-1">{{ template "stepHeader" . }}</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> 7 7 </summary> 8 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> 9 9 </details> ··· 11 11 {{ end }} 12 12 13 13 {{ define "stepHeader" }} 14 - {{ i "chevron-right" "w-4 h-4" }} {{ .Name }} 14 + {{ .Name }} 15 15 <span class="ml-auto text-sm text-gray-500 tabular-nums" data-timer="{{ .Id }}" data-start="{{ .StartTime.Unix }}"></span> 16 16 {{ end }}
+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://tangled.org/@tangled.org/core/blob/master/docs/spindle/pipeline.md" 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>
+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/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" }}
+3
appview/pages/templates/repo/pulls/pull.html
··· 21 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) }} 24 27 {{ template "repo/fragments/externalLinkPanel" $.Pull.AtUri }} 25 28 </div> 26 29 </div>
+61 -38
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 - <form class="flex gap-4" method="GET"> 35 - <input type="hidden" name="state" value="{{ .FilteringBy.String }}"> 36 - <input class="" type="text" name="q" value="{{ .FilterQuery }}"> 37 - <button class="btn" type="submit"> 38 - search 39 - </button> 40 - </form> 41 - </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 + > 42 48 <a 43 - href="/{{ .RepoInfo.FullName }}/pulls/new" 44 - 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" 45 51 > 46 - {{ i "git-pull-request-create" "w-4 h-4" }} 47 - <span>new</span> 52 + {{ i "x" "w-4 h-4" }} 48 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") }} 49 64 </div> 50 - <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> 51 74 {{ end }} 52 75 53 76 {{ define "repoAfter" }} ··· 140 163 {{ i "chevrons-down-up" "w-4 h-4" }} hide {{ len $otherPulls }} pull{{$s}} in this stack 141 164 </div> 142 165 </summary> 143 - {{ block "pullList" (list $otherPulls $) }} {{ end }} 166 + {{ block "stackedPullList" (list $otherPulls $) }} {{ end }} 144 167 </details> 145 168 {{ end }} 146 169 {{ end }} ··· 149 172 </div> 150 173 {{ end }} 151 174 152 - {{ define "pullList" }} 175 + {{ define "stackedPullList" }} 153 176 {{ $list := index . 0 }} 154 177 {{ $root := index . 1 }} 155 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"
+1 -1
appview/pages/templates/repo/settings/general.html
··· 58 58 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 59 59 </button> 60 60 </div> 61 - <fieldset> 61 + </fieldset> 62 62 </form> 63 63 {{ end }} 64 64
+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 -12
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 - autocomplete="off" 36 - type="text" 37 - id="member-did-{{ .Id }}" 38 - name="member" 39 - required 40 - placeholder="foo.bsky.social" 41 - /> 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> 42 47 <div class="flex gap-2 pt-2"> 43 48 <button 44 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://tangled.org/@tangled.org/core/blob/master/docs/spindle/hosting.md"> 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 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>
+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 }}
+1 -1
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">
+1 -1
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">
+7 -1
appview/pages/templates/user/fragments/editBio.html
··· 26 26 {{ if and .Profile .Profile.Pronouns }} 27 27 {{ $pronouns = .Profile.Pronouns }} 28 28 {{ end }} 29 - <input type="text" class="py-1 px-1 w-full" name="pronouns" value="{{ $pronouns }}"> 29 + <input 30 + type="text" 31 + class="py-1 px-1 w-full" 32 + name="pronouns" 33 + placeholder="they/them" 34 + value="{{ $pronouns }}" 35 + > 30 36 </div> 31 37 </div> 32 38
+1 -1
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">
+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>
+12 -2
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"> ··· 33 33 </p> 34 34 35 35 <div class="flex flex-col gap-1"> 36 + {{ block "commits" .Commits }} {{ end }} 36 37 {{ block "repoEvents" .RepoEvents }} {{ end }} 37 38 {{ block "issueEvents" .IssueEvents }} {{ end }} 38 39 {{ block "pullEvents" .PullEvents }} {{ end }} ··· 43 44 {{ end }} 44 45 {{ end }} 45 46 </div> 47 + {{ end }} 48 + 49 + {{ define "commits" }} 50 + {{ if . }} 51 + <div class="flex flex-wrap items-center gap-1"> 52 + {{ i "git-commit-horizontal" "size-5" }} 53 + created {{ . }} commits 54 + </div> 55 + {{ end }} 46 56 {{ end }} 47 57 48 58 {{ define "repoEvents" }} ··· 224 234 {{ define "ownRepos" }} 225 235 <div> 226 236 <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" 237 + <a href="/{{ resolve $.Card.UserDid }}?tab=repos" 228 238 class="flex text-black dark:text-white items-center gap-2 no-underline hover:no-underline group"> 229 239 <span>PINNED REPOS</span> 230 240 </a>
+1 -1
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">
+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>
+1 -1
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">
+2 -2
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"> ··· 23 23 {{ $s := index . 1 }} 24 24 <div class="py-4 px-6 rounded bg-white dark:bg-gray-800"> 25 25 <div class="font-medium dark:text-white flex gap-2 items-center"> 26 - <a href="/strings/{{ or $root.Card.UserHandle $root.Card.UserDid }}/{{ $s.Rkey }}">{{ $s.Filename }}</a> 26 + <a href="/strings/{{ resolve $root.Card.UserDid }}/{{ $s.Rkey }}">{{ $s.Filename }}</a> 27 27 </div> 28 28 {{ with $s.Description }} 29 29 <div class="text-gray-600 dark:text-gray-300 text-sm">
+16 -20
appview/pipelines/pipelines.go
··· 78 78 return 79 79 } 80 80 81 - repoInfo := f.RepoInfo(user) 82 - 83 81 ps, err := db.GetPipelineStatuses( 84 82 p.db, 85 - db.FilterEq("repo_owner", repoInfo.OwnerDid), 86 - db.FilterEq("repo_name", repoInfo.Name), 87 - db.FilterEq("knot", repoInfo.Knot), 83 + 30, 84 + db.FilterEq("repo_owner", f.Did), 85 + db.FilterEq("repo_name", f.Name), 86 + db.FilterEq("knot", f.Knot), 88 87 ) 89 88 if err != nil { 90 89 l.Error("failed to query db", "err", err) ··· 93 92 94 93 p.pages.Pipelines(w, pages.PipelinesParams{ 95 94 LoggedInUser: user, 96 - RepoInfo: repoInfo, 95 + RepoInfo: p.repoResolver.GetRepoInfo(r, user), 97 96 Pipelines: ps, 98 97 }) 99 98 } ··· 108 107 return 109 108 } 110 109 111 - repoInfo := f.RepoInfo(user) 112 - 113 110 pipelineId := chi.URLParam(r, "pipeline") 114 111 if pipelineId == "" { 115 112 l.Error("empty pipeline ID") ··· 124 121 125 122 ps, err := db.GetPipelineStatuses( 126 123 p.db, 127 - db.FilterEq("repo_owner", repoInfo.OwnerDid), 128 - db.FilterEq("repo_name", repoInfo.Name), 129 - db.FilterEq("knot", repoInfo.Knot), 124 + 1, 125 + db.FilterEq("repo_owner", f.Did), 126 + db.FilterEq("repo_name", f.Name), 127 + db.FilterEq("knot", f.Knot), 130 128 db.FilterEq("id", pipelineId), 131 129 ) 132 130 if err != nil { ··· 143 141 144 142 p.pages.Workflow(w, pages.WorkflowParams{ 145 143 LoggedInUser: user, 146 - RepoInfo: repoInfo, 144 + RepoInfo: p.repoResolver.GetRepoInfo(r, user), 147 145 Pipeline: singlePipeline, 148 146 Workflow: workflow, 149 147 }) ··· 174 172 ctx, cancel := context.WithCancel(r.Context()) 175 173 defer cancel() 176 174 177 - user := p.oauth.GetUser(r) 178 175 f, err := p.repoResolver.Resolve(r) 179 176 if err != nil { 180 177 l.Error("failed to get repo and knot", "err", err) ··· 182 179 return 183 180 } 184 181 185 - repoInfo := f.RepoInfo(user) 186 - 187 182 pipelineId := chi.URLParam(r, "pipeline") 188 183 workflow := chi.URLParam(r, "workflow") 189 184 if pipelineId == "" || workflow == "" { ··· 193 188 194 189 ps, err := db.GetPipelineStatuses( 195 190 p.db, 196 - db.FilterEq("repo_owner", repoInfo.OwnerDid), 197 - db.FilterEq("repo_name", repoInfo.Name), 198 - db.FilterEq("knot", repoInfo.Knot), 191 + 1, 192 + db.FilterEq("repo_owner", f.Did), 193 + db.FilterEq("repo_name", f.Name), 194 + db.FilterEq("knot", f.Knot), 199 195 db.FilterEq("id", pipelineId), 200 196 ) 201 197 if err != nil || len(ps) != 1 { ··· 205 201 } 206 202 207 203 singlePipeline := ps[0] 208 - spindle := repoInfo.Spindle 209 - knot := repoInfo.Knot 204 + spindle := f.Spindle 205 + knot := f.Knot 210 206 rkey := singlePipeline.Rkey 211 207 212 208 if spindle == "" || knot == "" || rkey == "" {
+1 -1
appview/pulls/opengraph.go
··· 293 293 filesChanged = niceDiff.Stat.FilesChanged 294 294 } 295 295 296 - card, err := s.drawPullSummaryCard(pull, &f.Repo, commentCount, diffStats, filesChanged) 296 + card, err := s.drawPullSummaryCard(pull, f, commentCount, diffStats, filesChanged) 297 297 if err != nil { 298 298 log.Println("failed to draw pull summary card", err) 299 299 http.Error(w, "failed to draw pull summary card", http.StatusInternalServerError)
+108 -101
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" ··· 23 24 "tangled.org/core/appview/oauth" 24 25 "tangled.org/core/appview/pages" 25 26 "tangled.org/core/appview/pages/markup" 27 + "tangled.org/core/appview/pages/repoinfo" 28 + "tangled.org/core/appview/refresolver" 26 29 "tangled.org/core/appview/reporesolver" 27 30 "tangled.org/core/appview/validator" 28 31 "tangled.org/core/appview/xrpcclient" ··· 45 48 repoResolver *reporesolver.RepoResolver 46 49 pages *pages.Pages 47 50 idResolver *idresolver.Resolver 51 + refResolver *refresolver.Resolver 48 52 db *db.DB 49 53 config *config.Config 50 54 notifier notify.Notifier ··· 59 63 repoResolver *reporesolver.RepoResolver, 60 64 pages *pages.Pages, 61 65 resolver *idresolver.Resolver, 66 + refResolver *refresolver.Resolver, 62 67 db *db.DB, 63 68 config *config.Config, 64 69 notifier notify.Notifier, ··· 72 77 repoResolver: repoResolver, 73 78 pages: pages, 74 79 idResolver: resolver, 80 + refResolver: refResolver, 75 81 db: db, 76 82 config: config, 77 83 notifier: notifier, ··· 123 129 124 130 s.pages.PullActionsFragment(w, pages.PullActionsParams{ 125 131 LoggedInUser: user, 126 - RepoInfo: f.RepoInfo(user), 132 + RepoInfo: s.repoResolver.GetRepoInfo(r, user), 127 133 Pull: pull, 128 134 RoundNumber: roundNumber, 129 135 MergeCheck: mergeCheckResponse, ··· 150 156 return 151 157 } 152 158 159 + backlinks, err := db.GetBacklinks(s.db, pull.AtUri()) 160 + if err != nil { 161 + log.Println("failed to get pull backlinks", err) 162 + s.pages.Notice(w, "pull-error", "Failed to get pull. Try again later.") 163 + return 164 + } 165 + 153 166 // can be nil if this pull is not stacked 154 167 stack, _ := r.Context().Value("stack").(models.Stack) 155 168 abandonedPulls, _ := r.Context().Value("abandonedPulls").([]*models.Pull) ··· 160 173 if user != nil && user.Did == pull.OwnerDid { 161 174 resubmitResult = s.resubmitCheck(r, f, pull, stack) 162 175 } 163 - 164 - repoInfo := f.RepoInfo(user) 165 176 166 177 m := make(map[string]models.Pipeline) 167 178 ··· 178 189 179 190 ps, err := db.GetPipelineStatuses( 180 191 s.db, 181 - db.FilterEq("repo_owner", repoInfo.OwnerDid), 182 - db.FilterEq("repo_name", repoInfo.Name), 183 - db.FilterEq("knot", repoInfo.Knot), 192 + len(shas), 193 + db.FilterEq("repo_owner", f.Did), 194 + db.FilterEq("repo_name", f.Name), 195 + db.FilterEq("knot", f.Knot), 184 196 db.FilterIn("sha", shas), 185 197 ) 186 198 if err != nil { ··· 205 217 206 218 labelDefs, err := db.GetLabelDefinitions( 207 219 s.db, 208 - db.FilterIn("at_uri", f.Repo.Labels), 220 + db.FilterIn("at_uri", f.Labels), 209 221 db.FilterContains("scope", tangled.RepoPullNSID), 210 222 ) 211 223 if err != nil { ··· 221 233 222 234 s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 223 235 LoggedInUser: user, 224 - RepoInfo: repoInfo, 236 + RepoInfo: s.repoResolver.GetRepoInfo(r, user), 225 237 Pull: pull, 226 238 Stack: stack, 227 239 AbandonedPulls: abandonedPulls, 240 + Backlinks: backlinks, 228 241 BranchDeleteStatus: branchDeleteStatus, 229 242 MergeCheck: mergeCheckResponse, 230 243 ResubmitCheck: resubmitResult, ··· 238 251 }) 239 252 } 240 253 241 - func (s *Pulls) mergeCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *models.Pull, stack models.Stack) types.MergeCheckResponse { 254 + func (s *Pulls) mergeCheck(r *http.Request, f *models.Repo, pull *models.Pull, stack models.Stack) types.MergeCheckResponse { 242 255 if pull.State == models.PullMerged { 243 256 return types.MergeCheckResponse{} 244 257 } ··· 267 280 r.Context(), 268 281 &xrpcc, 269 282 &tangled.RepoMergeCheck_Input{ 270 - Did: f.OwnerDid(), 283 + Did: f.Did, 271 284 Name: f.Name, 272 285 Branch: pull.TargetBranch, 273 286 Patch: patch, ··· 305 318 return result 306 319 } 307 320 308 - func (s *Pulls) branchDeleteStatus(r *http.Request, f *reporesolver.ResolvedRepo, pull *models.Pull) *models.BranchDeleteStatus { 321 + func (s *Pulls) branchDeleteStatus(r *http.Request, repo *models.Repo, pull *models.Pull) *models.BranchDeleteStatus { 309 322 if pull.State != models.PullMerged { 310 323 return nil 311 324 } ··· 316 329 } 317 330 318 331 var branch string 319 - var repo *models.Repo 320 332 // check if the branch exists 321 333 // NOTE: appview could cache branches/tags etc. for every repo by listening for gitRefUpdates 322 334 if pull.IsBranchBased() { 323 335 branch = pull.PullSource.Branch 324 - repo = &f.Repo 325 336 } else if pull.IsForkBased() { 326 337 branch = pull.PullSource.Branch 327 338 repo = pull.PullSource.Repo ··· 360 371 } 361 372 } 362 373 363 - func (s *Pulls) resubmitCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *models.Pull, stack models.Stack) pages.ResubmitResult { 374 + func (s *Pulls) resubmitCheck(r *http.Request, repo *models.Repo, pull *models.Pull, stack models.Stack) pages.ResubmitResult { 364 375 if pull.State == models.PullMerged || pull.State == models.PullDeleted || pull.PullSource == nil { 365 376 return pages.Unknown 366 377 } ··· 380 391 repoName = sourceRepo.Name 381 392 } else { 382 393 // pulls within the same repo 383 - knot = f.Knot 384 - ownerDid = f.OwnerDid() 385 - repoName = f.Name 394 + knot = repo.Knot 395 + ownerDid = repo.Did 396 + repoName = repo.Name 386 397 } 387 398 388 399 scheme := "http" ··· 394 405 Host: host, 395 406 } 396 407 397 - repo := fmt.Sprintf("%s/%s", ownerDid, repoName) 398 - branchResp, err := tangled.RepoBranch(r.Context(), xrpcc, pull.PullSource.Branch, repo) 408 + didSlashName := fmt.Sprintf("%s/%s", ownerDid, repoName) 409 + branchResp, err := tangled.RepoBranch(r.Context(), xrpcc, pull.PullSource.Branch, didSlashName) 399 410 if err != nil { 400 411 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 401 412 log.Println("failed to call XRPC repo.branches", xrpcerr) ··· 423 434 424 435 func (s *Pulls) RepoPullPatch(w http.ResponseWriter, r *http.Request) { 425 436 user := s.oauth.GetUser(r) 426 - f, err := s.repoResolver.Resolve(r) 427 - if err != nil { 428 - log.Println("failed to get repo and knot", err) 429 - return 430 - } 431 437 432 438 var diffOpts types.DiffOpts 433 439 if d := r.URL.Query().Get("diff"); d == "split" { ··· 456 462 457 463 s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{ 458 464 LoggedInUser: user, 459 - RepoInfo: f.RepoInfo(user), 465 + RepoInfo: s.repoResolver.GetRepoInfo(r, user), 460 466 Pull: pull, 461 467 Stack: stack, 462 468 Round: roundIdInt, ··· 470 476 func (s *Pulls) RepoPullInterdiff(w http.ResponseWriter, r *http.Request) { 471 477 user := s.oauth.GetUser(r) 472 478 473 - f, err := s.repoResolver.Resolve(r) 474 - if err != nil { 475 - log.Println("failed to get repo and knot", err) 476 - return 477 - } 478 - 479 479 var diffOpts types.DiffOpts 480 480 if d := r.URL.Query().Get("diff"); d == "split" { 481 481 diffOpts.Split = true ··· 520 520 521 521 s.pages.RepoPullInterdiffPage(w, pages.RepoPullInterdiffParams{ 522 522 LoggedInUser: s.oauth.GetUser(r), 523 - RepoInfo: f.RepoInfo(user), 523 + RepoInfo: s.repoResolver.GetRepoInfo(r, user), 524 524 Pull: pull, 525 525 Round: roundIdInt, 526 526 Interdiff: interdiff, ··· 645 645 } 646 646 pulls = pulls[:n] 647 647 648 - repoInfo := f.RepoInfo(user) 649 648 ps, err := db.GetPipelineStatuses( 650 649 s.db, 651 - db.FilterEq("repo_owner", repoInfo.OwnerDid), 652 - db.FilterEq("repo_name", repoInfo.Name), 653 - db.FilterEq("knot", repoInfo.Knot), 650 + len(shas), 651 + db.FilterEq("repo_owner", f.Did), 652 + db.FilterEq("repo_name", f.Name), 653 + db.FilterEq("knot", f.Knot), 654 654 db.FilterIn("sha", shas), 655 655 ) 656 656 if err != nil { ··· 664 664 665 665 labelDefs, err := db.GetLabelDefinitions( 666 666 s.db, 667 - db.FilterIn("at_uri", f.Repo.Labels), 667 + db.FilterIn("at_uri", f.Labels), 668 668 db.FilterContains("scope", tangled.RepoPullNSID), 669 669 ) 670 670 if err != nil { ··· 680 680 681 681 s.pages.RepoPulls(w, pages.RepoPullsParams{ 682 682 LoggedInUser: s.oauth.GetUser(r), 683 - RepoInfo: f.RepoInfo(user), 683 + RepoInfo: s.repoResolver.GetRepoInfo(r, user), 684 684 Pulls: pulls, 685 685 LabelDefs: defs, 686 686 FilteringBy: state, ··· 717 717 case http.MethodGet: 718 718 s.pages.PullNewCommentFragment(w, pages.PullNewCommentParams{ 719 719 LoggedInUser: user, 720 - RepoInfo: f.RepoInfo(user), 720 + RepoInfo: s.repoResolver.GetRepoInfo(r, user), 721 721 Pull: pull, 722 722 RoundNumber: roundNumber, 723 723 }) ··· 729 729 return 730 730 } 731 731 732 + mentions, references := s.refResolver.Resolve(r.Context(), body) 733 + 732 734 // Start a transaction 733 735 tx, err := s.db.BeginTx(r.Context(), nil) 734 736 if err != nil { ··· 771 773 Body: body, 772 774 CommentAt: atResp.Uri, 773 775 SubmissionId: pull.Submissions[roundNumber].ID, 776 + Mentions: mentions, 777 + References: references, 774 778 } 775 779 776 780 // Create the pull comment in the database with the commentAt field ··· 788 792 return 789 793 } 790 794 791 - s.notifier.NewPullComment(r.Context(), comment) 795 + s.notifier.NewPullComment(r.Context(), comment, mentions) 792 796 793 - s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId)) 797 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 798 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", ownerSlashRepo, pull.PullId, commentId)) 794 799 return 795 800 } 796 801 } ··· 814 819 Host: host, 815 820 } 816 821 817 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 822 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 818 823 xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 819 824 if err != nil { 820 825 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { ··· 841 846 842 847 s.pages.RepoNewPull(w, pages.RepoNewPullParams{ 843 848 LoggedInUser: user, 844 - RepoInfo: f.RepoInfo(user), 849 + RepoInfo: s.repoResolver.GetRepoInfo(r, user), 845 850 Branches: result.Branches, 846 851 Strategy: strategy, 847 852 SourceBranch: sourceBranch, ··· 864 869 } 865 870 866 871 // Determine PR type based on input parameters 867 - isPushAllowed := f.RepoInfo(user).Roles.IsPushAllowed() 872 + roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())} 873 + isPushAllowed := roles.IsPushAllowed() 868 874 isBranchBased := isPushAllowed && sourceBranch != "" && fromFork == "" 869 875 isForkBased := fromFork != "" && sourceBranch != "" 870 876 isPatchBased := patch != "" && !isBranchBased && !isForkBased ··· 962 968 func (s *Pulls) handleBranchBasedPull( 963 969 w http.ResponseWriter, 964 970 r *http.Request, 965 - f *reporesolver.ResolvedRepo, 971 + repo *models.Repo, 966 972 user *oauth.User, 967 973 title, 968 974 body, ··· 974 980 if !s.config.Core.Dev { 975 981 scheme = "https" 976 982 } 977 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 983 + host := fmt.Sprintf("%s://%s", scheme, repo.Knot) 978 984 xrpcc := &indigoxrpc.Client{ 979 985 Host: host, 980 986 } 981 987 982 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 983 - xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, targetBranch, sourceBranch) 988 + didSlashRepo := fmt.Sprintf("%s/%s", repo.Did, repo.Name) 989 + xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, didSlashRepo, targetBranch, sourceBranch) 984 990 if err != nil { 985 991 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 986 992 log.Println("failed to call XRPC repo.compare", xrpcerr) ··· 1017 1023 Sha: comparison.Rev2, 1018 1024 } 1019 1025 1020 - s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, combined, sourceRev, pullSource, recordPullSource, isStacked) 1026 + s.createPullRequest(w, r, repo, user, title, body, targetBranch, patch, combined, sourceRev, pullSource, recordPullSource, isStacked) 1021 1027 } 1022 1028 1023 - func (s *Pulls) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, f *reporesolver.ResolvedRepo, user *oauth.User, title, body, targetBranch, patch string, isStacked bool) { 1029 + func (s *Pulls) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, repo *models.Repo, user *oauth.User, title, body, targetBranch, patch string, isStacked bool) { 1024 1030 if err := s.validator.ValidatePatch(&patch); err != nil { 1025 1031 s.logger.Error("patch validation failed", "err", err) 1026 1032 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 1027 1033 return 1028 1034 } 1029 1035 1030 - s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, "", "", nil, nil, isStacked) 1036 + s.createPullRequest(w, r, repo, user, title, body, targetBranch, patch, "", "", nil, nil, isStacked) 1031 1037 } 1032 1038 1033 - 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) { 1039 + 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) { 1034 1040 repoString := strings.SplitN(forkRepo, "/", 2) 1035 1041 forkOwnerDid := repoString[0] 1036 1042 repoName := repoString[1] ··· 1132 1138 Sha: sourceRev, 1133 1139 } 1134 1140 1135 - s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, combined, sourceRev, pullSource, recordPullSource, isStacked) 1141 + s.createPullRequest(w, r, repo, user, title, body, targetBranch, patch, combined, sourceRev, pullSource, recordPullSource, isStacked) 1136 1142 } 1137 1143 1138 1144 func (s *Pulls) createPullRequest( 1139 1145 w http.ResponseWriter, 1140 1146 r *http.Request, 1141 - f *reporesolver.ResolvedRepo, 1147 + repo *models.Repo, 1142 1148 user *oauth.User, 1143 1149 title, body, targetBranch string, 1144 1150 patch string, ··· 1153 1159 s.createStackedPullRequest( 1154 1160 w, 1155 1161 r, 1156 - f, 1162 + repo, 1157 1163 user, 1158 1164 targetBranch, 1159 1165 patch, ··· 1198 1204 body = formatPatches[0].Body 1199 1205 } 1200 1206 } 1207 + 1208 + mentions, references := s.refResolver.Resolve(r.Context(), body) 1201 1209 1202 1210 rkey := tid.TID() 1203 1211 initialSubmission := models.PullSubmission{ ··· 1210 1218 Body: body, 1211 1219 TargetBranch: targetBranch, 1212 1220 OwnerDid: user.Did, 1213 - RepoAt: f.RepoAt(), 1221 + RepoAt: repo.RepoAt(), 1214 1222 Rkey: rkey, 1223 + Mentions: mentions, 1224 + References: references, 1215 1225 Submissions: []*models.PullSubmission{ 1216 1226 &initialSubmission, 1217 1227 }, ··· 1223 1233 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1224 1234 return 1225 1235 } 1226 - pullId, err := db.NextPullId(tx, f.RepoAt()) 1236 + pullId, err := db.NextPullId(tx, repo.RepoAt()) 1227 1237 if err != nil { 1228 1238 log.Println("failed to get pull id", err) 1229 1239 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") ··· 1238 1248 Val: &tangled.RepoPull{ 1239 1249 Title: title, 1240 1250 Target: &tangled.RepoPull_Target{ 1241 - Repo: string(f.RepoAt()), 1251 + Repo: string(repo.RepoAt()), 1242 1252 Branch: targetBranch, 1243 1253 }, 1244 1254 Patch: patch, ··· 1261 1271 1262 1272 s.notifier.NewPull(r.Context(), pull) 1263 1273 1264 - s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId)) 1274 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo) 1275 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pullId)) 1265 1276 } 1266 1277 1267 1278 func (s *Pulls) createStackedPullRequest( 1268 1279 w http.ResponseWriter, 1269 1280 r *http.Request, 1270 - f *reporesolver.ResolvedRepo, 1281 + repo *models.Repo, 1271 1282 user *oauth.User, 1272 1283 targetBranch string, 1273 1284 patch string, ··· 1299 1310 1300 1311 // build a stack out of this patch 1301 1312 stackId := uuid.New() 1302 - stack, err := newStack(f, user, targetBranch, patch, pullSource, stackId.String()) 1313 + stack, err := s.newStack(r.Context(), repo, user, targetBranch, patch, pullSource, stackId.String()) 1303 1314 if err != nil { 1304 1315 log.Println("failed to create stack", err) 1305 1316 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to create stack: %v", err)) ··· 1362 1373 return 1363 1374 } 1364 1375 1365 - s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls", f.OwnerSlashRepo())) 1376 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo) 1377 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls", ownerSlashRepo)) 1366 1378 } 1367 1379 1368 1380 func (s *Pulls) ValidatePatch(w http.ResponseWriter, r *http.Request) { ··· 1393 1405 1394 1406 func (s *Pulls) PatchUploadFragment(w http.ResponseWriter, r *http.Request) { 1395 1407 user := s.oauth.GetUser(r) 1396 - f, err := s.repoResolver.Resolve(r) 1397 - if err != nil { 1398 - log.Println("failed to get repo and knot", err) 1399 - return 1400 - } 1401 1408 1402 1409 s.pages.PullPatchUploadFragment(w, pages.PullPatchUploadParams{ 1403 - RepoInfo: f.RepoInfo(user), 1410 + RepoInfo: s.repoResolver.GetRepoInfo(r, user), 1404 1411 }) 1405 1412 } 1406 1413 ··· 1421 1428 Host: host, 1422 1429 } 1423 1430 1424 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1431 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 1425 1432 xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 1426 1433 if err != nil { 1427 1434 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { ··· 1454 1461 } 1455 1462 1456 1463 s.pages.PullCompareBranchesFragment(w, pages.PullCompareBranchesParams{ 1457 - RepoInfo: f.RepoInfo(user), 1464 + RepoInfo: s.repoResolver.GetRepoInfo(r, user), 1458 1465 Branches: withoutDefault, 1459 1466 }) 1460 1467 } 1461 1468 1462 1469 func (s *Pulls) CompareForksFragment(w http.ResponseWriter, r *http.Request) { 1463 1470 user := s.oauth.GetUser(r) 1464 - f, err := s.repoResolver.Resolve(r) 1465 - if err != nil { 1466 - log.Println("failed to get repo and knot", err) 1467 - return 1468 - } 1469 1471 1470 1472 forks, err := db.GetForksByDid(s.db, user.Did) 1471 1473 if err != nil { ··· 1474 1476 } 1475 1477 1476 1478 s.pages.PullCompareForkFragment(w, pages.PullCompareForkParams{ 1477 - RepoInfo: f.RepoInfo(user), 1479 + RepoInfo: s.repoResolver.GetRepoInfo(r, user), 1478 1480 Forks: forks, 1479 1481 Selected: r.URL.Query().Get("fork"), 1480 1482 }) ··· 1542 1544 Host: targetHost, 1543 1545 } 1544 1546 1545 - targetRepo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1547 + targetRepo := fmt.Sprintf("%s/%s", f.Did, f.Name) 1546 1548 targetXrpcBytes, err := tangled.RepoBranches(r.Context(), targetXrpcc, "", 0, targetRepo) 1547 1549 if err != nil { 1548 1550 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { ··· 1567 1569 }) 1568 1570 1569 1571 s.pages.PullCompareForkBranchesFragment(w, pages.PullCompareForkBranchesParams{ 1570 - RepoInfo: f.RepoInfo(user), 1572 + RepoInfo: s.repoResolver.GetRepoInfo(r, user), 1571 1573 SourceBranches: sourceBranches.Branches, 1572 1574 TargetBranches: targetBranches.Branches, 1573 1575 }) ··· 1575 1577 1576 1578 func (s *Pulls) ResubmitPull(w http.ResponseWriter, r *http.Request) { 1577 1579 user := s.oauth.GetUser(r) 1578 - f, err := s.repoResolver.Resolve(r) 1579 - if err != nil { 1580 - log.Println("failed to get repo and knot", err) 1581 - return 1582 - } 1583 1580 1584 1581 pull, ok := r.Context().Value("pull").(*models.Pull) 1585 1582 if !ok { ··· 1591 1588 switch r.Method { 1592 1589 case http.MethodGet: 1593 1590 s.pages.PullResubmitFragment(w, pages.PullResubmitParams{ 1594 - RepoInfo: f.RepoInfo(user), 1591 + RepoInfo: s.repoResolver.GetRepoInfo(r, user), 1595 1592 Pull: pull, 1596 1593 }) 1597 1594 return ··· 1658 1655 return 1659 1656 } 1660 1657 1661 - if !f.RepoInfo(user).Roles.IsPushAllowed() { 1658 + roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())} 1659 + if !roles.IsPushAllowed() { 1662 1660 log.Println("unauthorized user") 1663 1661 w.WriteHeader(http.StatusUnauthorized) 1664 1662 return ··· 1673 1671 Host: host, 1674 1672 } 1675 1673 1676 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1674 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 1677 1675 xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, pull.TargetBranch, pull.PullSource.Branch) 1678 1676 if err != nil { 1679 1677 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { ··· 1800 1798 func (s *Pulls) resubmitPullHelper( 1801 1799 w http.ResponseWriter, 1802 1800 r *http.Request, 1803 - f *reporesolver.ResolvedRepo, 1801 + repo *models.Repo, 1804 1802 user *oauth.User, 1805 1803 pull *models.Pull, 1806 1804 patch string, ··· 1809 1807 ) { 1810 1808 if pull.IsStacked() { 1811 1809 log.Println("resubmitting stacked PR") 1812 - s.resubmitStackedPullHelper(w, r, f, user, pull, patch, pull.StackId) 1810 + s.resubmitStackedPullHelper(w, r, repo, user, pull, patch, pull.StackId) 1813 1811 return 1814 1812 } 1815 1813 ··· 1889 1887 Val: &tangled.RepoPull{ 1890 1888 Title: pull.Title, 1891 1889 Target: &tangled.RepoPull_Target{ 1892 - Repo: string(f.RepoAt()), 1890 + Repo: string(repo.RepoAt()), 1893 1891 Branch: pull.TargetBranch, 1894 1892 }, 1895 1893 Patch: patch, // new patch ··· 1910 1908 return 1911 1909 } 1912 1910 1913 - s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 1911 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo) 1912 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 1914 1913 } 1915 1914 1916 1915 func (s *Pulls) resubmitStackedPullHelper( 1917 1916 w http.ResponseWriter, 1918 1917 r *http.Request, 1919 - f *reporesolver.ResolvedRepo, 1918 + repo *models.Repo, 1920 1919 user *oauth.User, 1921 1920 pull *models.Pull, 1922 1921 patch string, ··· 1925 1924 targetBranch := pull.TargetBranch 1926 1925 1927 1926 origStack, _ := r.Context().Value("stack").(models.Stack) 1928 - newStack, err := newStack(f, user, targetBranch, patch, pull.PullSource, stackId) 1927 + newStack, err := s.newStack(r.Context(), repo, user, targetBranch, patch, pull.PullSource, stackId) 1929 1928 if err != nil { 1930 1929 log.Println("failed to create resubmitted stack", err) 1931 1930 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") ··· 2103 2102 return 2104 2103 } 2105 2104 2106 - s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 2105 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo) 2106 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 2107 2107 } 2108 2108 2109 2109 func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) { ··· 2156 2156 2157 2157 authorName := ident.Handle.String() 2158 2158 mergeInput := &tangled.RepoMerge_Input{ 2159 - Did: f.OwnerDid(), 2159 + Did: f.Did, 2160 2160 Name: f.Name, 2161 2161 Branch: pull.TargetBranch, 2162 2162 Patch: patch, ··· 2221 2221 s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p) 2222 2222 } 2223 2223 2224 - s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.Name, pull.PullId)) 2224 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 2225 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 2225 2226 } 2226 2227 2227 2228 func (s *Pulls) ClosePull(w http.ResponseWriter, r *http.Request) { ··· 2241 2242 } 2242 2243 2243 2244 // auth filter: only owner or collaborators can close 2244 - roles := f.RolesInRepo(user) 2245 + roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())} 2245 2246 isOwner := roles.IsOwner() 2246 2247 isCollaborator := roles.IsCollaborator() 2247 2248 isPullAuthor := user.Did == pull.OwnerDid ··· 2293 2294 s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p) 2294 2295 } 2295 2296 2296 - s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 2297 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 2298 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 2297 2299 } 2298 2300 2299 2301 func (s *Pulls) ReopenPull(w http.ResponseWriter, r *http.Request) { ··· 2314 2316 } 2315 2317 2316 2318 // auth filter: only owner or collaborators can close 2317 - roles := f.RolesInRepo(user) 2319 + roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())} 2318 2320 isOwner := roles.IsOwner() 2319 2321 isCollaborator := roles.IsCollaborator() 2320 2322 isPullAuthor := user.Did == pull.OwnerDid ··· 2366 2368 s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p) 2367 2369 } 2368 2370 2369 - s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 2371 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 2372 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 2370 2373 } 2371 2374 2372 - func newStack(f *reporesolver.ResolvedRepo, user *oauth.User, targetBranch, patch string, pullSource *models.PullSource, stackId string) (models.Stack, error) { 2375 + func (s *Pulls) newStack(ctx context.Context, repo *models.Repo, user *oauth.User, targetBranch, patch string, pullSource *models.PullSource, stackId string) (models.Stack, error) { 2373 2376 formatPatches, err := patchutil.ExtractPatches(patch) 2374 2377 if err != nil { 2375 2378 return nil, fmt.Errorf("Failed to extract patches: %v", err) ··· 2394 2397 body := fp.Body 2395 2398 rkey := tid.TID() 2396 2399 2400 + mentions, references := s.refResolver.Resolve(ctx, body) 2401 + 2397 2402 initialSubmission := models.PullSubmission{ 2398 2403 Patch: fp.Raw, 2399 2404 SourceRev: fp.SHA, ··· 2404 2409 Body: body, 2405 2410 TargetBranch: targetBranch, 2406 2411 OwnerDid: user.Did, 2407 - RepoAt: f.RepoAt(), 2412 + RepoAt: repo.RepoAt(), 2408 2413 Rkey: rkey, 2414 + Mentions: mentions, 2415 + References: references, 2409 2416 Submissions: []*models.PullSubmission{ 2410 2417 &initialSubmission, 2411 2418 },
+65
appview/refresolver/resolver.go
··· 1 + package refresolver 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 + rawMentions, rawRefs := markup.FindReferences(r.config.Core.AppviewHost, source) 39 + l.Debug("found possible references", "mentions", rawMentions, "refs", rawRefs) 40 + idents := r.idResolver.ResolveIdents(ctx, rawMentions) 41 + var mentions []syntax.DID 42 + for _, ident := range idents { 43 + if ident != nil && !ident.Handle.IsInvalidHandle() { 44 + mentions = append(mentions, ident.DID) 45 + } 46 + } 47 + l.Debug("found mentions", "mentions", mentions) 48 + 49 + var resolvedRefs []models.ReferenceLink 50 + for _, rawRef := range rawRefs { 51 + ident, err := r.idResolver.ResolveIdent(ctx, rawRef.Handle) 52 + if err != nil || ident == nil || ident.Handle.IsInvalidHandle() { 53 + continue 54 + } 55 + rawRef.Handle = string(ident.DID) 56 + resolvedRefs = append(resolvedRefs, rawRef) 57 + } 58 + aturiRefs, err := db.ValidateReferenceLinks(r.execer, resolvedRefs) 59 + if err != nil { 60 + l.Error("failed running query", "err", err) 61 + } 62 + l.Debug("found references", "refs", aturiRefs) 63 + 64 + return mentions, aturiRefs 65 + }
+49
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 + f, err := rp.repoResolver.Resolve(r) 22 + if err != nil { 23 + l.Error("failed to get repo and knot", "err", err) 24 + return 25 + } 26 + scheme := "http" 27 + if !rp.config.Core.Dev { 28 + scheme = "https" 29 + } 30 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 31 + xrpcc := &indigoxrpc.Client{ 32 + Host: host, 33 + } 34 + didSlashRepo := f.DidSlashRepo() 35 + archiveBytes, err := tangled.RepoArchive(r.Context(), xrpcc, "tar.gz", "", ref, didSlashRepo) 36 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 37 + l.Error("failed to call XRPC repo.archive", "err", xrpcerr) 38 + rp.pages.Error503(w) 39 + return 40 + } 41 + // Set headers for file download, just pass along whatever the knot specifies 42 + safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(ref).Short(), "/", "-") 43 + filename := fmt.Sprintf("%s-%s.tar.gz", f.Name, safeRefFilename) 44 + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) 45 + w.Header().Set("Content-Type", "application/gzip") 46 + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(archiveBytes))) 47 + // Write the archive data directly 48 + w.Write(archiveBytes) 49 + }
+11 -5
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" 19 18 "tangled.org/core/tid" 20 19 "tangled.org/core/types" ··· 131 130 132 131 rp.pages.RepoArtifactFragment(w, pages.RepoArtifactParams{ 133 132 LoggedInUser: user, 134 - RepoInfo: f.RepoInfo(user), 133 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 135 134 Artifact: artifact, 136 135 }) 137 136 } ··· 174 173 175 174 artifact := artifacts[0] 176 175 177 - ownerPds := f.OwnerId.PDSEndpoint() 176 + ownerId, err := rp.idResolver.ResolveIdent(r.Context(), f.Did) 177 + if err != nil { 178 + log.Println("failed to resolve repo owner did", f.Did, err) 179 + http.Error(w, "repository owner not found", http.StatusNotFound) 180 + return 181 + } 182 + 183 + ownerPds := ownerId.PDSEndpoint() 178 184 url, _ := url.Parse(fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob", ownerPds)) 179 185 q := url.Query() 180 186 q.Set("cid", artifact.BlobCid.String()) ··· 290 296 w.Write([]byte{}) 291 297 } 292 298 293 - func (rp *Repo) resolveTag(ctx context.Context, f *reporesolver.ResolvedRepo, tagParam string) (*types.TagReference, error) { 299 + func (rp *Repo) resolveTag(ctx context.Context, f *models.Repo, tagParam string) (*types.TagReference, error) { 294 300 tagParam, err := url.QueryUnescape(tagParam) 295 301 if err != nil { 296 302 return nil, err ··· 305 311 Host: host, 306 312 } 307 313 308 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 314 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 309 315 xrpcBytes, err := tangled.RepoTags(ctx, xrpcc, "", 0, repo) 310 316 if err != nil { 311 317 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 + }
+24 -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" 15 14 15 + "github.com/bluesky-social/indigo/atproto/identity" 16 16 "github.com/bluesky-social/indigo/atproto/syntax" 17 17 "github.com/gorilla/feeds" 18 18 ) 19 19 20 - func (rp *Repo) getRepoFeed(ctx context.Context, f *reporesolver.ResolvedRepo) (*feeds.Feed, error) { 20 + func (rp *Repo) getRepoFeed(ctx context.Context, repo *models.Repo, ownerSlashRepo string) (*feeds.Feed, error) { 21 21 const feedLimitPerType = 100 22 22 23 - pulls, err := db.GetPullsWithLimit(rp.db, feedLimitPerType, db.FilterEq("repo_at", f.RepoAt())) 23 + pulls, err := db.GetPullsWithLimit(rp.db, feedLimitPerType, db.FilterEq("repo_at", repo.RepoAt())) 24 24 if err != nil { 25 25 return nil, err 26 26 } ··· 28 28 issues, err := db.GetIssuesPaginated( 29 29 rp.db, 30 30 pagination.Page{Limit: feedLimitPerType}, 31 - db.FilterEq("repo_at", f.RepoAt()), 31 + db.FilterEq("repo_at", repo.RepoAt()), 32 32 ) 33 33 if err != nil { 34 34 return nil, err 35 35 } 36 36 37 37 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"}, 38 + Title: fmt.Sprintf("activity feed for @%s", ownerSlashRepo), 39 + Link: &feeds.Link{Href: fmt.Sprintf("%s/%s", rp.config.Core.AppviewHost, ownerSlashRepo), Type: "text/html", Rel: "alternate"}, 40 40 Items: make([]*feeds.Item, 0), 41 41 Updated: time.UnixMilli(0), 42 42 } 43 43 44 44 for _, pull := range pulls { 45 - items, err := rp.createPullItems(ctx, pull, f) 45 + items, err := rp.createPullItems(ctx, pull, repo, ownerSlashRepo) 46 46 if err != nil { 47 47 return nil, err 48 48 } ··· 50 50 } 51 51 52 52 for _, issue := range issues { 53 - item, err := rp.createIssueItem(ctx, issue, f) 53 + item, err := rp.createIssueItem(ctx, issue, repo, ownerSlashRepo) 54 54 if err != nil { 55 55 return nil, err 56 56 } ··· 71 71 return feed, nil 72 72 } 73 73 74 - func (rp *Repo) createPullItems(ctx context.Context, pull *models.Pull, f *reporesolver.ResolvedRepo) ([]*feeds.Item, error) { 74 + func (rp *Repo) createPullItems(ctx context.Context, pull *models.Pull, repo *models.Repo, ownerSlashRepo string) ([]*feeds.Item, error) { 75 75 owner, err := rp.idResolver.ResolveIdent(ctx, pull.OwnerDid) 76 76 if err != nil { 77 77 return nil, err ··· 80 80 var items []*feeds.Item 81 81 82 82 state := rp.getPullState(pull) 83 - description := rp.buildPullDescription(owner.Handle, state, pull, f.OwnerSlashRepo()) 83 + description := rp.buildPullDescription(owner.Handle, state, pull, ownerSlashRepo) 84 84 85 85 mainItem := &feeds.Item{ 86 86 Title: fmt.Sprintf("[PR #%d] %s", pull.PullId, pull.Title), 87 87 Description: description, 88 - Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/pulls/%d", rp.config.Core.AppviewHost, f.OwnerSlashRepo(), pull.PullId)}, 88 + Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/pulls/%d", rp.config.Core.AppviewHost, ownerSlashRepo, pull.PullId)}, 89 89 Created: pull.Created, 90 90 Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)}, 91 91 } ··· 98 98 99 99 roundItem := &feeds.Item{ 100 100 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)}, 101 + Description: fmt.Sprintf("@%s submitted changes (at round #%d) on PR #%d in @%s", owner.Handle, round.RoundNumber, pull.PullId, ownerSlashRepo), 102 + Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/pulls/%d/round/%d/", rp.config.Core.AppviewHost, ownerSlashRepo, pull.PullId, round.RoundNumber)}, 103 103 Created: round.Created, 104 104 Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)}, 105 105 } ··· 109 109 return items, nil 110 110 } 111 111 112 - func (rp *Repo) createIssueItem(ctx context.Context, issue models.Issue, f *reporesolver.ResolvedRepo) (*feeds.Item, error) { 112 + func (rp *Repo) createIssueItem(ctx context.Context, issue models.Issue, repo *models.Repo, ownerSlashRepo string) (*feeds.Item, error) { 113 113 owner, err := rp.idResolver.ResolveIdent(ctx, issue.Did) 114 114 if err != nil { 115 115 return nil, err ··· 122 122 123 123 return &feeds.Item{ 124 124 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)}, 125 + Description: fmt.Sprintf("@%s %s issue #%d in @%s", owner.Handle, state, issue.IssueId, ownerSlashRepo), 126 + Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/issues/%d", rp.config.Core.AppviewHost, ownerSlashRepo, issue.IssueId)}, 127 127 Created: issue.Created, 128 128 Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)}, 129 129 }, nil ··· 146 146 return fmt.Sprintf("%s in %s", base, repoName) 147 147 } 148 148 149 - func (rp *Repo) RepoAtomFeed(w http.ResponseWriter, r *http.Request) { 149 + func (rp *Repo) AtomFeed(w http.ResponseWriter, r *http.Request) { 150 150 f, err := rp.repoResolver.Resolve(r) 151 151 if err != nil { 152 152 log.Println("failed to fully resolve repo:", err) 153 153 return 154 154 } 155 + repoOwnerId, ok := r.Context().Value("resolvedId").(identity.Identity) 156 + if !ok || repoOwnerId.Handle.IsInvalidHandle() { 157 + log.Println("failed to get resolved repo owner id") 158 + return 159 + } 160 + ownerSlashRepo := repoOwnerId.Handle.String() + "/" + f.Name 155 161 156 - feed, err := rp.getRepoFeed(r.Context(), f) 162 + feed, err := rp.getRepoFeed(r.Context(), f, ownerSlashRepo) 157 163 if err != nil { 158 164 log.Println("failed to get repo feed:", err) 159 165 rp.pages.Error500(w)
+20 -23
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" 27 26 "tangled.org/core/types" 28 27 ··· 30 29 "github.com/go-enry/go-enry/v2" 31 30 ) 32 31 33 - func (rp *Repo) RepoIndex(w http.ResponseWriter, r *http.Request) { 32 + func (rp *Repo) Index(w http.ResponseWriter, r *http.Request) { 34 33 l := rp.logger.With("handler", "RepoIndex") 35 34 36 35 ref := chi.URLParam(r, "ref") ··· 52 51 } 53 52 54 53 user := rp.oauth.GetUser(r) 55 - repoInfo := f.RepoInfo(user) 56 54 57 55 // Build index response from multiple XRPC calls 58 56 result, err := rp.buildIndexResponse(r.Context(), xrpcc, f, ref) ··· 62 60 rp.pages.RepoIndexPage(w, pages.RepoIndexParams{ 63 61 LoggedInUser: user, 64 62 NeedsKnotUpgrade: true, 65 - RepoInfo: repoInfo, 63 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 66 64 }) 67 65 return 68 66 } ··· 140 138 for _, c := range commitsTrunc { 141 139 shas = append(shas, c.Hash.String()) 142 140 } 143 - pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas) 141 + pipelines, err := getPipelineStatuses(rp.db, f, shas) 144 142 if err != nil { 145 143 l.Error("failed to fetch pipeline statuses", "err", err) 146 144 // non-fatal ··· 148 146 149 147 rp.pages.RepoIndexPage(w, pages.RepoIndexParams{ 150 148 LoggedInUser: user, 151 - RepoInfo: repoInfo, 149 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 152 150 TagMap: tagMap, 153 151 RepoIndexResponse: *result, 154 152 CommitsTrunc: commitsTrunc, ··· 165 163 func (rp *Repo) getLanguageInfo( 166 164 ctx context.Context, 167 165 l *slog.Logger, 168 - f *reporesolver.ResolvedRepo, 166 + repo *models.Repo, 169 167 xrpcc *indigoxrpc.Client, 170 168 currentRef string, 171 169 isDefaultRef bool, ··· 173 171 // first attempt to fetch from db 174 172 langs, err := db.GetRepoLanguages( 175 173 rp.db, 176 - db.FilterEq("repo_at", f.RepoAt()), 174 + db.FilterEq("repo_at", repo.RepoAt()), 177 175 db.FilterEq("ref", currentRef), 178 176 ) 179 177 180 178 if err != nil || langs == nil { 181 179 // 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) 180 + didSlashRepo := fmt.Sprintf("%s/%s", repo.Did, repo.Name) 181 + ls, err := tangled.RepoLanguages(ctx, xrpcc, currentRef, didSlashRepo) 184 182 if err != nil { 185 183 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 186 184 l.Error("failed to call XRPC repo.languages", "err", xrpcerr) ··· 195 193 196 194 for _, lang := range ls.Languages { 197 195 langs = append(langs, models.RepoLanguage{ 198 - RepoAt: f.RepoAt(), 196 + RepoAt: repo.RepoAt(), 199 197 Ref: currentRef, 200 198 IsDefaultRef: isDefaultRef, 201 199 Language: lang.Name, ··· 210 208 defer tx.Rollback() 211 209 212 210 // update appview's cache 213 - err = db.UpdateRepoLanguages(tx, f.RepoAt(), currentRef, langs) 211 + err = db.UpdateRepoLanguages(tx, repo.RepoAt(), currentRef, langs) 214 212 if err != nil { 215 213 // non-fatal 216 214 l.Error("failed to cache lang results", "err", err) ··· 255 253 } 256 254 257 255 // 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) 256 + func (rp *Repo) buildIndexResponse(ctx context.Context, xrpcc *indigoxrpc.Client, repo *models.Repo, ref string) (*types.RepoIndexResponse, error) { 257 + didSlashRepo := fmt.Sprintf("%s/%s", repo.Did, repo.Name) 260 258 261 259 // first get branches to determine the ref if not specified 262 - branchesBytes, err := tangled.RepoBranches(ctx, xrpcc, "", 0, repo) 260 + branchesBytes, err := tangled.RepoBranches(ctx, xrpcc, "", 0, didSlashRepo) 263 261 if err != nil { 264 262 return nil, fmt.Errorf("failed to call repoBranches: %w", err) 265 263 } ··· 303 301 wg.Add(1) 304 302 go func() { 305 303 defer wg.Done() 306 - tagsBytes, err := tangled.RepoTags(ctx, xrpcc, "", 0, repo) 304 + tagsBytes, err := tangled.RepoTags(ctx, xrpcc, "", 0, didSlashRepo) 307 305 if err != nil { 308 306 errs = errors.Join(errs, fmt.Errorf("failed to call repoTags: %w", err)) 309 307 return ··· 318 316 wg.Add(1) 319 317 go func() { 320 318 defer wg.Done() 321 - resp, err := tangled.RepoTree(ctx, xrpcc, "", ref, repo) 319 + resp, err := tangled.RepoTree(ctx, xrpcc, "", ref, didSlashRepo) 322 320 if err != nil { 323 321 errs = errors.Join(errs, fmt.Errorf("failed to call repoTree: %w", err)) 324 322 return ··· 330 328 wg.Add(1) 331 329 go func() { 332 330 defer wg.Done() 333 - logBytes, err := tangled.RepoLog(ctx, xrpcc, "", 50, "", ref, repo) 331 + logBytes, err := tangled.RepoLog(ctx, xrpcc, "", 50, "", ref, didSlashRepo) 334 332 if err != nil { 335 333 errs = errors.Join(errs, fmt.Errorf("failed to call repoLog: %w", err)) 336 334 return ··· 351 349 if treeResp != nil && treeResp.Files != nil { 352 350 for _, file := range treeResp.Files { 353 351 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, 352 + Name: file.Name, 353 + Mode: file.Mode, 354 + Size: file.Size, 359 355 } 356 + 360 357 if file.Last_commit != nil { 361 358 when, _ := time.Parse(time.RFC3339, file.Last_commit.When) 362 359 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.GetVerifiedObjectCommits(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.NiceDiff{*result.Diff}) 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 + }
+2 -2
appview/repo/opengraph.go
··· 327 327 return nil 328 328 } 329 329 330 - func (rp *Repo) RepoOpenGraphSummary(w http.ResponseWriter, r *http.Request) { 330 + func (rp *Repo) Opengraph(w http.ResponseWriter, r *http.Request) { 331 331 f, err := rp.repoResolver.Resolve(r) 332 332 if err != nil { 333 333 log.Println("failed to get repo and knot", err) ··· 374 374 }) 375 375 } 376 376 377 - card, err := rp.drawRepoSummaryCard(&f.Repo, languageStats) 377 + card, err := rp.drawRepoSummaryCard(f, languageStats) 378 378 if err != nil { 379 379 log.Println("failed to draw repo summary card", err) 380 380 http.Error(w, "failed to draw repo summary card", http.StatusInternalServerError)
+23 -1392
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" 34 27 "tangled.org/core/rbac" 35 28 "tangled.org/core/tid" 36 - "tangled.org/core/types" 37 29 "tangled.org/core/xrpc/serviceauth" 38 30 39 31 comatproto "github.com/bluesky-social/indigo/api/atproto" 40 32 atpclient "github.com/bluesky-social/indigo/atproto/client" 41 33 "github.com/bluesky-social/indigo/atproto/syntax" 42 34 lexutil "github.com/bluesky-social/indigo/lex/util" 43 - indigoxrpc "github.com/bluesky-social/indigo/xrpc" 44 35 securejoin "github.com/cyphar/filepath-securejoin" 45 36 "github.com/go-chi/chi/v5" 46 - "github.com/go-git/go-git/v5/plumbing" 47 37 ) 48 38 49 39 type Repo struct { ··· 88 78 } 89 79 } 90 80 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 - EmailToDid: emailToDidMap, 250 - VerifiedCommits: vc, 251 - Pipelines: pipelines, 252 - }) 253 - } 254 - 255 - func (rp *Repo) RepoCommit(w http.ResponseWriter, r *http.Request) { 256 - l := rp.logger.With("handler", "RepoCommit") 257 - 258 - f, err := rp.repoResolver.Resolve(r) 259 - if err != nil { 260 - l.Error("failed to fully resolve repo", "err", err) 261 - return 262 - } 263 - ref := chi.URLParam(r, "ref") 264 - ref, _ = url.PathUnescape(ref) 265 - 266 - var diffOpts types.DiffOpts 267 - if d := r.URL.Query().Get("diff"); d == "split" { 268 - diffOpts.Split = true 269 - } 270 - 271 - if !plumbing.IsHash(ref) { 272 - rp.pages.Error404(w) 273 - return 274 - } 275 - 276 - scheme := "http" 277 - if !rp.config.Core.Dev { 278 - scheme = "https" 279 - } 280 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 281 - xrpcc := &indigoxrpc.Client{ 282 - Host: host, 283 - } 284 - 285 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 286 - xrpcBytes, err := tangled.RepoDiff(r.Context(), xrpcc, ref, repo) 287 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 288 - l.Error("failed to call XRPC repo.diff", "err", xrpcerr) 289 - rp.pages.Error503(w) 290 - return 291 - } 292 - 293 - var result types.RepoCommitResponse 294 - if err := json.Unmarshal(xrpcBytes, &result); err != nil { 295 - l.Error("failed to decode XRPC response", "err", err) 296 - rp.pages.Error503(w) 297 - return 298 - } 299 - 300 - emailToDidMap, err := db.GetEmailToDid(rp.db, []string{result.Diff.Commit.Committer.Email, result.Diff.Commit.Author.Email}, true) 301 - if err != nil { 302 - l.Error("failed to get email to did mapping", "err", err) 303 - } 304 - 305 - vc, err := commitverify.GetVerifiedCommits(rp.db, emailToDidMap, []types.NiceDiff{*result.Diff}) 306 - if err != nil { 307 - l.Error("failed to GetVerifiedCommits", "err", err) 308 - } 309 - 310 - user := rp.oauth.GetUser(r) 311 - repoInfo := f.RepoInfo(user) 312 - pipelines, err := getPipelineStatuses(rp.db, repoInfo, []string{result.Diff.Commit.This}) 313 - if err != nil { 314 - l.Error("failed to getPipelineStatuses", "err", err) 315 - // non-fatal 316 - } 317 - var pipeline *models.Pipeline 318 - if p, ok := pipelines[result.Diff.Commit.This]; ok { 319 - pipeline = &p 320 - } 321 - 322 - rp.pages.RepoCommit(w, pages.RepoCommitParams{ 323 - LoggedInUser: user, 324 - RepoInfo: f.RepoInfo(user), 325 - RepoCommitResponse: result, 326 - EmailToDid: emailToDidMap, 327 - VerifiedCommit: vc, 328 - Pipeline: pipeline, 329 - DiffOpts: diffOpts, 330 - }) 331 - } 332 - 333 - func (rp *Repo) RepoTree(w http.ResponseWriter, r *http.Request) { 334 - l := rp.logger.With("handler", "RepoTree") 335 - 336 - f, err := rp.repoResolver.Resolve(r) 337 - if err != nil { 338 - l.Error("failed to fully resolve repo", "err", err) 339 - return 340 - } 341 - 342 - ref := chi.URLParam(r, "ref") 343 - ref, _ = url.PathUnescape(ref) 344 - 345 - // if the tree path has a trailing slash, let's strip it 346 - // so we don't 404 347 - treePath := chi.URLParam(r, "*") 348 - treePath, _ = url.PathUnescape(treePath) 349 - treePath = strings.TrimSuffix(treePath, "/") 350 - 351 - scheme := "http" 352 - if !rp.config.Core.Dev { 353 - scheme = "https" 354 - } 355 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 356 - xrpcc := &indigoxrpc.Client{ 357 - Host: host, 358 - } 359 - 360 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 361 - xrpcResp, err := tangled.RepoTree(r.Context(), xrpcc, treePath, ref, repo) 362 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 363 - l.Error("failed to call XRPC repo.tree", "err", xrpcerr) 364 - rp.pages.Error503(w) 365 - return 366 - } 367 - 368 - // Convert XRPC response to internal types.RepoTreeResponse 369 - files := make([]types.NiceTree, len(xrpcResp.Files)) 370 - for i, xrpcFile := range xrpcResp.Files { 371 - file := types.NiceTree{ 372 - Name: xrpcFile.Name, 373 - Mode: xrpcFile.Mode, 374 - Size: int64(xrpcFile.Size), 375 - IsFile: xrpcFile.Is_file, 376 - IsSubtree: xrpcFile.Is_subtree, 377 - } 378 - 379 - // Convert last commit info if present 380 - if xrpcFile.Last_commit != nil { 381 - commitWhen, _ := time.Parse(time.RFC3339, xrpcFile.Last_commit.When) 382 - file.LastCommit = &types.LastCommitInfo{ 383 - Hash: plumbing.NewHash(xrpcFile.Last_commit.Hash), 384 - Message: xrpcFile.Last_commit.Message, 385 - When: commitWhen, 386 - } 387 - } 388 - 389 - files[i] = file 390 - } 391 - 392 - result := types.RepoTreeResponse{ 393 - Ref: xrpcResp.Ref, 394 - Files: files, 395 - } 396 - 397 - if xrpcResp.Parent != nil { 398 - result.Parent = *xrpcResp.Parent 399 - } 400 - if xrpcResp.Dotdot != nil { 401 - result.DotDot = *xrpcResp.Dotdot 402 - } 403 - if xrpcResp.Readme != nil { 404 - result.ReadmeFileName = xrpcResp.Readme.Filename 405 - result.Readme = xrpcResp.Readme.Contents 406 - } 407 - 408 - // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated, 409 - // so we can safely redirect to the "parent" (which is the same file). 410 - if len(result.Files) == 0 && result.Parent == treePath { 411 - redirectTo := fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), url.PathEscape(ref), result.Parent) 412 - http.Redirect(w, r, redirectTo, http.StatusFound) 413 - return 414 - } 415 - 416 - user := rp.oauth.GetUser(r) 417 - 418 - var breadcrumbs [][]string 419 - breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))}) 420 - if treePath != "" { 421 - for idx, elem := range strings.Split(treePath, "/") { 422 - breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))}) 423 - } 424 - } 425 - 426 - sortFiles(result.Files) 427 - 428 - rp.pages.RepoTree(w, pages.RepoTreeParams{ 429 - LoggedInUser: user, 430 - BreadCrumbs: breadcrumbs, 431 - TreePath: treePath, 432 - RepoInfo: f.RepoInfo(user), 433 - RepoTreeResponse: result, 434 - }) 435 - } 436 - 437 - func (rp *Repo) RepoTags(w http.ResponseWriter, r *http.Request) { 438 - l := rp.logger.With("handler", "RepoTags") 439 - 440 - f, err := rp.repoResolver.Resolve(r) 441 - if err != nil { 442 - l.Error("failed to get repo and knot", "err", err) 443 - return 444 - } 445 - 446 - scheme := "http" 447 - if !rp.config.Core.Dev { 448 - scheme = "https" 449 - } 450 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 451 - xrpcc := &indigoxrpc.Client{ 452 - Host: host, 453 - } 454 - 455 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 456 - xrpcBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 457 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 458 - l.Error("failed to call XRPC repo.tags", "err", xrpcerr) 459 - rp.pages.Error503(w) 460 - return 461 - } 462 - 463 - var result types.RepoTagsResponse 464 - if err := json.Unmarshal(xrpcBytes, &result); err != nil { 465 - l.Error("failed to decode XRPC response", "err", err) 466 - rp.pages.Error503(w) 467 - return 468 - } 469 - 470 - artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt())) 471 - if err != nil { 472 - l.Error("failed grab artifacts", "err", err) 473 - return 474 - } 475 - 476 - // convert artifacts to map for easy UI building 477 - artifactMap := make(map[plumbing.Hash][]models.Artifact) 478 - for _, a := range artifacts { 479 - artifactMap[a.Tag] = append(artifactMap[a.Tag], a) 480 - } 481 - 482 - var danglingArtifacts []models.Artifact 483 - for _, a := range artifacts { 484 - found := false 485 - for _, t := range result.Tags { 486 - if t.Tag != nil { 487 - if t.Tag.Hash == a.Tag { 488 - found = true 489 - } 490 - } 491 - } 492 - 493 - if !found { 494 - danglingArtifacts = append(danglingArtifacts, a) 495 - } 496 - } 497 - 498 - user := rp.oauth.GetUser(r) 499 - rp.pages.RepoTags(w, pages.RepoTagsParams{ 500 - LoggedInUser: user, 501 - RepoInfo: f.RepoInfo(user), 502 - RepoTagsResponse: result, 503 - ArtifactMap: artifactMap, 504 - DanglingArtifacts: danglingArtifacts, 505 - }) 506 - } 507 - 508 - func (rp *Repo) RepoBranches(w http.ResponseWriter, r *http.Request) { 509 - l := rp.logger.With("handler", "RepoBranches") 510 - 511 - f, err := rp.repoResolver.Resolve(r) 512 - if err != nil { 513 - l.Error("failed to get repo and knot", "err", err) 514 - return 515 - } 516 - 517 - scheme := "http" 518 - if !rp.config.Core.Dev { 519 - scheme = "https" 520 - } 521 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 522 - xrpcc := &indigoxrpc.Client{ 523 - Host: host, 524 - } 525 - 526 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 527 - xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 528 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 529 - l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 530 - rp.pages.Error503(w) 531 - return 532 - } 533 - 534 - var result types.RepoBranchesResponse 535 - if err := json.Unmarshal(xrpcBytes, &result); err != nil { 536 - l.Error("failed to decode XRPC response", "err", err) 537 - rp.pages.Error503(w) 538 - return 539 - } 540 - 541 - sortBranches(result.Branches) 542 - 543 - user := rp.oauth.GetUser(r) 544 - rp.pages.RepoBranches(w, pages.RepoBranchesParams{ 545 - LoggedInUser: user, 546 - RepoInfo: f.RepoInfo(user), 547 - RepoBranchesResponse: result, 548 - }) 549 - } 550 - 551 - func (rp *Repo) DeleteBranch(w http.ResponseWriter, r *http.Request) { 552 - l := rp.logger.With("handler", "DeleteBranch") 553 - 554 - f, err := rp.repoResolver.Resolve(r) 555 - if err != nil { 556 - l.Error("failed to get repo and knot", "err", err) 557 - return 558 - } 559 - 560 - noticeId := "delete-branch-error" 561 - fail := func(msg string, err error) { 562 - l.Error(msg, "err", err) 563 - rp.pages.Notice(w, noticeId, msg) 564 - } 565 - 566 - branch := r.FormValue("branch") 567 - if branch == "" { 568 - fail("No branch provided.", nil) 569 - return 570 - } 571 - 572 - client, err := rp.oauth.ServiceClient( 573 - r, 574 - oauth.WithService(f.Knot), 575 - oauth.WithLxm(tangled.RepoDeleteBranchNSID), 576 - oauth.WithDev(rp.config.Core.Dev), 577 - ) 578 - if err != nil { 579 - fail("Failed to connect to knotserver", nil) 580 - return 581 - } 582 - 583 - err = tangled.RepoDeleteBranch( 584 - r.Context(), 585 - client, 586 - &tangled.RepoDeleteBranch_Input{ 587 - Branch: branch, 588 - Repo: f.RepoAt().String(), 589 - }, 590 - ) 591 - if err := xrpcclient.HandleXrpcErr(err); err != nil { 592 - fail(fmt.Sprintf("Failed to delete branch: %s", err), err) 593 - return 594 - } 595 - l.Error("deleted branch from knot", "branch", branch, "repo", f.RepoAt()) 596 - 597 - rp.pages.HxRefresh(w) 598 - } 599 - 600 - func (rp *Repo) RepoBlob(w http.ResponseWriter, r *http.Request) { 601 - l := rp.logger.With("handler", "RepoBlob") 602 - 603 - f, err := rp.repoResolver.Resolve(r) 604 - if err != nil { 605 - l.Error("failed to get repo and knot", "err", err) 606 - return 607 - } 608 - 609 - ref := chi.URLParam(r, "ref") 610 - ref, _ = url.PathUnescape(ref) 611 - 612 - filePath := chi.URLParam(r, "*") 613 - filePath, _ = url.PathUnescape(filePath) 614 - 615 - scheme := "http" 616 - if !rp.config.Core.Dev { 617 - scheme = "https" 618 - } 619 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 620 - xrpcc := &indigoxrpc.Client{ 621 - Host: host, 622 - } 623 - 624 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name) 625 - resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, repo) 626 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 627 - l.Error("failed to call XRPC repo.blob", "err", xrpcerr) 628 - rp.pages.Error503(w) 629 - return 630 - } 631 - 632 - // Use XRPC response directly instead of converting to internal types 633 - 634 - var breadcrumbs [][]string 635 - breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))}) 636 - if filePath != "" { 637 - for idx, elem := range strings.Split(filePath, "/") { 638 - breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))}) 639 - } 640 - } 641 - 642 - showRendered := false 643 - renderToggle := false 644 - 645 - if markup.GetFormat(resp.Path) == markup.FormatMarkdown { 646 - renderToggle = true 647 - showRendered = r.URL.Query().Get("code") != "true" 648 - } 649 - 650 - var unsupported bool 651 - var isImage bool 652 - var isVideo bool 653 - var contentSrc string 654 - 655 - if resp.IsBinary != nil && *resp.IsBinary { 656 - ext := strings.ToLower(filepath.Ext(resp.Path)) 657 - switch ext { 658 - case ".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp": 659 - isImage = true 660 - case ".mp4", ".webm", ".ogg", ".mov", ".avi": 661 - isVideo = true 662 - default: 663 - unsupported = true 664 - } 665 - 666 - // fetch the raw binary content using sh.tangled.repo.blob xrpc 667 - repoName := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 668 - 669 - baseURL := &url.URL{ 670 - Scheme: scheme, 671 - Host: f.Knot, 672 - Path: "/xrpc/sh.tangled.repo.blob", 673 - } 674 - query := baseURL.Query() 675 - query.Set("repo", repoName) 676 - query.Set("ref", ref) 677 - query.Set("path", filePath) 678 - query.Set("raw", "true") 679 - baseURL.RawQuery = query.Encode() 680 - blobURL := baseURL.String() 681 - 682 - contentSrc = blobURL 683 - if !rp.config.Core.Dev { 684 - contentSrc = markup.GenerateCamoURL(rp.config.Camo.Host, rp.config.Camo.SharedSecret, blobURL) 685 - } 686 - } 687 - 688 - lines := 0 689 - if resp.IsBinary == nil || !*resp.IsBinary { 690 - lines = strings.Count(resp.Content, "\n") + 1 691 - } 692 - 693 - var sizeHint uint64 694 - if resp.Size != nil { 695 - sizeHint = uint64(*resp.Size) 696 - } else { 697 - sizeHint = uint64(len(resp.Content)) 698 - } 699 - 700 - user := rp.oauth.GetUser(r) 701 - 702 - // Determine if content is binary (dereference pointer) 703 - isBinary := false 704 - if resp.IsBinary != nil { 705 - isBinary = *resp.IsBinary 706 - } 707 - 708 - rp.pages.RepoBlob(w, pages.RepoBlobParams{ 709 - LoggedInUser: user, 710 - RepoInfo: f.RepoInfo(user), 711 - BreadCrumbs: breadcrumbs, 712 - ShowRendered: showRendered, 713 - RenderToggle: renderToggle, 714 - Unsupported: unsupported, 715 - IsImage: isImage, 716 - IsVideo: isVideo, 717 - ContentSrc: contentSrc, 718 - RepoBlob_Output: resp, 719 - Contents: resp.Content, 720 - Lines: lines, 721 - SizeHint: sizeHint, 722 - IsBinary: isBinary, 723 - }) 724 - } 725 - 726 - func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) { 727 - l := rp.logger.With("handler", "RepoBlobRaw") 728 - 729 - f, err := rp.repoResolver.Resolve(r) 730 - if err != nil { 731 - l.Error("failed to get repo and knot", "err", err) 732 - w.WriteHeader(http.StatusBadRequest) 733 - return 734 - } 735 - 736 - ref := chi.URLParam(r, "ref") 737 - ref, _ = url.PathUnescape(ref) 738 - 739 - filePath := chi.URLParam(r, "*") 740 - filePath, _ = url.PathUnescape(filePath) 741 - 742 - scheme := "http" 743 - if !rp.config.Core.Dev { 744 - scheme = "https" 745 - } 746 - 747 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name) 748 - baseURL := &url.URL{ 749 - Scheme: scheme, 750 - Host: f.Knot, 751 - Path: "/xrpc/sh.tangled.repo.blob", 752 - } 753 - query := baseURL.Query() 754 - query.Set("repo", repo) 755 - query.Set("ref", ref) 756 - query.Set("path", filePath) 757 - query.Set("raw", "true") 758 - baseURL.RawQuery = query.Encode() 759 - blobURL := baseURL.String() 760 - 761 - req, err := http.NewRequest("GET", blobURL, nil) 762 - if err != nil { 763 - l.Error("failed to create request", "err", err) 764 - return 765 - } 766 - 767 - // forward the If-None-Match header 768 - if clientETag := r.Header.Get("If-None-Match"); clientETag != "" { 769 - req.Header.Set("If-None-Match", clientETag) 770 - } 771 - 772 - client := &http.Client{} 773 - resp, err := client.Do(req) 774 - if err != nil { 775 - l.Error("failed to reach knotserver", "err", err) 776 - rp.pages.Error503(w) 777 - return 778 - } 779 - defer resp.Body.Close() 780 - 781 - // forward 304 not modified 782 - if resp.StatusCode == http.StatusNotModified { 783 - w.WriteHeader(http.StatusNotModified) 784 - return 785 - } 786 - 787 - if resp.StatusCode != http.StatusOK { 788 - l.Error("knotserver returned non-OK status for raw blob", "url", blobURL, "statuscode", resp.StatusCode) 789 - w.WriteHeader(resp.StatusCode) 790 - _, _ = io.Copy(w, resp.Body) 791 - return 792 - } 793 - 794 - contentType := resp.Header.Get("Content-Type") 795 - body, err := io.ReadAll(resp.Body) 796 - if err != nil { 797 - l.Error("error reading response body from knotserver", "err", err) 798 - w.WriteHeader(http.StatusInternalServerError) 799 - return 800 - } 801 - 802 - if strings.HasPrefix(contentType, "text/") || isTextualMimeType(contentType) { 803 - // serve all textual content as text/plain 804 - w.Header().Set("Content-Type", "text/plain; charset=utf-8") 805 - w.Write(body) 806 - } else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") { 807 - // serve images and videos with their original content type 808 - w.Header().Set("Content-Type", contentType) 809 - w.Write(body) 810 - } else { 811 - w.WriteHeader(http.StatusUnsupportedMediaType) 812 - w.Write([]byte("unsupported content type")) 813 - return 814 - } 815 - } 816 - 817 - // isTextualMimeType returns true if the MIME type represents textual content 818 - // that should be served as text/plain 819 - func isTextualMimeType(mimeType string) bool { 820 - textualTypes := []string{ 821 - "application/json", 822 - "application/xml", 823 - "application/yaml", 824 - "application/x-yaml", 825 - "application/toml", 826 - "application/javascript", 827 - "application/ecmascript", 828 - "message/", 829 - } 830 - 831 - return slices.Contains(textualTypes, mimeType) 832 - } 833 - 834 81 // modify the spindle configured for this repo 835 82 func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) { 836 83 user := rp.oauth.GetUser(r) ··· 871 118 } 872 119 } 873 120 874 - newRepo := f.Repo 121 + newRepo := *f 875 122 newRepo.Spindle = newSpindle 876 123 record := newRepo.AsRecord() 877 124 ··· 1010 257 l.Info("wrote label record to PDS") 1011 258 1012 259 // update the repo to subscribe to this label 1013 - newRepo := f.Repo 260 + newRepo := *f 1014 261 newRepo.Labels = append(newRepo.Labels, aturi) 1015 262 repoRecord := newRepo.AsRecord() 1016 263 ··· 1122 369 } 1123 370 1124 371 // update repo record to remove the label reference 1125 - newRepo := f.Repo 372 + newRepo := *f 1126 373 var updated []string 1127 374 removedAt := label.AtUri().String() 1128 375 for _, l := range newRepo.Labels { ··· 1215 462 return 1216 463 } 1217 464 1218 - newRepo := f.Repo 465 + newRepo := *f 1219 466 newRepo.Labels = append(newRepo.Labels, labelAts...) 1220 467 1221 468 // dedup ··· 1230 477 return 1231 478 } 1232 479 1233 - ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey) 480 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Did, f.Rkey) 1234 481 if err != nil { 1235 482 fail("Failed to update labels, no record found on PDS.", err) 1236 483 return ··· 1302 549 } 1303 550 1304 551 // update repo record to remove the label reference 1305 - newRepo := f.Repo 552 + newRepo := *f 1306 553 var updated []string 1307 554 for _, l := range newRepo.Labels { 1308 555 if !slices.Contains(labelAts, l) { ··· 1318 565 return 1319 566 } 1320 567 1321 - ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey) 568 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Did, f.Rkey) 1322 569 if err != nil { 1323 570 fail("Failed to update labels, no record found on PDS.", err) 1324 571 return ··· 1365 612 1366 613 labelDefs, err := db.GetLabelDefinitions( 1367 614 rp.db, 1368 - db.FilterIn("at_uri", f.Repo.Labels), 615 + db.FilterIn("at_uri", f.Labels), 1369 616 db.FilterContains("scope", subject.Collection().String()), 1370 617 ) 1371 618 if err != nil { ··· 1388 635 user := rp.oauth.GetUser(r) 1389 636 rp.pages.LabelPanel(w, pages.LabelPanelParams{ 1390 637 LoggedInUser: user, 1391 - RepoInfo: f.RepoInfo(user), 638 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 1392 639 Defs: defs, 1393 640 Subject: subject.String(), 1394 641 State: state, ··· 1413 660 1414 661 labelDefs, err := db.GetLabelDefinitions( 1415 662 rp.db, 1416 - db.FilterIn("at_uri", f.Repo.Labels), 663 + db.FilterIn("at_uri", f.Labels), 1417 664 db.FilterContains("scope", subject.Collection().String()), 1418 665 ) 1419 666 if err != nil { ··· 1436 683 user := rp.oauth.GetUser(r) 1437 684 rp.pages.EditLabelPanel(w, pages.EditLabelPanelParams{ 1438 685 LoggedInUser: user, 1439 - RepoInfo: f.RepoInfo(user), 686 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 1440 687 Defs: defs, 1441 688 Subject: subject.String(), 1442 689 State: state, ··· 1617 864 r.Context(), 1618 865 client, 1619 866 &tangled.RepoDelete_Input{ 1620 - Did: f.OwnerDid(), 867 + Did: f.Did, 1621 868 Name: f.Name, 1622 869 Rkey: f.Rkey, 1623 870 }, ··· 1655 902 l.Info("removed collaborators") 1656 903 1657 904 // remove repo RBAC 1658 - err = rp.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo()) 905 + err = rp.enforcer.RemoveRepo(f.Did, f.Knot, f.DidSlashRepo()) 1659 906 if err != nil { 1660 907 rp.pages.Notice(w, noticeId, "Failed to update RBAC rules") 1661 908 return 1662 909 } 1663 910 1664 911 // remove repo from db 1665 - err = db.RemoveRepo(tx, f.OwnerDid(), f.Name) 912 + err = db.RemoveRepo(tx, f.Did, f.Name) 1666 913 if err != nil { 1667 914 rp.pages.Notice(w, noticeId, "Failed to update appview") 1668 915 return ··· 1683 930 return 1684 931 } 1685 932 1686 - rp.pages.HxRedirect(w, fmt.Sprintf("/%s", f.OwnerDid())) 1687 - } 1688 - 1689 - func (rp *Repo) EditBaseSettings(w http.ResponseWriter, r *http.Request) { 1690 - l := rp.logger.With("handler", "EditBaseSettings") 1691 - 1692 - noticeId := "repo-base-settings-error" 1693 - 1694 - f, err := rp.repoResolver.Resolve(r) 1695 - if err != nil { 1696 - l.Error("failed to get repo and knot", "err", err) 1697 - w.WriteHeader(http.StatusBadRequest) 1698 - return 1699 - } 1700 - 1701 - client, err := rp.oauth.AuthorizedClient(r) 1702 - if err != nil { 1703 - l.Error("failed to get client") 1704 - rp.pages.Notice(w, noticeId, "Failed to update repository information, try again later.") 1705 - return 1706 - } 1707 - 1708 - var ( 1709 - description = r.FormValue("description") 1710 - website = r.FormValue("website") 1711 - topicStr = r.FormValue("topics") 1712 - ) 1713 - 1714 - err = rp.validator.ValidateURI(website) 1715 - if err != nil { 1716 - l.Error("invalid uri", "err", err) 1717 - rp.pages.Notice(w, noticeId, err.Error()) 1718 - return 1719 - } 1720 - 1721 - topics, err := rp.validator.ValidateRepoTopicStr(topicStr) 1722 - if err != nil { 1723 - l.Error("invalid topics", "err", err) 1724 - rp.pages.Notice(w, noticeId, err.Error()) 1725 - return 1726 - } 1727 - l.Debug("got", "topicsStr", topicStr, "topics", topics) 1728 - 1729 - newRepo := f.Repo 1730 - newRepo.Description = description 1731 - newRepo.Website = website 1732 - newRepo.Topics = topics 1733 - record := newRepo.AsRecord() 1734 - 1735 - tx, err := rp.db.BeginTx(r.Context(), nil) 1736 - if err != nil { 1737 - l.Error("failed to begin transaction", "err", err) 1738 - rp.pages.Notice(w, noticeId, "Failed to save repository information.") 1739 - return 1740 - } 1741 - defer tx.Rollback() 1742 - 1743 - err = db.PutRepo(tx, newRepo) 1744 - if err != nil { 1745 - l.Error("failed to update repository", "err", err) 1746 - rp.pages.Notice(w, noticeId, "Failed to save repository information.") 1747 - return 1748 - } 1749 - 1750 - ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 1751 - if err != nil { 1752 - // failed to get record 1753 - l.Error("failed to get repo record", "err", err) 1754 - rp.pages.Notice(w, noticeId, "Failed to save repository information, no record found on PDS.") 1755 - return 1756 - } 1757 - _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1758 - Collection: tangled.RepoNSID, 1759 - Repo: newRepo.Did, 1760 - Rkey: newRepo.Rkey, 1761 - SwapRecord: ex.Cid, 1762 - Record: &lexutil.LexiconTypeDecoder{ 1763 - Val: &record, 1764 - }, 1765 - }) 1766 - 1767 - if err != nil { 1768 - l.Error("failed to perferom update-repo query", "err", err) 1769 - // failed to get record 1770 - rp.pages.Notice(w, noticeId, "Failed to save repository information, unable to save to PDS.") 1771 - return 1772 - } 1773 - 1774 - err = tx.Commit() 1775 - if err != nil { 1776 - l.Error("failed to commit", "err", err) 1777 - } 1778 - 1779 - rp.pages.HxRefresh(w) 1780 - } 1781 - 1782 - func (rp *Repo) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { 1783 - l := rp.logger.With("handler", "SetDefaultBranch") 1784 - 1785 - f, err := rp.repoResolver.Resolve(r) 1786 - if err != nil { 1787 - l.Error("failed to get repo and knot", "err", err) 1788 - return 1789 - } 1790 - 1791 - noticeId := "operation-error" 1792 - branch := r.FormValue("branch") 1793 - if branch == "" { 1794 - http.Error(w, "malformed form", http.StatusBadRequest) 1795 - return 1796 - } 1797 - 1798 - client, err := rp.oauth.ServiceClient( 1799 - r, 1800 - oauth.WithService(f.Knot), 1801 - oauth.WithLxm(tangled.RepoSetDefaultBranchNSID), 1802 - oauth.WithDev(rp.config.Core.Dev), 1803 - ) 1804 - if err != nil { 1805 - l.Error("failed to connect to knot server", "err", err) 1806 - rp.pages.Notice(w, noticeId, "Failed to connect to knot server.") 1807 - return 1808 - } 1809 - 1810 - xe := tangled.RepoSetDefaultBranch( 1811 - r.Context(), 1812 - client, 1813 - &tangled.RepoSetDefaultBranch_Input{ 1814 - Repo: f.RepoAt().String(), 1815 - DefaultBranch: branch, 1816 - }, 1817 - ) 1818 - if err := xrpcclient.HandleXrpcErr(xe); err != nil { 1819 - l.Error("xrpc failed", "err", xe) 1820 - rp.pages.Notice(w, noticeId, err.Error()) 1821 - return 1822 - } 1823 - 1824 - rp.pages.HxRefresh(w) 1825 - } 1826 - 1827 - func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) { 1828 - user := rp.oauth.GetUser(r) 1829 - l := rp.logger.With("handler", "Secrets") 1830 - l = l.With("did", user.Did) 1831 - 1832 - f, err := rp.repoResolver.Resolve(r) 1833 - if err != nil { 1834 - l.Error("failed to get repo and knot", "err", err) 1835 - return 1836 - } 1837 - 1838 - if f.Spindle == "" { 1839 - l.Error("empty spindle cannot add/rm secret", "err", err) 1840 - return 1841 - } 1842 - 1843 - lxm := tangled.RepoAddSecretNSID 1844 - if r.Method == http.MethodDelete { 1845 - lxm = tangled.RepoRemoveSecretNSID 1846 - } 1847 - 1848 - spindleClient, err := rp.oauth.ServiceClient( 1849 - r, 1850 - oauth.WithService(f.Spindle), 1851 - oauth.WithLxm(lxm), 1852 - oauth.WithExp(60), 1853 - oauth.WithDev(rp.config.Core.Dev), 1854 - ) 1855 - if err != nil { 1856 - l.Error("failed to create spindle client", "err", err) 1857 - return 1858 - } 1859 - 1860 - key := r.FormValue("key") 1861 - if key == "" { 1862 - w.WriteHeader(http.StatusBadRequest) 1863 - return 1864 - } 1865 - 1866 - switch r.Method { 1867 - case http.MethodPut: 1868 - errorId := "add-secret-error" 1869 - 1870 - value := r.FormValue("value") 1871 - if value == "" { 1872 - w.WriteHeader(http.StatusBadRequest) 1873 - return 1874 - } 1875 - 1876 - err = tangled.RepoAddSecret( 1877 - r.Context(), 1878 - spindleClient, 1879 - &tangled.RepoAddSecret_Input{ 1880 - Repo: f.RepoAt().String(), 1881 - Key: key, 1882 - Value: value, 1883 - }, 1884 - ) 1885 - if err != nil { 1886 - l.Error("Failed to add secret.", "err", err) 1887 - rp.pages.Notice(w, errorId, "Failed to add secret.") 1888 - return 1889 - } 1890 - 1891 - case http.MethodDelete: 1892 - errorId := "operation-error" 1893 - 1894 - err = tangled.RepoRemoveSecret( 1895 - r.Context(), 1896 - spindleClient, 1897 - &tangled.RepoRemoveSecret_Input{ 1898 - Repo: f.RepoAt().String(), 1899 - Key: key, 1900 - }, 1901 - ) 1902 - if err != nil { 1903 - l.Error("Failed to delete secret.", "err", err) 1904 - rp.pages.Notice(w, errorId, "Failed to delete secret.") 1905 - return 1906 - } 1907 - } 1908 - 1909 - rp.pages.HxRefresh(w) 1910 - } 1911 - 1912 - type tab = map[string]any 1913 - 1914 - var ( 1915 - // would be great to have ordered maps right about now 1916 - settingsTabs []tab = []tab{ 1917 - {"Name": "general", "Icon": "sliders-horizontal"}, 1918 - {"Name": "access", "Icon": "users"}, 1919 - {"Name": "pipelines", "Icon": "layers-2"}, 1920 - } 1921 - ) 1922 - 1923 - func (rp *Repo) RepoSettings(w http.ResponseWriter, r *http.Request) { 1924 - tabVal := r.URL.Query().Get("tab") 1925 - if tabVal == "" { 1926 - tabVal = "general" 1927 - } 1928 - 1929 - switch tabVal { 1930 - case "general": 1931 - rp.generalSettings(w, r) 1932 - 1933 - case "access": 1934 - rp.accessSettings(w, r) 1935 - 1936 - case "pipelines": 1937 - rp.pipelineSettings(w, r) 1938 - } 1939 - } 1940 - 1941 - func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) { 1942 - l := rp.logger.With("handler", "generalSettings") 1943 - 1944 - f, err := rp.repoResolver.Resolve(r) 1945 - user := rp.oauth.GetUser(r) 1946 - 1947 - scheme := "http" 1948 - if !rp.config.Core.Dev { 1949 - scheme = "https" 1950 - } 1951 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 1952 - xrpcc := &indigoxrpc.Client{ 1953 - Host: host, 1954 - } 1955 - 1956 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1957 - xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 1958 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1959 - l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 1960 - rp.pages.Error503(w) 1961 - return 1962 - } 1963 - 1964 - var result types.RepoBranchesResponse 1965 - if err := json.Unmarshal(xrpcBytes, &result); err != nil { 1966 - l.Error("failed to decode XRPC response", "err", err) 1967 - rp.pages.Error503(w) 1968 - return 1969 - } 1970 - 1971 - defaultLabels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", models.DefaultLabelDefs())) 1972 - if err != nil { 1973 - l.Error("failed to fetch labels", "err", err) 1974 - rp.pages.Error503(w) 1975 - return 1976 - } 1977 - 1978 - labels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Repo.Labels)) 1979 - if err != nil { 1980 - l.Error("failed to fetch labels", "err", err) 1981 - rp.pages.Error503(w) 1982 - return 1983 - } 1984 - // remove default labels from the labels list, if present 1985 - defaultLabelMap := make(map[string]bool) 1986 - for _, dl := range defaultLabels { 1987 - defaultLabelMap[dl.AtUri().String()] = true 1988 - } 1989 - n := 0 1990 - for _, l := range labels { 1991 - if !defaultLabelMap[l.AtUri().String()] { 1992 - labels[n] = l 1993 - n++ 1994 - } 1995 - } 1996 - labels = labels[:n] 1997 - 1998 - subscribedLabels := make(map[string]struct{}) 1999 - for _, l := range f.Repo.Labels { 2000 - subscribedLabels[l] = struct{}{} 2001 - } 2002 - 2003 - // if there is atleast 1 unsubbed default label, show the "subscribe all" button, 2004 - // if all default labels are subbed, show the "unsubscribe all" button 2005 - shouldSubscribeAll := false 2006 - for _, dl := range defaultLabels { 2007 - if _, ok := subscribedLabels[dl.AtUri().String()]; !ok { 2008 - // one of the default labels is not subscribed to 2009 - shouldSubscribeAll = true 2010 - break 2011 - } 2012 - } 2013 - 2014 - rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{ 2015 - LoggedInUser: user, 2016 - RepoInfo: f.RepoInfo(user), 2017 - Branches: result.Branches, 2018 - Labels: labels, 2019 - DefaultLabels: defaultLabels, 2020 - SubscribedLabels: subscribedLabels, 2021 - ShouldSubscribeAll: shouldSubscribeAll, 2022 - Tabs: settingsTabs, 2023 - Tab: "general", 2024 - }) 2025 - } 2026 - 2027 - func (rp *Repo) accessSettings(w http.ResponseWriter, r *http.Request) { 2028 - l := rp.logger.With("handler", "accessSettings") 2029 - 2030 - f, err := rp.repoResolver.Resolve(r) 2031 - user := rp.oauth.GetUser(r) 2032 - 2033 - repoCollaborators, err := f.Collaborators(r.Context()) 2034 - if err != nil { 2035 - l.Error("failed to get collaborators", "err", err) 2036 - } 2037 - 2038 - rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{ 2039 - LoggedInUser: user, 2040 - RepoInfo: f.RepoInfo(user), 2041 - Tabs: settingsTabs, 2042 - Tab: "access", 2043 - Collaborators: repoCollaborators, 2044 - }) 2045 - } 2046 - 2047 - func (rp *Repo) pipelineSettings(w http.ResponseWriter, r *http.Request) { 2048 - l := rp.logger.With("handler", "pipelineSettings") 2049 - 2050 - f, err := rp.repoResolver.Resolve(r) 2051 - user := rp.oauth.GetUser(r) 2052 - 2053 - // all spindles that the repo owner is a member of 2054 - spindles, err := rp.enforcer.GetSpindlesForUser(f.OwnerDid()) 2055 - if err != nil { 2056 - l.Error("failed to fetch spindles", "err", err) 2057 - return 2058 - } 2059 - 2060 - var secrets []*tangled.RepoListSecrets_Secret 2061 - if f.Spindle != "" { 2062 - if spindleClient, err := rp.oauth.ServiceClient( 2063 - r, 2064 - oauth.WithService(f.Spindle), 2065 - oauth.WithLxm(tangled.RepoListSecretsNSID), 2066 - oauth.WithExp(60), 2067 - oauth.WithDev(rp.config.Core.Dev), 2068 - ); err != nil { 2069 - l.Error("failed to create spindle client", "err", err) 2070 - } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil { 2071 - l.Error("failed to fetch secrets", "err", err) 2072 - } else { 2073 - secrets = resp.Secrets 2074 - } 2075 - } 2076 - 2077 - slices.SortFunc(secrets, func(a, b *tangled.RepoListSecrets_Secret) int { 2078 - return strings.Compare(a.Key, b.Key) 2079 - }) 2080 - 2081 - var dids []string 2082 - for _, s := range secrets { 2083 - dids = append(dids, s.CreatedBy) 2084 - } 2085 - resolvedIdents := rp.idResolver.ResolveIdents(r.Context(), dids) 2086 - 2087 - // convert to a more manageable form 2088 - var niceSecret []map[string]any 2089 - for id, s := range secrets { 2090 - when, _ := time.Parse(time.RFC3339, s.CreatedAt) 2091 - niceSecret = append(niceSecret, map[string]any{ 2092 - "Id": id, 2093 - "Key": s.Key, 2094 - "CreatedAt": when, 2095 - "CreatedBy": resolvedIdents[id].Handle.String(), 2096 - }) 2097 - } 2098 - 2099 - rp.pages.RepoPipelineSettings(w, pages.RepoPipelineSettingsParams{ 2100 - LoggedInUser: user, 2101 - RepoInfo: f.RepoInfo(user), 2102 - Tabs: settingsTabs, 2103 - Tab: "pipelines", 2104 - Spindles: spindles, 2105 - CurrentSpindle: f.Spindle, 2106 - Secrets: niceSecret, 2107 - }) 933 + rp.pages.HxRedirect(w, fmt.Sprintf("/%s", f.Did)) 2108 934 } 2109 935 2110 936 func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) { ··· 2133 959 return 2134 960 } 2135 961 2136 - repoInfo := f.RepoInfo(user) 2137 - if repoInfo.Source == nil { 962 + if f.Source == "" { 2138 963 rp.pages.Notice(w, "repo", "This repository is not a fork.") 2139 964 return 2140 965 } ··· 2145 970 &tangled.RepoForkSync_Input{ 2146 971 Did: user.Did, 2147 972 Name: f.Name, 2148 - Source: repoInfo.Source.RepoAt().String(), 973 + Source: f.Source, 2149 974 Branch: ref, 2150 975 }, 2151 976 ) ··· 2181 1006 rp.pages.ForkRepo(w, pages.ForkRepoParams{ 2182 1007 LoggedInUser: user, 2183 1008 Knots: knots, 2184 - RepoInfo: f.RepoInfo(user), 1009 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 2185 1010 }) 2186 1011 2187 1012 case http.MethodPost: ··· 2232 1057 uri = "http" 2233 1058 } 2234 1059 2235 - forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.Repo.Name) 1060 + forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.Did, f.Name) 2236 1061 l = l.With("cloneUrl", forkSourceUrl) 2237 1062 2238 1063 sourceAt := f.RepoAt().String() ··· 2245 1070 Knot: targetKnot, 2246 1071 Rkey: rkey, 2247 1072 Source: sourceAt, 2248 - Description: f.Repo.Description, 1073 + Description: f.Description, 2249 1074 Created: time.Now(), 2250 - Labels: models.DefaultLabelDefs(), 1075 + Labels: rp.config.Label.DefaultLabelDefs, 2251 1076 } 2252 1077 record := repo.AsRecord() 2253 1078 ··· 2304 1129 } 2305 1130 defer rollback() 2306 1131 1132 + // TODO: this could coordinate better with the knot to recieve a clone status 2307 1133 client, err := rp.oauth.ServiceClient( 2308 1134 r, 2309 1135 oauth.WithService(targetKnot), 2310 1136 oauth.WithLxm(tangled.RepoCreateNSID), 2311 1137 oauth.WithDev(rp.config.Core.Dev), 1138 + oauth.WithTimeout(time.Second*20), // big repos take time to clone 2312 1139 ) 2313 1140 if err != nil { 2314 1141 l.Error("could not create service client", "err", err) ··· 2388 1215 }) 2389 1216 return err 2390 1217 } 2391 - 2392 - func (rp *Repo) RepoCompareNew(w http.ResponseWriter, r *http.Request) { 2393 - l := rp.logger.With("handler", "RepoCompareNew") 2394 - 2395 - user := rp.oauth.GetUser(r) 2396 - f, err := rp.repoResolver.Resolve(r) 2397 - if err != nil { 2398 - l.Error("failed to get repo and knot", "err", err) 2399 - return 2400 - } 2401 - 2402 - scheme := "http" 2403 - if !rp.config.Core.Dev { 2404 - scheme = "https" 2405 - } 2406 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 2407 - xrpcc := &indigoxrpc.Client{ 2408 - Host: host, 2409 - } 2410 - 2411 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 2412 - branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 2413 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2414 - l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 2415 - rp.pages.Error503(w) 2416 - return 2417 - } 2418 - 2419 - var branchResult types.RepoBranchesResponse 2420 - if err := json.Unmarshal(branchBytes, &branchResult); err != nil { 2421 - l.Error("failed to decode XRPC branches response", "err", err) 2422 - rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2423 - return 2424 - } 2425 - branches := branchResult.Branches 2426 - 2427 - sortBranches(branches) 2428 - 2429 - var defaultBranch string 2430 - for _, b := range branches { 2431 - if b.IsDefault { 2432 - defaultBranch = b.Name 2433 - } 2434 - } 2435 - 2436 - base := defaultBranch 2437 - head := defaultBranch 2438 - 2439 - params := r.URL.Query() 2440 - queryBase := params.Get("base") 2441 - queryHead := params.Get("head") 2442 - if queryBase != "" { 2443 - base = queryBase 2444 - } 2445 - if queryHead != "" { 2446 - head = queryHead 2447 - } 2448 - 2449 - tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 2450 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2451 - l.Error("failed to call XRPC repo.tags", "err", xrpcerr) 2452 - rp.pages.Error503(w) 2453 - return 2454 - } 2455 - 2456 - var tags types.RepoTagsResponse 2457 - if err := json.Unmarshal(tagBytes, &tags); err != nil { 2458 - l.Error("failed to decode XRPC tags response", "err", err) 2459 - rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2460 - return 2461 - } 2462 - 2463 - repoinfo := f.RepoInfo(user) 2464 - 2465 - rp.pages.RepoCompareNew(w, pages.RepoCompareNewParams{ 2466 - LoggedInUser: user, 2467 - RepoInfo: repoinfo, 2468 - Branches: branches, 2469 - Tags: tags.Tags, 2470 - Base: base, 2471 - Head: head, 2472 - }) 2473 - } 2474 - 2475 - func (rp *Repo) RepoCompare(w http.ResponseWriter, r *http.Request) { 2476 - l := rp.logger.With("handler", "RepoCompare") 2477 - 2478 - user := rp.oauth.GetUser(r) 2479 - f, err := rp.repoResolver.Resolve(r) 2480 - if err != nil { 2481 - l.Error("failed to get repo and knot", "err", err) 2482 - return 2483 - } 2484 - 2485 - var diffOpts types.DiffOpts 2486 - if d := r.URL.Query().Get("diff"); d == "split" { 2487 - diffOpts.Split = true 2488 - } 2489 - 2490 - // if user is navigating to one of 2491 - // /compare/{base}/{head} 2492 - // /compare/{base}...{head} 2493 - base := chi.URLParam(r, "base") 2494 - head := chi.URLParam(r, "head") 2495 - if base == "" && head == "" { 2496 - rest := chi.URLParam(r, "*") // master...feature/xyz 2497 - parts := strings.SplitN(rest, "...", 2) 2498 - if len(parts) == 2 { 2499 - base = parts[0] 2500 - head = parts[1] 2501 - } 2502 - } 2503 - 2504 - base, _ = url.PathUnescape(base) 2505 - head, _ = url.PathUnescape(head) 2506 - 2507 - if base == "" || head == "" { 2508 - l.Error("invalid comparison") 2509 - rp.pages.Error404(w) 2510 - return 2511 - } 2512 - 2513 - scheme := "http" 2514 - if !rp.config.Core.Dev { 2515 - scheme = "https" 2516 - } 2517 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 2518 - xrpcc := &indigoxrpc.Client{ 2519 - Host: host, 2520 - } 2521 - 2522 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 2523 - 2524 - branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 2525 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2526 - l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 2527 - rp.pages.Error503(w) 2528 - return 2529 - } 2530 - 2531 - var branches types.RepoBranchesResponse 2532 - if err := json.Unmarshal(branchBytes, &branches); err != nil { 2533 - l.Error("failed to decode XRPC branches response", "err", err) 2534 - rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2535 - return 2536 - } 2537 - 2538 - tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 2539 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2540 - l.Error("failed to call XRPC repo.tags", "err", xrpcerr) 2541 - rp.pages.Error503(w) 2542 - return 2543 - } 2544 - 2545 - var tags types.RepoTagsResponse 2546 - if err := json.Unmarshal(tagBytes, &tags); err != nil { 2547 - l.Error("failed to decode XRPC tags response", "err", err) 2548 - rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2549 - return 2550 - } 2551 - 2552 - compareBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, base, head) 2553 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2554 - l.Error("failed to call XRPC repo.compare", "err", xrpcerr) 2555 - rp.pages.Error503(w) 2556 - return 2557 - } 2558 - 2559 - var formatPatch types.RepoFormatPatchResponse 2560 - if err := json.Unmarshal(compareBytes, &formatPatch); err != nil { 2561 - l.Error("failed to decode XRPC compare response", "err", err) 2562 - rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2563 - return 2564 - } 2565 - 2566 - var diff types.NiceDiff 2567 - if formatPatch.CombinedPatchRaw != "" { 2568 - diff = patchutil.AsNiceDiff(formatPatch.CombinedPatchRaw, base) 2569 - } else { 2570 - diff = patchutil.AsNiceDiff(formatPatch.FormatPatchRaw, base) 2571 - } 2572 - 2573 - repoinfo := f.RepoInfo(user) 2574 - 2575 - rp.pages.RepoCompare(w, pages.RepoCompareParams{ 2576 - LoggedInUser: user, 2577 - RepoInfo: repoinfo, 2578 - Branches: branches.Branches, 2579 - Tags: tags.Tags, 2580 - Base: base, 2581 - Head: head, 2582 - Diff: &diff, 2583 - DiffOpts: diffOpts, 2584 - }) 2585 - 2586 - }
+7 -21
appview/repo/repo_util.go
··· 1 1 package repo 2 2 3 3 import ( 4 - "crypto/rand" 5 - "math/big" 6 4 "slices" 7 5 "sort" 8 6 "strings" 9 7 10 8 "tangled.org/core/appview/db" 11 9 "tangled.org/core/appview/models" 12 - "tangled.org/core/appview/pages/repoinfo" 13 10 "tangled.org/core/types" 14 11 15 12 "github.com/go-git/go-git/v5/plumbing/object" ··· 17 14 18 15 func sortFiles(files []types.NiceTree) { 19 16 sort.Slice(files, func(i, j int) bool { 20 - iIsFile := files[i].IsFile 21 - jIsFile := files[j].IsFile 17 + iIsFile := files[i].IsFile() 18 + jIsFile := files[j].IsFile() 22 19 if iIsFile != jIsFile { 23 20 return !iIsFile 24 21 } ··· 90 87 return 91 88 } 92 89 93 - func randomString(n int) string { 94 - const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" 95 - result := make([]byte, n) 96 - 97 - for i := 0; i < n; i++ { 98 - n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letters)))) 99 - result[i] = letters[n.Int64()] 100 - } 101 - 102 - return string(result) 103 - } 104 - 105 90 // grab pipelines from DB and munge that into a hashmap with commit sha as key 106 91 // 107 92 // golang is so blessed that it requires 35 lines of imperative code for this 108 93 func getPipelineStatuses( 109 94 d *db.DB, 110 - repoInfo repoinfo.RepoInfo, 95 + repo *models.Repo, 111 96 shas []string, 112 97 ) (map[string]models.Pipeline, error) { 113 98 m := make(map[string]models.Pipeline) ··· 118 103 119 104 ps, err := db.GetPipelineStatuses( 120 105 d, 121 - db.FilterEq("repo_owner", repoInfo.OwnerDid), 122 - db.FilterEq("repo_name", repoInfo.Name), 123 - db.FilterEq("knot", repoInfo.Knot), 106 + len(shas), 107 + db.FilterEq("repo_owner", repo.Did), 108 + db.FilterEq("repo_name", repo.Name), 109 + db.FilterEq("knot", repo.Knot), 124 110 db.FilterIn("sha", shas), 125 111 ) 126 112 if err != nil {
+13 -14
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 ··· 75 74 r.Group(func(r chi.Router) { 76 75 r.Use(middleware.AuthMiddleware(rp.oauth)) 77 76 r.With(mw.RepoPermissionMiddleware("repo:settings")).Route("/settings", func(r chi.Router) { 78 - r.Get("/", rp.RepoSettings) 77 + r.Get("/", rp.Settings) 79 78 r.With(mw.RepoPermissionMiddleware("repo:owner")).Put("/base", rp.EditBaseSettings) 80 79 r.With(mw.RepoPermissionMiddleware("repo:owner")).Post("/spindle", rp.EditSpindle) 81 80 r.With(mw.RepoPermissionMiddleware("repo:owner")).Put("/label", rp.AddLabelDef)
+470
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/types" 18 + 19 + comatproto "github.com/bluesky-social/indigo/api/atproto" 20 + lexutil "github.com/bluesky-social/indigo/lex/util" 21 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 22 + ) 23 + 24 + type tab = map[string]any 25 + 26 + var ( 27 + // would be great to have ordered maps right about now 28 + settingsTabs []tab = []tab{ 29 + {"Name": "general", "Icon": "sliders-horizontal"}, 30 + {"Name": "access", "Icon": "users"}, 31 + {"Name": "pipelines", "Icon": "layers-2"}, 32 + } 33 + ) 34 + 35 + func (rp *Repo) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { 36 + l := rp.logger.With("handler", "SetDefaultBranch") 37 + 38 + f, err := rp.repoResolver.Resolve(r) 39 + if err != nil { 40 + l.Error("failed to get repo and knot", "err", err) 41 + return 42 + } 43 + 44 + noticeId := "operation-error" 45 + branch := r.FormValue("branch") 46 + if branch == "" { 47 + http.Error(w, "malformed form", http.StatusBadRequest) 48 + return 49 + } 50 + 51 + client, err := rp.oauth.ServiceClient( 52 + r, 53 + oauth.WithService(f.Knot), 54 + oauth.WithLxm(tangled.RepoSetDefaultBranchNSID), 55 + oauth.WithDev(rp.config.Core.Dev), 56 + ) 57 + if err != nil { 58 + l.Error("failed to connect to knot server", "err", err) 59 + rp.pages.Notice(w, noticeId, "Failed to connect to knot server.") 60 + return 61 + } 62 + 63 + xe := tangled.RepoSetDefaultBranch( 64 + r.Context(), 65 + client, 66 + &tangled.RepoSetDefaultBranch_Input{ 67 + Repo: f.RepoAt().String(), 68 + DefaultBranch: branch, 69 + }, 70 + ) 71 + if err := xrpcclient.HandleXrpcErr(xe); err != nil { 72 + l.Error("xrpc failed", "err", xe) 73 + rp.pages.Notice(w, noticeId, err.Error()) 74 + return 75 + } 76 + 77 + rp.pages.HxRefresh(w) 78 + } 79 + 80 + func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) { 81 + user := rp.oauth.GetUser(r) 82 + l := rp.logger.With("handler", "Secrets") 83 + l = l.With("did", user.Did) 84 + 85 + f, err := rp.repoResolver.Resolve(r) 86 + if err != nil { 87 + l.Error("failed to get repo and knot", "err", err) 88 + return 89 + } 90 + 91 + if f.Spindle == "" { 92 + l.Error("empty spindle cannot add/rm secret", "err", err) 93 + return 94 + } 95 + 96 + lxm := tangled.RepoAddSecretNSID 97 + if r.Method == http.MethodDelete { 98 + lxm = tangled.RepoRemoveSecretNSID 99 + } 100 + 101 + spindleClient, err := rp.oauth.ServiceClient( 102 + r, 103 + oauth.WithService(f.Spindle), 104 + oauth.WithLxm(lxm), 105 + oauth.WithExp(60), 106 + oauth.WithDev(rp.config.Core.Dev), 107 + ) 108 + if err != nil { 109 + l.Error("failed to create spindle client", "err", err) 110 + return 111 + } 112 + 113 + key := r.FormValue("key") 114 + if key == "" { 115 + w.WriteHeader(http.StatusBadRequest) 116 + return 117 + } 118 + 119 + switch r.Method { 120 + case http.MethodPut: 121 + errorId := "add-secret-error" 122 + 123 + value := r.FormValue("value") 124 + if value == "" { 125 + w.WriteHeader(http.StatusBadRequest) 126 + return 127 + } 128 + 129 + err = tangled.RepoAddSecret( 130 + r.Context(), 131 + spindleClient, 132 + &tangled.RepoAddSecret_Input{ 133 + Repo: f.RepoAt().String(), 134 + Key: key, 135 + Value: value, 136 + }, 137 + ) 138 + if err != nil { 139 + l.Error("Failed to add secret.", "err", err) 140 + rp.pages.Notice(w, errorId, "Failed to add secret.") 141 + return 142 + } 143 + 144 + case http.MethodDelete: 145 + errorId := "operation-error" 146 + 147 + err = tangled.RepoRemoveSecret( 148 + r.Context(), 149 + spindleClient, 150 + &tangled.RepoRemoveSecret_Input{ 151 + Repo: f.RepoAt().String(), 152 + Key: key, 153 + }, 154 + ) 155 + if err != nil { 156 + l.Error("Failed to delete secret.", "err", err) 157 + rp.pages.Notice(w, errorId, "Failed to delete secret.") 158 + return 159 + } 160 + } 161 + 162 + rp.pages.HxRefresh(w) 163 + } 164 + 165 + func (rp *Repo) Settings(w http.ResponseWriter, r *http.Request) { 166 + tabVal := r.URL.Query().Get("tab") 167 + if tabVal == "" { 168 + tabVal = "general" 169 + } 170 + 171 + switch tabVal { 172 + case "general": 173 + rp.generalSettings(w, r) 174 + 175 + case "access": 176 + rp.accessSettings(w, r) 177 + 178 + case "pipelines": 179 + rp.pipelineSettings(w, r) 180 + } 181 + } 182 + 183 + func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) { 184 + l := rp.logger.With("handler", "generalSettings") 185 + 186 + f, err := rp.repoResolver.Resolve(r) 187 + user := rp.oauth.GetUser(r) 188 + 189 + scheme := "http" 190 + if !rp.config.Core.Dev { 191 + scheme = "https" 192 + } 193 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 194 + xrpcc := &indigoxrpc.Client{ 195 + Host: host, 196 + } 197 + 198 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 199 + xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 200 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 201 + l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 202 + rp.pages.Error503(w) 203 + return 204 + } 205 + 206 + var result types.RepoBranchesResponse 207 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 208 + l.Error("failed to decode XRPC response", "err", err) 209 + rp.pages.Error503(w) 210 + return 211 + } 212 + 213 + defaultLabels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", rp.config.Label.DefaultLabelDefs)) 214 + if err != nil { 215 + l.Error("failed to fetch labels", "err", err) 216 + rp.pages.Error503(w) 217 + return 218 + } 219 + 220 + labels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Labels)) 221 + if err != nil { 222 + l.Error("failed to fetch labels", "err", err) 223 + rp.pages.Error503(w) 224 + return 225 + } 226 + // remove default labels from the labels list, if present 227 + defaultLabelMap := make(map[string]bool) 228 + for _, dl := range defaultLabels { 229 + defaultLabelMap[dl.AtUri().String()] = true 230 + } 231 + n := 0 232 + for _, l := range labels { 233 + if !defaultLabelMap[l.AtUri().String()] { 234 + labels[n] = l 235 + n++ 236 + } 237 + } 238 + labels = labels[:n] 239 + 240 + subscribedLabels := make(map[string]struct{}) 241 + for _, l := range f.Labels { 242 + subscribedLabels[l] = struct{}{} 243 + } 244 + 245 + // if there is atleast 1 unsubbed default label, show the "subscribe all" button, 246 + // if all default labels are subbed, show the "unsubscribe all" button 247 + shouldSubscribeAll := false 248 + for _, dl := range defaultLabels { 249 + if _, ok := subscribedLabels[dl.AtUri().String()]; !ok { 250 + // one of the default labels is not subscribed to 251 + shouldSubscribeAll = true 252 + break 253 + } 254 + } 255 + 256 + rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{ 257 + LoggedInUser: user, 258 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 259 + Branches: result.Branches, 260 + Labels: labels, 261 + DefaultLabels: defaultLabels, 262 + SubscribedLabels: subscribedLabels, 263 + ShouldSubscribeAll: shouldSubscribeAll, 264 + Tabs: settingsTabs, 265 + Tab: "general", 266 + }) 267 + } 268 + 269 + func (rp *Repo) accessSettings(w http.ResponseWriter, r *http.Request) { 270 + l := rp.logger.With("handler", "accessSettings") 271 + 272 + f, err := rp.repoResolver.Resolve(r) 273 + user := rp.oauth.GetUser(r) 274 + 275 + collaborators, err := func(repo *models.Repo) ([]pages.Collaborator, error) { 276 + repoCollaborators, err := rp.enforcer.E.GetImplicitUsersForResourceByDomain(repo.DidSlashRepo(), repo.Knot) 277 + if err != nil { 278 + return nil, err 279 + } 280 + var collaborators []pages.Collaborator 281 + for _, item := range repoCollaborators { 282 + // currently only two roles: owner and member 283 + var role string 284 + switch item[3] { 285 + case "repo:owner": 286 + role = "owner" 287 + case "repo:collaborator": 288 + role = "collaborator" 289 + default: 290 + continue 291 + } 292 + 293 + did := item[0] 294 + 295 + c := pages.Collaborator{ 296 + Did: did, 297 + Role: role, 298 + } 299 + collaborators = append(collaborators, c) 300 + } 301 + return collaborators, nil 302 + }(f) 303 + if err != nil { 304 + l.Error("failed to get collaborators", "err", err) 305 + } 306 + 307 + rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{ 308 + LoggedInUser: user, 309 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 310 + Tabs: settingsTabs, 311 + Tab: "access", 312 + Collaborators: collaborators, 313 + }) 314 + } 315 + 316 + func (rp *Repo) pipelineSettings(w http.ResponseWriter, r *http.Request) { 317 + l := rp.logger.With("handler", "pipelineSettings") 318 + 319 + f, err := rp.repoResolver.Resolve(r) 320 + user := rp.oauth.GetUser(r) 321 + 322 + // all spindles that the repo owner is a member of 323 + spindles, err := rp.enforcer.GetSpindlesForUser(f.Did) 324 + if err != nil { 325 + l.Error("failed to fetch spindles", "err", err) 326 + return 327 + } 328 + 329 + var secrets []*tangled.RepoListSecrets_Secret 330 + if f.Spindle != "" { 331 + if spindleClient, err := rp.oauth.ServiceClient( 332 + r, 333 + oauth.WithService(f.Spindle), 334 + oauth.WithLxm(tangled.RepoListSecretsNSID), 335 + oauth.WithExp(60), 336 + oauth.WithDev(rp.config.Core.Dev), 337 + ); err != nil { 338 + l.Error("failed to create spindle client", "err", err) 339 + } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil { 340 + l.Error("failed to fetch secrets", "err", err) 341 + } else { 342 + secrets = resp.Secrets 343 + } 344 + } 345 + 346 + slices.SortFunc(secrets, func(a, b *tangled.RepoListSecrets_Secret) int { 347 + return strings.Compare(a.Key, b.Key) 348 + }) 349 + 350 + var dids []string 351 + for _, s := range secrets { 352 + dids = append(dids, s.CreatedBy) 353 + } 354 + resolvedIdents := rp.idResolver.ResolveIdents(r.Context(), dids) 355 + 356 + // convert to a more manageable form 357 + var niceSecret []map[string]any 358 + for id, s := range secrets { 359 + when, _ := time.Parse(time.RFC3339, s.CreatedAt) 360 + niceSecret = append(niceSecret, map[string]any{ 361 + "Id": id, 362 + "Key": s.Key, 363 + "CreatedAt": when, 364 + "CreatedBy": resolvedIdents[id].Handle.String(), 365 + }) 366 + } 367 + 368 + rp.pages.RepoPipelineSettings(w, pages.RepoPipelineSettingsParams{ 369 + LoggedInUser: user, 370 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 371 + Tabs: settingsTabs, 372 + Tab: "pipelines", 373 + Spindles: spindles, 374 + CurrentSpindle: f.Spindle, 375 + Secrets: niceSecret, 376 + }) 377 + } 378 + 379 + func (rp *Repo) EditBaseSettings(w http.ResponseWriter, r *http.Request) { 380 + l := rp.logger.With("handler", "EditBaseSettings") 381 + 382 + noticeId := "repo-base-settings-error" 383 + 384 + f, err := rp.repoResolver.Resolve(r) 385 + if err != nil { 386 + l.Error("failed to get repo and knot", "err", err) 387 + w.WriteHeader(http.StatusBadRequest) 388 + return 389 + } 390 + 391 + client, err := rp.oauth.AuthorizedClient(r) 392 + if err != nil { 393 + l.Error("failed to get client") 394 + rp.pages.Notice(w, noticeId, "Failed to update repository information, try again later.") 395 + return 396 + } 397 + 398 + var ( 399 + description = r.FormValue("description") 400 + website = r.FormValue("website") 401 + topicStr = r.FormValue("topics") 402 + ) 403 + 404 + err = rp.validator.ValidateURI(website) 405 + if website != "" && err != nil { 406 + l.Error("invalid uri", "err", err) 407 + rp.pages.Notice(w, noticeId, err.Error()) 408 + return 409 + } 410 + 411 + topics, err := rp.validator.ValidateRepoTopicStr(topicStr) 412 + if err != nil { 413 + l.Error("invalid topics", "err", err) 414 + rp.pages.Notice(w, noticeId, err.Error()) 415 + return 416 + } 417 + l.Debug("got", "topicsStr", topicStr, "topics", topics) 418 + 419 + newRepo := *f 420 + newRepo.Description = description 421 + newRepo.Website = website 422 + newRepo.Topics = topics 423 + record := newRepo.AsRecord() 424 + 425 + tx, err := rp.db.BeginTx(r.Context(), nil) 426 + if err != nil { 427 + l.Error("failed to begin transaction", "err", err) 428 + rp.pages.Notice(w, noticeId, "Failed to save repository information.") 429 + return 430 + } 431 + defer tx.Rollback() 432 + 433 + err = db.PutRepo(tx, newRepo) 434 + if err != nil { 435 + l.Error("failed to update repository", "err", err) 436 + rp.pages.Notice(w, noticeId, "Failed to save repository information.") 437 + return 438 + } 439 + 440 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 441 + if err != nil { 442 + // failed to get record 443 + l.Error("failed to get repo record", "err", err) 444 + rp.pages.Notice(w, noticeId, "Failed to save repository information, no record found on PDS.") 445 + return 446 + } 447 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 448 + Collection: tangled.RepoNSID, 449 + Repo: newRepo.Did, 450 + Rkey: newRepo.Rkey, 451 + SwapRecord: ex.Cid, 452 + Record: &lexutil.LexiconTypeDecoder{ 453 + Val: &record, 454 + }, 455 + }) 456 + 457 + if err != nil { 458 + l.Error("failed to perferom update-repo query", "err", err) 459 + // failed to get record 460 + rp.pages.Notice(w, noticeId, "Failed to save repository information, unable to save to PDS.") 461 + return 462 + } 463 + 464 + err = tx.Commit() 465 + if err != nil { 466 + l.Error("failed to commit", "err", err) 467 + } 468 + 469 + rp.pages.HxRefresh(w) 470 + }
+79
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/types" 14 + 15 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 16 + "github.com/go-git/go-git/v5/plumbing" 17 + ) 18 + 19 + func (rp *Repo) Tags(w http.ResponseWriter, r *http.Request) { 20 + l := rp.logger.With("handler", "RepoTags") 21 + f, err := rp.repoResolver.Resolve(r) 22 + if err != nil { 23 + l.Error("failed to get repo and knot", "err", err) 24 + return 25 + } 26 + scheme := "http" 27 + if !rp.config.Core.Dev { 28 + scheme = "https" 29 + } 30 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 31 + xrpcc := &indigoxrpc.Client{ 32 + Host: host, 33 + } 34 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 35 + xrpcBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 36 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 37 + l.Error("failed to call XRPC repo.tags", "err", xrpcerr) 38 + rp.pages.Error503(w) 39 + return 40 + } 41 + var result types.RepoTagsResponse 42 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 43 + l.Error("failed to decode XRPC response", "err", err) 44 + rp.pages.Error503(w) 45 + return 46 + } 47 + artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt())) 48 + if err != nil { 49 + l.Error("failed grab artifacts", "err", err) 50 + return 51 + } 52 + // convert artifacts to map for easy UI building 53 + artifactMap := make(map[plumbing.Hash][]models.Artifact) 54 + for _, a := range artifacts { 55 + artifactMap[a.Tag] = append(artifactMap[a.Tag], a) 56 + } 57 + var danglingArtifacts []models.Artifact 58 + for _, a := range artifacts { 59 + found := false 60 + for _, t := range result.Tags { 61 + if t.Tag != nil { 62 + if t.Tag.Hash == a.Tag { 63 + found = true 64 + } 65 + } 66 + } 67 + if !found { 68 + danglingArtifacts = append(danglingArtifacts, a) 69 + } 70 + } 71 + user := rp.oauth.GetUser(r) 72 + rp.pages.RepoTags(w, pages.RepoTagsParams{ 73 + LoggedInUser: user, 74 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 75 + RepoTagsResponse: result, 76 + ArtifactMap: artifactMap, 77 + DanglingArtifacts: danglingArtifacts, 78 + }) 79 + }
+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 + }
+76 -164
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 70 - } 71 - 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() 51 + return repo, nil 78 52 } 79 53 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 := path.Dir(extractPathAfterRef(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) 132 90 } 133 - } 134 - 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) 91 + stats = &models.RepoStats{ 92 + StarCount: starCount, 93 + IssueCount: issueCount, 94 + PullCount: pullCount, 95 + } 164 96 } 165 97 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 - Website: f.Website, 192 - Topics: f.Topics, 193 - IsStarred: isStarred, 194 - Knot: knot, 195 - Spindle: f.Spindle, 196 - Roles: f.RolesInRepo(user), 197 - Stats: models.RepoStats{ 198 - StarCount: starCount, 199 - IssueCount: issueCount, 200 - PullCount: pullCount, 201 - }, 202 - CurrentDir: f.CurrentDir, 203 - Ref: f.Ref, 204 - } 123 + // page context 124 + CurrentDir: currentDir, 125 + Ref: ref, 205 126 206 - if sourceRepo != nil { 207 - repoInfo.Source = sourceRepo 208 - repoInfo.SourceHandle = sourceHandle.Handle.String() 127 + // info related to the session 128 + IsStarred: isStarred, 129 + Roles: roles, 209 130 } 210 131 211 132 return repoInfo 212 - } 213 - 214 - func (f *ResolvedRepo) RolesInRepo(u *oauth.User) repoinfo.RolesInRepo { 215 - if u != nil { 216 - r := f.rr.enforcer.GetPermissionsInRepo(u.Did, f.Knot, f.DidSlashRepo()) 217 - return repoinfo.RolesInRepo{Roles: r} 218 - } else { 219 - return repoinfo.RolesInRepo{} 220 - } 221 133 } 222 134 223 135 // extractPathAfterRef gets the actual repository path
+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
+19 -2
appview/spindles/spindles.go
··· 38 38 Logger *slog.Logger 39 39 } 40 40 41 + type tab = map[string]any 42 + 43 + var ( 44 + spindlesTabs []tab = []tab{ 45 + {"Name": "profile", "Icon": "user"}, 46 + {"Name": "keys", "Icon": "key"}, 47 + {"Name": "emails", "Icon": "mail"}, 48 + {"Name": "notifications", "Icon": "bell"}, 49 + {"Name": "knots", "Icon": "volleyball"}, 50 + {"Name": "spindles", "Icon": "spool"}, 51 + } 52 + ) 53 + 41 54 func (s *Spindles) Router() http.Handler { 42 55 r := chi.NewRouter() 43 56 ··· 69 82 s.Pages.Spindles(w, pages.SpindlesParams{ 70 83 LoggedInUser: user, 71 84 Spindles: all, 85 + Tabs: spindlesTabs, 86 + Tab: "spindles", 72 87 }) 73 88 } 74 89 ··· 127 142 Spindle: spindle, 128 143 Members: members, 129 144 Repos: repoMap, 145 + Tabs: spindlesTabs, 146 + Tab: "spindles", 130 147 }) 131 148 } 132 149 ··· 365 382 366 383 shouldRedirect := r.Header.Get("shouldRedirect") 367 384 if shouldRedirect == "true" { 368 - s.Pages.HxRedirect(w, "/spindles") 385 + s.Pages.HxRedirect(w, "/settings/spindles") 369 386 return 370 387 } 371 388 ··· 581 598 } 582 599 583 600 // success 584 - s.Pages.HxRedirect(w, fmt.Sprintf("/spindles/%s", instance)) 601 + s.Pages.HxRedirect(w, fmt.Sprintf("/settings/spindles/%s", instance)) 585 602 } 586 603 587 604 func (s *Spindles) removeMember(w http.ResponseWriter, r *http.Request) {
+10 -4
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" ··· 20 18 21 19 page := pagination.FromContext(r.Context()) 22 20 23 - goodFirstIssueLabel := fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "good-first-issue") 21 + goodFirstIssueLabel := s.config.Label.GoodFirstIssue 22 + 23 + gfiLabelDef, err := db.GetLabelDefinition(s.db, db.FilterEq("at_uri", goodFirstIssueLabel)) 24 + if err != nil { 25 + log.Println("failed to get gfi label def", err) 26 + s.pages.Error500(w) 27 + return 28 + } 24 29 25 30 repoLabels, err := db.GetRepoLabels(s.db, db.FilterEq("label_at", goodFirstIssueLabel)) 26 31 if err != nil { ··· 35 40 RepoGroups: []*models.RepoGroup{}, 36 41 LabelDefs: make(map[string]*models.LabelDefinition), 37 42 Page: page, 43 + GfiLabel: gfiLabelDef, 38 44 }) 39 45 return 40 46 } ··· 143 149 RepoGroups: paginatedGroups, 144 150 LabelDefs: labelDefsMap, 145 151 Page: page, 146 - GfiLabel: labelDefsMap[goodFirstIssueLabel], 152 + GfiLabel: gfiLabelDef, 147 153 }) 148 154 }
+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 }
+17 -11
appview/state/profile.go
··· 66 66 return nil, fmt.Errorf("failed to get string count: %w", err) 67 67 } 68 68 69 - starredCount, err := db.CountStars(s.db, db.FilterEq("starred_by_did", did)) 69 + starredCount, err := db.CountStars(s.db, db.FilterEq("did", did)) 70 70 if err != nil { 71 71 return nil, fmt.Errorf("failed to get starred repo count: %w", err) 72 72 } ··· 96 96 97 97 return &pages.ProfileCard{ 98 98 UserDid: did, 99 - UserHandle: ident.Handle.String(), 100 99 Profile: profile, 101 100 FollowStatus: followStatus, 102 101 Stats: pages.ProfileStats{ ··· 119 118 s.pages.Error500(w) 120 119 return 121 120 } 122 - l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 121 + l = l.With("profileDid", profile.UserDid) 123 122 124 123 repos, err := db.GetRepos( 125 124 s.db, ··· 160 159 timeline, err := db.MakeProfileTimeline(s.db, profile.UserDid) 161 160 if err != nil { 162 161 l.Error("failed to create timeline", "err", err) 162 + } 163 + 164 + // populate commit counts in the timeline, using the punchcard 165 + currentMonth := time.Now().Month() 166 + for _, p := range profile.Punchcard.Punches { 167 + idx := currentMonth - p.Date.Month() 168 + if int(idx) < len(timeline.ByMonth) { 169 + timeline.ByMonth[idx].Commits += p.Count 170 + } 163 171 } 164 172 165 173 s.pages.ProfileOverview(w, pages.ProfileOverviewParams{ ··· 180 188 s.pages.Error500(w) 181 189 return 182 190 } 183 - l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 191 + l = l.With("profileDid", profile.UserDid) 184 192 185 193 repos, err := db.GetRepos( 186 194 s.db, ··· 209 217 s.pages.Error500(w) 210 218 return 211 219 } 212 - l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 220 + l = l.With("profileDid", profile.UserDid) 213 221 214 - stars, err := db.GetStars(s.db, 0, db.FilterEq("starred_by_did", profile.UserDid)) 222 + stars, err := db.GetRepoStars(s.db, 0, db.FilterEq("did", profile.UserDid)) 215 223 if err != nil { 216 224 l.Error("failed to get stars", "err", err) 217 225 s.pages.Error500(w) ··· 219 227 } 220 228 var repos []models.Repo 221 229 for _, s := range stars { 222 - if s.Repo != nil { 223 - repos = append(repos, *s.Repo) 224 - } 230 + repos = append(repos, *s.Repo) 225 231 } 226 232 227 233 err = s.pages.ProfileStarred(w, pages.ProfileStarredParams{ ··· 240 246 s.pages.Error500(w) 241 247 return 242 248 } 243 - l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 249 + l = l.With("profileDid", profile.UserDid) 244 250 245 251 strings, err := db.GetStrings(s.db, 0, db.FilterEq("did", profile.UserDid)) 246 252 if err != nil { ··· 272 278 if err != nil { 273 279 return nil, err 274 280 } 275 - l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 281 + l = l.With("profileDid", profile.UserDid) 276 282 277 283 loggedInUser := s.oauth.GetUser(r) 278 284 params := FollowsPageParams{
+19 -11
appview/state/router.go
··· 57 57 if userutil.IsFlattenedDid(firstPart) { 58 58 unflattenedDid := userutil.UnflattenDid(firstPart) 59 59 redirectPath := strings.Join(append([]string{unflattenedDid}, pathParts[1:]...), "/") 60 - http.Redirect(w, r, "/"+redirectPath, http.StatusFound) 60 + 61 + redirectURL := *r.URL 62 + redirectURL.Path = "/" + redirectPath 63 + 64 + http.Redirect(w, r, redirectURL.String(), http.StatusFound) 61 65 return 62 66 } 63 67 64 68 // if using a handle with @, rewrite to work without @ 65 69 if normalized := strings.TrimPrefix(firstPart, "@"); userutil.IsHandle(normalized) { 66 70 redirectPath := strings.Join(append([]string{normalized}, pathParts[1:]...), "/") 67 - http.Redirect(w, r, "/"+redirectPath, http.StatusFound) 71 + 72 + redirectURL := *r.URL 73 + redirectURL.Path = "/" + redirectPath 74 + 75 + http.Redirect(w, r, redirectURL.String(), http.StatusFound) 68 76 return 69 77 } 78 + 70 79 } 71 80 72 81 standardRouter.ServeHTTP(w, r) ··· 82 91 r.Get("/", s.Profile) 83 92 r.Get("/feed.atom", s.AtomFeedPage) 84 93 85 - // redirect /@handle/repo.git -> /@handle/repo 86 - r.Get("/{repo}.git", func(w http.ResponseWriter, r *http.Request) { 87 - nonDotGitPath := strings.TrimSuffix(r.URL.Path, ".git") 88 - http.Redirect(w, r, nonDotGitPath, http.StatusMovedPermanently) 89 - }) 90 - 91 94 r.With(mw.ResolveRepo()).Route("/{repo}", func(r chi.Router) { 92 95 r.Use(mw.GoImport()) 93 96 r.Mount("/", s.RepoRouter(mw)) ··· 136 139 // r.Post("/import", s.ImportRepo) 137 140 }) 138 141 139 - r.Get("/goodfirstissues", s.GoodFirstIssues) 142 + r.With(middleware.Paginate).Get("/goodfirstissues", s.GoodFirstIssues) 140 143 141 144 r.With(middleware.AuthMiddleware(s.oauth)).Route("/follow", func(r chi.Router) { 142 145 r.Post("/", s.Follow) ··· 163 166 164 167 r.Mount("/settings", s.SettingsRouter()) 165 168 r.Mount("/strings", s.StringsRouter(mw)) 166 - r.Mount("/knots", s.KnotsRouter()) 167 - r.Mount("/spindles", s.SpindlesRouter()) 169 + 170 + r.Mount("/settings/knots", s.KnotsRouter()) 171 + r.Mount("/settings/spindles", s.SpindlesRouter()) 172 + 168 173 r.Mount("/notifications", s.NotificationsRouter(mw)) 169 174 170 175 r.Mount("/signup", s.SignupRouter()) ··· 258 263 issues := issues.New( 259 264 s.oauth, 260 265 s.repoResolver, 266 + s.enforcer, 261 267 s.pages, 262 268 s.idResolver, 269 + s.refResolver, 263 270 s.db, 264 271 s.config, 265 272 s.notifier, ··· 276 283 s.repoResolver, 277 284 s.pages, 278 285 s.idResolver, 286 + s.refResolver, 279 287 s.db, 280 288 s.config, 281 289 s.notifier,
+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
+13 -9
appview/state/state.go
··· 21 21 phnotify "tangled.org/core/appview/notify/posthog" 22 22 "tangled.org/core/appview/oauth" 23 23 "tangled.org/core/appview/pages" 24 + "tangled.org/core/appview/refresolver" 24 25 "tangled.org/core/appview/reporesolver" 25 26 "tangled.org/core/appview/validator" 26 27 xrpcclient "tangled.org/core/appview/xrpcclient" ··· 49 50 enforcer *rbac.Enforcer 50 51 pages *pages.Pages 51 52 idResolver *idresolver.Resolver 53 + refResolver *refresolver.Resolver 52 54 posthog posthog.Client 53 55 jc *jetstream.JetstreamClient 54 56 config *config.Config ··· 78 80 return nil, fmt.Errorf("failed to create enforcer: %w", err) 79 81 } 80 82 81 - res, err := idresolver.RedisResolver(config.Redis.ToURL()) 83 + res, err := idresolver.RedisResolver(config.Redis.ToURL(), config.Plc.PLCURL) 82 84 if err != nil { 83 85 logger.Error("failed to create redis resolver", "err", err) 84 - res = idresolver.DefaultResolver() 86 + res = idresolver.DefaultResolver(config.Plc.PLCURL) 85 87 } 86 88 87 89 posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint}) ··· 96 98 } 97 99 validator := validator.New(d, res, enforcer) 98 100 99 - repoResolver := reporesolver.New(config, enforcer, res, d) 101 + repoResolver := reporesolver.New(config, enforcer, d) 102 + 103 + refResolver := refresolver.New(config, res, d, log.SubLogger(logger, "refResolver")) 100 104 101 105 wrapper := db.DbWrapper{Execer: d} 102 106 jc, err := jetstream.NewJetstreamClient( ··· 129 133 return nil, fmt.Errorf("failed to create jetstream client: %w", err) 130 134 } 131 135 132 - if err := BackfillDefaultDefs(d, res); err != nil { 136 + if err := BackfillDefaultDefs(d, res, config.Label.DefaultLabelDefs); err != nil { 133 137 return nil, fmt.Errorf("failed to backfill default label defs: %w", err) 134 138 } 135 139 ··· 178 182 enforcer, 179 183 pages, 180 184 res, 185 + refResolver, 181 186 posthog, 182 187 jc, 183 188 config, ··· 294 299 return 295 300 } 296 301 297 - gfiLabel, err := db.GetLabelDefinition(s.db, db.FilterEq("at_uri", models.LabelGoodFirstIssue)) 302 + gfiLabel, err := db.GetLabelDefinition(s.db, db.FilterEq("at_uri", s.config.Label.GoodFirstIssue)) 298 303 if err != nil { 299 304 // non-fatal 300 305 } ··· 517 522 Rkey: rkey, 518 523 Description: description, 519 524 Created: time.Now(), 520 - Labels: models.DefaultLabelDefs(), 525 + Labels: s.config.Label.DefaultLabelDefs, 521 526 } 522 527 record := repo.AsRecord() 523 528 ··· 659 664 return err 660 665 } 661 666 662 - func BackfillDefaultDefs(e db.Execer, r *idresolver.Resolver) error { 663 - defaults := models.DefaultLabelDefs() 667 + func BackfillDefaultDefs(e db.Execer, r *idresolver.Resolver, defaults []string) error { 664 668 defaultLabels, err := db.GetLabelDefinitions(e, db.FilterIn("at_uri", defaults)) 665 669 if err != nil { 666 670 return err ··· 670 674 return nil 671 675 } 672 676 673 - labelDefs, err := models.FetchDefaultDefs(r) 677 + labelDefs, err := models.FetchLabelDefs(r, defaults) 674 678 if err != nil { 675 679 return err 676 680 }
+14 -2
appview/strings/strings.go
··· 148 148 showRendered = r.URL.Query().Get("code") != "true" 149 149 } 150 150 151 + starCount, err := db.GetStarCount(s.Db, string.AtUri()) 152 + if err != nil { 153 + l.Error("failed to get star count", "err", err) 154 + } 155 + user := s.OAuth.GetUser(r) 156 + isStarred := false 157 + if user != nil { 158 + isStarred = db.GetStarStatus(s.Db, user.Did, string.AtUri()) 159 + } 160 + 151 161 s.Pages.SingleString(w, pages.SingleStringParams{ 152 - LoggedInUser: s.OAuth.GetUser(r), 162 + LoggedInUser: user, 153 163 RenderToggle: renderToggle, 154 164 ShowRendered: showRendered, 155 - String: string, 165 + String: &string, 156 166 Stats: string.Stats(), 167 + IsStarred: isStarred, 168 + StarCount: starCount, 157 169 Owner: id, 158 170 }) 159 171 }
+3 -3
docs/hacking.md
··· 117 117 # type `poweroff` at the shell to exit the VM 118 118 ``` 119 119 120 - This starts a knot on port 6000, a spindle on port 6555 120 + This starts a knot on port 6444, a spindle on port 6555 121 121 with `ssh` exposed on port 2222. 122 122 123 123 Once the services are running, head to 124 - http://localhost:3000/knots and hit verify. It should 124 + http://localhost:3000/settings/knots and hit verify. It should 125 125 verify the ownership of the services instantly if everything 126 126 went smoothly. 127 127 ··· 146 146 ### running a spindle 147 147 148 148 The above VM should already be running a spindle on 149 - `localhost:6555`. Head to http://localhost:3000/spindles and 149 + `localhost:6555`. Head to http://localhost:3000/settings/spindles and 150 150 hit verify. You can then configure each repository to use 151 151 this spindle and run CI jobs. 152 152
+1 -1
docs/knot-hosting.md
··· 131 131 132 132 You should now have a running knot server! You can finalize 133 133 your registration by hitting the `verify` button on the 134 - [/knots](https://tangled.org/knots) page. This simply creates 134 + [/settings/knots](https://tangled.org/settings/knots) page. This simply creates 135 135 a record on your PDS to announce the existence of the knot. 136 136 137 137 ### custom paths
+3 -3
docs/migrations.md
··· 14 14 For knots: 15 15 16 16 - Upgrade to latest tag (v1.9.0 or above) 17 - - Head to the [knot dashboard](https://tangled.org/knots) and 17 + - Head to the [knot dashboard](https://tangled.org/settings/knots) and 18 18 hit the "retry" button to verify your knot 19 19 20 20 For spindles: 21 21 22 22 - Upgrade to latest tag (v1.9.0 or above) 23 23 - Head to the [spindle 24 - dashboard](https://tangled.org/spindles) and hit the 24 + dashboard](https://tangled.org/settings/spindles) and hit the 25 25 "retry" button to verify your spindle 26 26 27 27 ## Upgrading from v1.7.x ··· 41 41 [settings](https://tangled.org/settings) page. 42 42 - Restart your knot once you have replaced the environment 43 43 variable 44 - - Head to the [knot dashboard](https://tangled.org/knots) and 44 + - Head to the [knot dashboard](https://tangled.org/settings/knots) and 45 45 hit the "retry" button to verify your knot. This simply 46 46 writes a `sh.tangled.knot` record to your PDS. 47 47
+17
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": { ··· 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",
+12 -10
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"]; ··· 81 86 lexgen = self.callPackage ./nix/pkgs/lexgen.nix {inherit indigo;}; 82 87 goat = self.callPackage ./nix/pkgs/goat.nix {inherit indigo;}; 83 88 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; 89 + inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src actor-typeahead-src; 85 90 }; 86 91 appview = self.callPackage ./nix/pkgs/appview.nix {}; 87 92 spindle = self.callPackage ./nix/pkgs/spindle.nix {}; ··· 179 184 air-watcher = name: arg: 180 185 pkgs.writeShellScriptBin "run" 181 186 '' 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" 187 + export PATH=${pkgs.go}/bin:$PATH 188 + ${pkgs.air}/bin/air -c ./.air/${name}.toml \ 189 + -build.args_bin "${arg}" 188 190 ''; 189 191 tailwind-watcher = 190 192 pkgs.writeShellScriptBin "run" ··· 283 285 }: { 284 286 imports = [./nix/modules/appview.nix]; 285 287 286 - services.tangled.appview.package = lib.mkDefault self.packages.${pkgs.system}.appview; 288 + services.tangled.appview.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.appview; 287 289 }; 288 290 nixosModules.knot = { 289 291 lib, ··· 292 294 }: { 293 295 imports = [./nix/modules/knot.nix]; 294 296 295 - services.tangled.knot.package = lib.mkDefault self.packages.${pkgs.system}.knot; 297 + services.tangled.knot.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.knot; 296 298 }; 297 299 nixosModules.spindle = { 298 300 lib, ··· 301 303 }: { 302 304 imports = [./nix/modules/spindle.nix]; 303 305 304 - services.tangled.spindle.package = lib.mkDefault self.packages.${pkgs.system}.spindle; 306 + services.tangled.spindle.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.spindle; 305 307 }; 306 308 }; 307 309 }
+4 -12
go.mod
··· 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 ··· 45 47 github.com/wyatt915/goldmark-treeblood v0.0.1 46 48 github.com/yuin/goldmark v1.7.13 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 ··· 65 68 github.com/aymerick/douceur v0.2.0 // indirect 66 69 github.com/beorn7/perks v1.0.1 // indirect 67 70 github.com/bits-and-blooms/bitset v1.22.0 // indirect 68 - github.com/blevesearch/bleve/v2 v2.5.3 // indirect 69 71 github.com/blevesearch/bleve_index_api v1.2.8 // indirect 70 72 github.com/blevesearch/geo v0.2.4 // indirect 71 73 github.com/blevesearch/go-faiss v1.0.25 // indirect ··· 83 85 github.com/blevesearch/zapx/v14 v14.4.2 // indirect 84 86 github.com/blevesearch/zapx/v15 v15.4.2 // indirect 85 87 github.com/blevesearch/zapx/v16 v16.2.4 // indirect 86 - github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect 87 88 github.com/casbin/govaluate v1.3.0 // indirect 88 89 github.com/cenkalti/backoff/v4 v4.3.0 // indirect 89 90 github.com/cespare/xxhash/v2 v2.3.0 // indirect 90 91 github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect 91 92 github.com/charmbracelet/lipgloss v1.1.0 // indirect 92 - github.com/charmbracelet/log v0.4.2 // indirect 93 93 github.com/charmbracelet/x/ansi v0.8.0 // indirect 94 94 github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect 95 95 github.com/charmbracelet/x/term v0.2.1 // indirect ··· 98 98 github.com/containerd/errdefs/pkg v0.3.0 // indirect 99 99 github.com/containerd/log v0.1.0 // indirect 100 100 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 101 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 103 102 github.com/distribution/reference v0.6.0 // indirect 104 103 github.com/dlclark/regexp2 v1.11.5 // indirect ··· 152 151 github.com/kevinburke/ssh_config v1.2.0 // indirect 153 152 github.com/klauspost/compress v1.18.0 // indirect 154 153 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 154 github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 161 155 github.com/mattn/go-isatty v0.0.20 // indirect 162 156 github.com/mattn/go-runewidth v0.0.16 // indirect ··· 191 185 github.com/prometheus/procfs v0.16.1 // indirect 192 186 github.com/rivo/uniseg v0.4.7 // indirect 193 187 github.com/ryanuber/go-glob v1.0.0 // indirect 194 - github.com/segmentio/asm v1.2.0 // indirect 195 188 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect 196 189 github.com/spaolacci/murmur3 v1.1.0 // indirect 197 190 github.com/vmihailenco/go-tinylfu v0.2.2 // indirect ··· 199 192 github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect 200 193 github.com/wyatt915/treeblood v0.1.16 // indirect 201 194 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 202 - gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab // indirect 203 195 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 204 196 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 205 197 go.etcd.io/bbolt v1.4.0 // indirect
-17
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= 76 75 github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE= 77 76 github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= ··· 126 125 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 127 126 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 128 127 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 129 - github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= 130 - github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= 131 128 github.com/dgraph-io/ristretto v0.2.0 h1:XAfl+7cmoUDWW/2Lx8TGZQjjxIQ2Ley9DSf52dru4WE= 132 129 github.com/dgraph-io/ristretto v0.2.0/go.mod h1:8uBHCU/PBV4Ag0CJrP47b9Ofby5dqWNh4FicAdoqFNU= 133 130 github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WAFKLNi6ZS0675eEUC9y3AlwSbQu1Y= ··· 330 327 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 331 328 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 332 329 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 333 - github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA= 334 - github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= 335 - github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= 336 - github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= 337 - github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k= 338 - github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo= 339 - github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= 340 - github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= 341 - github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVfecA= 342 - github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU= 343 - github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= 344 - github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= 345 330 github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 346 331 github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 347 332 github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= ··· 466 451 github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 467 452 github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= 468 453 github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= 469 - github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= 470 - github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= 471 454 github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= 472 455 github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= 473 456 github.com/sethvargo/go-envconfig v1.1.0 h1:cWZiJxeTm7AlCvzGXrEXaSTCNgip5oJepekh/BOQuog=
+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 }
+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 }
+38
input.css
··· 161 161 @apply no-underline; 162 162 } 163 163 164 + .prose a.mention { 165 + @apply no-underline hover:underline; 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 { ··· 924 929 text-decoration: underline; 925 930 } 926 931 } 932 + 933 + actor-typeahead { 934 + --color-background: #ffffff; 935 + --color-border: #d1d5db; 936 + --color-shadow: #000000; 937 + --color-hover: #f9fafb; 938 + --color-avatar-fallback: #e5e7eb; 939 + --radius: 0.0; 940 + --padding-menu: 0.0rem; 941 + z-index: 1000; 942 + } 943 + 944 + actor-typeahead::part(handle) { 945 + color: #111827; 946 + } 947 + 948 + actor-typeahead::part(menu) { 949 + box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); 950 + } 951 + 952 + @media (prefers-color-scheme: dark) { 953 + actor-typeahead { 954 + --color-background: #1f2937; 955 + --color-border: #4b5563; 956 + --color-shadow: #000000; 957 + --color-hover: #374151; 958 + --color-avatar-fallback: #4b5563; 959 + } 960 + 961 + actor-typeahead::part(handle) { 962 + color: #f9fafb; 963 + } 964 + }
+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"`
+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) {
+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)
+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 }
+63 -2
knotserver/internal.go
··· 68 68 writeJSON(w, data) 69 69 } 70 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 + 71 131 type PushOptions struct { 72 132 skipCi bool 73 133 verboseCi bool ··· 217 277 218 278 var pipeline workflow.RawPipeline 219 279 for _, e := range workflowDir { 220 - if !e.IsFile { 280 + if !e.IsFile() { 221 281 continue 222 282 } 223 283 ··· 353 413 r := chi.NewRouter() 354 414 l := log.FromContext(ctx) 355 415 l = log.SubLogger(l, "internal") 356 - res := idresolver.DefaultResolver() 416 + res := idresolver.DefaultResolver(c.Server.PlcUrl) 357 417 358 418 h := InternalHandle{ 359 419 db, ··· 366 426 367 427 r.Get("/push-allowed", h.PushAllowed) 368 428 r.Get("/keys", h.InternalKeys) 429 + r.Get("/guard", h.Guard) 369 430 r.Post("/hooks/post-receive", h.PostReceiveHook) 370 431 r.Mount("/debug", middleware.Profiler()) 371 432
+1 -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)
+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
+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 {
+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 }
+14
lexicons/pulls/pull.json
··· 36 36 "createdAt": { 37 37 "type": "string", 38 38 "format": "datetime" 39 + }, 40 + "mentions": { 41 + "type": "array", 42 + "items": { 43 + "type": "string", 44 + "format": "did" 45 + } 46 + }, 47 + "references": { 48 + "type": "array", 49 + "items": { 50 + "type": "string", 51 + "format": "at-uri" 52 + } 39 53 } 40 54 } 41 55 }
+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 }
+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",
+278 -12
nix/modules/appview.nix
··· 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 }; 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 { 36 + type = types.str; 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 + }; 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 + 25 228 environmentFile = mkOption { 26 229 type = with types; nullOr path; 27 230 default = null; 28 - example = "/etc-/appview.env"; 231 + example = "/etc/appview.env"; 29 232 description = '' 30 233 Additional environment file as defined in {manpage}`systemd.exec(5)`. 31 234 32 - Sensitive secrets such as {env}`TANGLED_COOKIE_SECRET` may be 33 - passed to the service without makeing them world readable in the 34 - nix store. 35 - 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. 36 245 ''; 37 246 }; 38 247 }; ··· 47 256 systemd.services.appview = { 48 257 description = "tangled appview service"; 49 258 wantedBy = ["multi-user.target"]; 50 - after = ["redis-appview.service"]; 259 + after = ["redis-appview.service" "network-online.target"]; 51 260 requires = ["redis-appview.service"]; 261 + wants = ["network-online.target"]; 52 262 53 263 serviceConfig = { 54 - ListenStream = "0.0.0.0:${toString cfg.port}"; 264 + Type = "simple"; 55 265 ExecStart = "${cfg.package}/bin/appview"; 56 266 Restart = "always"; 57 - EnvironmentFile = optional (cfg.environmentFile != null) cfg.environmentFile; 58 - }; 267 + RestartSec = "10s"; 268 + EnvironmentFile = mkIf (cfg.environmentFile != null) cfg.environmentFile; 269 + 270 + # state directory 271 + StateDirectory = "appview"; 272 + WorkingDirectory = "/var/lib/appview"; 59 273 60 - environment = { 61 - TANGLED_DB_PATH = "appview.db"; 274 + # security hardening 275 + NoNewPrivileges = true; 276 + PrivateTmp = true; 277 + ProtectSystem = "strict"; 278 + ProtectHome = true; 279 + ReadWritePaths = ["/var/lib/appview"]; 62 280 }; 281 + 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 + }; 63 329 }; 64 330 }; 65 331 }
+74 -2
nix/modules/knot.nix
··· 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";
+8 -1
nix/modules/spindle.nix
··· 37 37 description = "Hostname for the server (required)"; 38 38 }; 39 39 40 + plcUrl = mkOption { 41 + type = types.str; 42 + default = "https://plc.directory"; 43 + description = "atproto PLC directory"; 44 + }; 45 + 40 46 jetstreamEndpoint = mkOption { 41 47 type = types.str; 42 48 default = "wss://jetstream1.us-west.bsky.network/subscribe"; ··· 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}"
+2
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, 10 11 src, ··· 24 25 cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 fonts/ 25 26 cp -f ${inter-fonts-src}/InterVariable*.ttf fonts/ 26 27 cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono*.woff2 fonts/ 28 + cp -f ${actor-typeahead-src}/actor-typeahead.js . 27 29 # tailwindcss -c $src/tailwind.config.js -i $src/input.css -o tw.css won't work 28 30 # for whatever reason (produces broken css), so we are doing this instead 29 31 cd ${src} && ${tailwindcss}/bin/tailwindcss -i input.css -o $out/tw.css
+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";
+18 -5
nix/vm.nix
··· 10 10 if var == "" 11 11 then throw "\$${name} must be defined, see docs/hacking.md 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 { ··· 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 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;
+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 }
+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_"`
+5 -6
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 { ··· 310 309 envs.AddEnv("HOME", homeDir) 311 310 312 311 mkExecResp, err := e.docker.ContainerExecCreate(ctx, addl.container, container.ExecOptions{ 313 - Cmd: []string{"bash", "-c", step.command}, 312 + Cmd: []string{"bash", "-c", step.Command()}, 314 313 AttachStdout: true, 315 314 AttachStderr: true, 316 315 Env: envs, ··· 333 332 // Docker doesn't provide an API to kill an exec run 334 333 // (sure, we could grab the PID and kill it ourselves, 335 334 // but that's wasted effort) 336 - e.l.Warn("step timed out", "step", step.Name) 335 + e.l.Warn("step timed out", "step", step.Name()) 337 336 338 337 <-tailDone 339 338
-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 + }
+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 + }
+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")
+97 -41
spindle/server.go
··· 6 6 "encoding/json" 7 7 "fmt" 8 8 "log/slog" 9 + "maps" 9 10 "net/http" 10 11 11 12 "github.com/go-chi/chi/v5" ··· 49 50 vault secrets.Manager 50 51 } 51 52 52 - func Run(ctx context.Context) error { 53 + // New creates a new Spindle server with the provided configuration and engines. 54 + func New(ctx context.Context, cfg *config.Config, engines map[string]models.Engine) (*Spindle, error) { 53 55 logger := log.FromContext(ctx) 54 56 55 - cfg, err := config.Load(ctx) 56 - if err != nil { 57 - return fmt.Errorf("failed to load config: %w", err) 58 - } 59 - 60 57 d, err := db.Make(cfg.Server.DBPath) 61 58 if err != nil { 62 - return fmt.Errorf("failed to setup db: %w", err) 59 + return nil, fmt.Errorf("failed to setup db: %w", err) 63 60 } 64 61 65 62 e, err := rbac.NewEnforcer(cfg.Server.DBPath) 66 63 if err != nil { 67 - return fmt.Errorf("failed to setup rbac enforcer: %w", err) 64 + return nil, fmt.Errorf("failed to setup rbac enforcer: %w", err) 68 65 } 69 66 e.E.EnableAutoSave(true) 70 67 ··· 74 71 switch cfg.Server.Secrets.Provider { 75 72 case "openbao": 76 73 if cfg.Server.Secrets.OpenBao.ProxyAddr == "" { 77 - return fmt.Errorf("openbao proxy address is required when using openbao secrets provider") 74 + return nil, fmt.Errorf("openbao proxy address is required when using openbao secrets provider") 78 75 } 79 76 vault, err = secrets.NewOpenBaoManager( 80 77 cfg.Server.Secrets.OpenBao.ProxyAddr, ··· 82 79 secrets.WithMountPath(cfg.Server.Secrets.OpenBao.Mount), 83 80 ) 84 81 if err != nil { 85 - return fmt.Errorf("failed to setup openbao secrets provider: %w", err) 82 + return nil, fmt.Errorf("failed to setup openbao secrets provider: %w", err) 86 83 } 87 84 logger.Info("using openbao secrets provider", "proxy_address", cfg.Server.Secrets.OpenBao.ProxyAddr, "mount", cfg.Server.Secrets.OpenBao.Mount) 88 85 case "sqlite", "": 89 86 vault, err = secrets.NewSQLiteManager(cfg.Server.DBPath, secrets.WithTableName("secrets")) 90 87 if err != nil { 91 - return fmt.Errorf("failed to setup sqlite secrets provider: %w", err) 88 + return nil, fmt.Errorf("failed to setup sqlite secrets provider: %w", err) 92 89 } 93 90 logger.Info("using sqlite secrets provider", "path", cfg.Server.DBPath) 94 91 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 92 + return nil, fmt.Errorf("unknown secrets provider: %s", cfg.Server.Secrets.Provider) 101 93 } 102 94 103 95 jq := queue.NewQueue(cfg.Server.QueueSize, cfg.Server.MaxJobCount) ··· 110 102 } 111 103 jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, log.SubLogger(logger, "jetstream"), d, true, true) 112 104 if err != nil { 113 - return fmt.Errorf("failed to setup jetstream client: %w", err) 105 + return nil, fmt.Errorf("failed to setup jetstream client: %w", err) 114 106 } 115 107 jc.AddDid(cfg.Server.Owner) 116 108 117 109 // Check if the spindle knows about any Dids; 118 110 dids, err := d.GetAllDids() 119 111 if err != nil { 120 - return fmt.Errorf("failed to get all dids: %w", err) 112 + return nil, fmt.Errorf("failed to get all dids: %w", err) 121 113 } 122 114 for _, d := range dids { 123 115 jc.AddDid(d) 124 116 } 125 117 126 - resolver := idresolver.DefaultResolver() 118 + resolver := idresolver.DefaultResolver(cfg.Server.PlcUrl) 127 119 128 - spindle := Spindle{ 120 + spindle := &Spindle{ 129 121 jc: jc, 130 122 e: e, 131 123 db: d, 132 124 l: logger, 133 125 n: &n, 134 - engs: map[string]models.Engine{"nixery": nixeryEng}, 126 + engs: engines, 135 127 jq: jq, 136 128 cfg: cfg, 137 129 res: resolver, ··· 140 132 141 133 err = e.AddSpindle(rbacDomain) 142 134 if err != nil { 143 - return fmt.Errorf("failed to set rbac domain: %w", err) 135 + return nil, fmt.Errorf("failed to set rbac domain: %w", err) 144 136 } 145 137 err = spindle.configureOwner() 146 138 if err != nil { 147 - return err 139 + return nil, err 148 140 } 149 141 logger.Info("owner set", "did", cfg.Server.Owner) 150 142 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 143 cursorStore, err := cursor.NewSQLiteStore(cfg.Server.DBPath) 161 144 if err != nil { 162 - return fmt.Errorf("failed to setup sqlite3 cursor store: %w", err) 145 + return nil, fmt.Errorf("failed to setup sqlite3 cursor store: %w", err) 163 146 } 164 147 165 148 err = jc.StartJetstream(ctx, spindle.ingest()) 166 149 if err != nil { 167 - return fmt.Errorf("failed to start jetstream consumer: %w", err) 150 + return nil, fmt.Errorf("failed to start jetstream consumer: %w", err) 168 151 } 169 152 170 153 // for each incoming sh.tangled.pipeline, we execute ··· 177 160 ccfg.CursorStore = cursorStore 178 161 knownKnots, err := d.Knots() 179 162 if err != nil { 180 - return err 163 + return nil, err 181 164 } 182 165 for _, knot := range knownKnots { 183 166 logger.Info("adding source start", "knot", knot) ··· 185 168 } 186 169 spindle.ks = eventconsumer.NewConsumer(*ccfg) 187 170 171 + return spindle, nil 172 + } 173 + 174 + // DB returns the database instance. 175 + func (s *Spindle) DB() *db.DB { 176 + return s.db 177 + } 178 + 179 + // Queue returns the job queue instance. 180 + func (s *Spindle) Queue() *queue.Queue { 181 + return s.jq 182 + } 183 + 184 + // Engines returns the map of available engines. 185 + func (s *Spindle) Engines() map[string]models.Engine { 186 + return s.engs 187 + } 188 + 189 + // Vault returns the secrets manager instance. 190 + func (s *Spindle) Vault() secrets.Manager { 191 + return s.vault 192 + } 193 + 194 + // Notifier returns the notifier instance. 195 + func (s *Spindle) Notifier() *notifier.Notifier { 196 + return s.n 197 + } 198 + 199 + // Enforcer returns the RBAC enforcer instance. 200 + func (s *Spindle) Enforcer() *rbac.Enforcer { 201 + return s.e 202 + } 203 + 204 + // Start starts the Spindle server (blocking). 205 + func (s *Spindle) Start(ctx context.Context) error { 206 + // starts a job queue runner in the background 207 + s.jq.Start() 208 + defer s.jq.Stop() 209 + 210 + // Stop vault token renewal if it implements Stopper 211 + if stopper, ok := s.vault.(secrets.Stopper); ok { 212 + defer stopper.Stop() 213 + } 214 + 188 215 go func() { 189 - logger.Info("starting knot event consumer") 190 - spindle.ks.Start(ctx) 216 + s.l.Info("starting knot event consumer") 217 + s.ks.Start(ctx) 191 218 }() 192 219 193 - logger.Info("starting spindle server", "address", cfg.Server.ListenAddr) 194 - logger.Error("server error", "error", http.ListenAndServe(cfg.Server.ListenAddr, spindle.Router())) 220 + s.l.Info("starting spindle server", "address", s.cfg.Server.ListenAddr) 221 + return http.ListenAndServe(s.cfg.Server.ListenAddr, s.Router()) 222 + } 195 223 196 - return nil 224 + func Run(ctx context.Context) error { 225 + cfg, err := config.Load(ctx) 226 + if err != nil { 227 + return fmt.Errorf("failed to load config: %w", err) 228 + } 229 + 230 + nixeryEng, err := nixery.New(ctx, cfg) 231 + if err != nil { 232 + return err 233 + } 234 + 235 + s, err := New(ctx, cfg, map[string]models.Engine{ 236 + "nixery": nixeryEng, 237 + }) 238 + if err != nil { 239 + return err 240 + } 241 + 242 + return s.Start(ctx) 197 243 } 198 244 199 245 func (s *Spindle) Router() http.Handler { ··· 266 312 267 313 workflows := make(map[models.Engine][]models.Workflow) 268 314 315 + // Build pipeline environment variables once for all workflows 316 + pipelineEnv := models.PipelineEnvVars(tpl.TriggerMetadata, pipelineId, s.cfg.Server.Dev) 317 + 269 318 for _, w := range tpl.Workflows { 270 319 if w != nil { 271 320 if _, ok := s.engs[w.Engine]; !ok { ··· 290 339 if err != nil { 291 340 return err 292 341 } 342 + 343 + // inject TANGLED_* env vars after InitWorkflow 344 + // This prevents user-defined env vars from overriding them 345 + if ewf.Environment == nil { 346 + ewf.Environment = make(map[string]string) 347 + } 348 + maps.Copy(ewf.Environment, pipelineEnv) 293 349 294 350 workflows[eng] = append(workflows[eng], *ewf) 295 351
+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 }
+22 -1
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 ) ··· 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 {