Monorepo for Tangled tangled.org

Compare changes

Choose any two refs to compare.

Changed files
+8510 -3525
api
appview
commitverify
db
email
issues
knots
labels
mentions
middleware
models
notifications
notify
oauth
ogcard
pages
pipelines
pulls
repo
reporesolver
serververify
settings
spindles
state
strings
validator
cmd
dolly
crypto
docs
hook
ico
jetstream
knotserver
lexicons
nix
orm
patchutil
rbac
sets
spindle
types
+727 -27
api/tangled/cbor_gen.go
··· 6938 } 6939 6940 cw := cbg.NewCborWriter(w) 6941 - fieldCount := 5 6942 6943 if t.Body == nil { 6944 fieldCount-- 6945 } 6946 ··· 7045 return err 7046 } 7047 7048 // t.CreatedAt (string) (string) 7049 if len("createdAt") > 1000000 { 7050 return xerrors.Errorf("Value in field \"createdAt\" was too long") ··· 7067 if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 7068 return err 7069 } 7070 return nil 7071 } 7072 ··· 7095 7096 n := extra 7097 7098 - nameBuf := make([]byte, 9) 7099 for i := uint64(0); i < n; i++ { 7100 nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 7101 if err != nil { ··· 7165 7166 t.Title = string(sval) 7167 } 7168 // t.CreatedAt (string) (string) 7169 case "createdAt": 7170 ··· 7175 } 7176 7177 t.CreatedAt = string(sval) 7178 } 7179 7180 default: ··· 7194 } 7195 7196 cw := cbg.NewCborWriter(w) 7197 - fieldCount := 5 7198 7199 if t.ReplyTo == nil { 7200 fieldCount-- ··· 7301 } 7302 } 7303 7304 // t.CreatedAt (string) (string) 7305 if len("createdAt") > 1000000 { 7306 return xerrors.Errorf("Value in field \"createdAt\" was too long") ··· 7323 if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 7324 return err 7325 } 7326 return nil 7327 } 7328 ··· 7351 7352 n := extra 7353 7354 - nameBuf := make([]byte, 9) 7355 for i := uint64(0); i < n; i++ { 7356 nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 7357 if err != nil { ··· 7421 t.ReplyTo = (*string)(&sval) 7422 } 7423 } 7424 // t.CreatedAt (string) (string) 7425 case "createdAt": 7426 ··· 7431 } 7432 7433 t.CreatedAt = string(sval) 7434 } 7435 7436 default: ··· 7614 } 7615 7616 cw := cbg.NewCborWriter(w) 7617 - fieldCount := 7 7618 7619 if t.Body == nil { 7620 fieldCount-- 7621 } 7622 ··· 7680 } 7681 7682 // t.Patch (string) (string) 7683 - if len("patch") > 1000000 { 7684 - return xerrors.Errorf("Value in field \"patch\" was too long") 7685 - } 7686 7687 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("patch"))); err != nil { 7688 - return err 7689 - } 7690 - if _, err := cw.WriteString(string("patch")); err != nil { 7691 - return err 7692 - } 7693 7694 - if len(t.Patch) > 1000000 { 7695 - return xerrors.Errorf("Value in field t.Patch was too long") 7696 - } 7697 7698 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Patch))); err != nil { 7699 - return err 7700 - } 7701 - if _, err := cw.WriteString(string(t.Patch)); err != nil { 7702 - return err 7703 } 7704 7705 // t.Title (string) (string) ··· 7760 return err 7761 } 7762 7763 // t.CreatedAt (string) (string) 7764 if len("createdAt") > 1000000 { 7765 return xerrors.Errorf("Value in field \"createdAt\" was too long") ··· 7782 if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 7783 return err 7784 } 7785 return nil 7786 } 7787 ··· 7810 7811 n := extra 7812 7813 - nameBuf := make([]byte, 9) 7814 for i := uint64(0); i < n; i++ { 7815 nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 7816 if err != nil { ··· 7862 case "patch": 7863 7864 { 7865 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 7866 if err != nil { 7867 return err 7868 } 7869 7870 - t.Patch = string(sval) 7871 } 7872 // t.Title (string) (string) 7873 case "title": ··· 7920 } 7921 7922 } 7923 // t.CreatedAt (string) (string) 7924 case "createdAt": 7925 ··· 7931 7932 t.CreatedAt = string(sval) 7933 } 7934 7935 default: 7936 // Field doesn't exist on this type, so ignore it ··· 7949 } 7950 7951 cw := cbg.NewCborWriter(w) 7952 7953 - if _, err := cw.Write([]byte{164}); err != nil { 7954 return err 7955 } 7956 ··· 8019 return err 8020 } 8021 8022 // t.CreatedAt (string) (string) 8023 if len("createdAt") > 1000000 { 8024 return xerrors.Errorf("Value in field \"createdAt\" was too long") ··· 8041 if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 8042 return err 8043 } 8044 return nil 8045 } 8046 ··· 8069 8070 n := extra 8071 8072 - nameBuf := make([]byte, 9) 8073 for i := uint64(0); i < n; i++ { 8074 nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 8075 if err != nil { ··· 8118 8119 t.LexiconTypeID = string(sval) 8120 } 8121 // t.CreatedAt (string) (string) 8122 case "createdAt": 8123 ··· 8128 } 8129 8130 t.CreatedAt = string(sval) 8131 } 8132 8133 default:
··· 6938 } 6939 6940 cw := cbg.NewCborWriter(w) 6941 + fieldCount := 7 6942 6943 if t.Body == nil { 6944 + fieldCount-- 6945 + } 6946 + 6947 + if t.Mentions == nil { 6948 + fieldCount-- 6949 + } 6950 + 6951 + if t.References == nil { 6952 fieldCount-- 6953 } 6954 ··· 7053 return err 7054 } 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 + 7092 // t.CreatedAt (string) (string) 7093 if len("createdAt") > 1000000 { 7094 return xerrors.Errorf("Value in field \"createdAt\" was too long") ··· 7111 if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 7112 return err 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 + } 7150 return nil 7151 } 7152 ··· 7175 7176 n := extra 7177 7178 + nameBuf := make([]byte, 10) 7179 for i := uint64(0); i < n; i++ { 7180 nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 7181 if err != nil { ··· 7245 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 + } 7287 + } 7288 // t.CreatedAt (string) (string) 7289 case "createdAt": 7290 ··· 7295 } 7296 7297 t.CreatedAt = string(sval) 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 } 7339 7340 default: ··· 7354 } 7355 7356 cw := cbg.NewCborWriter(w) 7357 + fieldCount := 7 7358 + 7359 + if t.Mentions == nil { 7360 + fieldCount-- 7361 + } 7362 + 7363 + if t.References == nil { 7364 + fieldCount-- 7365 + } 7366 7367 if t.ReplyTo == nil { 7368 fieldCount-- ··· 7469 } 7470 } 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 + 7508 // t.CreatedAt (string) (string) 7509 if len("createdAt") > 1000000 { 7510 return xerrors.Errorf("Value in field \"createdAt\" was too long") ··· 7527 if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 7528 return err 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 + } 7566 return nil 7567 } 7568 ··· 7591 7592 n := extra 7593 7594 + nameBuf := make([]byte, 10) 7595 for i := uint64(0); i < n; i++ { 7596 nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 7597 if err != nil { ··· 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 + 7702 + } 7703 + } 7704 // t.CreatedAt (string) (string) 7705 case "createdAt": 7706 ··· 7711 } 7712 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 + } 7754 } 7755 7756 default: ··· 7934 } 7935 7936 cw := cbg.NewCborWriter(w) 7937 + fieldCount := 10 7938 7939 if t.Body == nil { 7940 + fieldCount-- 7941 + } 7942 + 7943 + if t.Mentions == nil { 7944 + fieldCount-- 7945 + } 7946 + 7947 + if t.Patch == nil { 7948 + fieldCount-- 7949 + } 7950 + 7951 + if t.References == nil { 7952 fieldCount-- 7953 } 7954 ··· 8012 } 8013 8014 // t.Patch (string) (string) 8015 + if t.Patch != nil { 8016 8017 + if len("patch") > 1000000 { 8018 + return xerrors.Errorf("Value in field \"patch\" was too long") 8019 + } 8020 8021 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("patch"))); err != nil { 8022 + return err 8023 + } 8024 + if _, err := cw.WriteString(string("patch")); err != nil { 8025 + return err 8026 + } 8027 8028 + if t.Patch == nil { 8029 + if _, err := cw.Write(cbg.CborNull); err != nil { 8030 + return err 8031 + } 8032 + } else { 8033 + if len(*t.Patch) > 1000000 { 8034 + return xerrors.Errorf("Value in field t.Patch was too long") 8035 + } 8036 + 8037 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Patch))); err != nil { 8038 + return err 8039 + } 8040 + if _, err := cw.WriteString(string(*t.Patch)); err != nil { 8041 + return err 8042 + } 8043 + } 8044 } 8045 8046 // t.Title (string) (string) ··· 8101 return err 8102 } 8103 8104 + // t.Mentions ([]string) (slice) 8105 + if t.Mentions != nil { 8106 + 8107 + if len("mentions") > 1000000 { 8108 + return xerrors.Errorf("Value in field \"mentions\" was too long") 8109 + } 8110 + 8111 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("mentions"))); err != nil { 8112 + return err 8113 + } 8114 + if _, err := cw.WriteString(string("mentions")); err != nil { 8115 + return err 8116 + } 8117 + 8118 + if len(t.Mentions) > 8192 { 8119 + return xerrors.Errorf("Slice value in field t.Mentions was too long") 8120 + } 8121 + 8122 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Mentions))); err != nil { 8123 + return err 8124 + } 8125 + for _, v := range t.Mentions { 8126 + if len(v) > 1000000 { 8127 + return xerrors.Errorf("Value in field v was too long") 8128 + } 8129 + 8130 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 8131 + return err 8132 + } 8133 + if _, err := cw.WriteString(string(v)); err != nil { 8134 + return err 8135 + } 8136 + 8137 + } 8138 + } 8139 + 8140 // t.CreatedAt (string) (string) 8141 if len("createdAt") > 1000000 { 8142 return xerrors.Errorf("Value in field \"createdAt\" was too long") ··· 8159 if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 8160 return err 8161 } 8162 + 8163 + // t.PatchBlob (util.LexBlob) (struct) 8164 + if len("patchBlob") > 1000000 { 8165 + return xerrors.Errorf("Value in field \"patchBlob\" was too long") 8166 + } 8167 + 8168 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("patchBlob"))); err != nil { 8169 + return err 8170 + } 8171 + if _, err := cw.WriteString(string("patchBlob")); err != nil { 8172 + return err 8173 + } 8174 + 8175 + if err := t.PatchBlob.MarshalCBOR(cw); err != nil { 8176 + return err 8177 + } 8178 + 8179 + // t.References ([]string) (slice) 8180 + if t.References != nil { 8181 + 8182 + if len("references") > 1000000 { 8183 + return xerrors.Errorf("Value in field \"references\" was too long") 8184 + } 8185 + 8186 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("references"))); err != nil { 8187 + return err 8188 + } 8189 + if _, err := cw.WriteString(string("references")); err != nil { 8190 + return err 8191 + } 8192 + 8193 + if len(t.References) > 8192 { 8194 + return xerrors.Errorf("Slice value in field t.References was too long") 8195 + } 8196 + 8197 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.References))); err != nil { 8198 + return err 8199 + } 8200 + for _, v := range t.References { 8201 + if len(v) > 1000000 { 8202 + return xerrors.Errorf("Value in field v was too long") 8203 + } 8204 + 8205 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 8206 + return err 8207 + } 8208 + if _, err := cw.WriteString(string(v)); err != nil { 8209 + return err 8210 + } 8211 + 8212 + } 8213 + } 8214 return nil 8215 } 8216 ··· 8239 8240 n := extra 8241 8242 + nameBuf := make([]byte, 10) 8243 for i := uint64(0); i < n; i++ { 8244 nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 8245 if err != nil { ··· 8291 case "patch": 8292 8293 { 8294 + b, err := cr.ReadByte() 8295 if err != nil { 8296 return err 8297 } 8298 + if b != cbg.CborNull[0] { 8299 + if err := cr.UnreadByte(); err != nil { 8300 + return err 8301 + } 8302 8303 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8304 + if err != nil { 8305 + return err 8306 + } 8307 + 8308 + t.Patch = (*string)(&sval) 8309 + } 8310 } 8311 // t.Title (string) (string) 8312 case "title": ··· 8359 } 8360 8361 } 8362 + // t.Mentions ([]string) (slice) 8363 + case "mentions": 8364 + 8365 + maj, extra, err = cr.ReadHeader() 8366 + if err != nil { 8367 + return err 8368 + } 8369 + 8370 + if extra > 8192 { 8371 + return fmt.Errorf("t.Mentions: array too large (%d)", extra) 8372 + } 8373 + 8374 + if maj != cbg.MajArray { 8375 + return fmt.Errorf("expected cbor array") 8376 + } 8377 + 8378 + if extra > 0 { 8379 + t.Mentions = make([]string, extra) 8380 + } 8381 + 8382 + for i := 0; i < int(extra); i++ { 8383 + { 8384 + var maj byte 8385 + var extra uint64 8386 + var err error 8387 + _ = maj 8388 + _ = extra 8389 + _ = err 8390 + 8391 + { 8392 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8393 + if err != nil { 8394 + return err 8395 + } 8396 + 8397 + t.Mentions[i] = string(sval) 8398 + } 8399 + 8400 + } 8401 + } 8402 // t.CreatedAt (string) (string) 8403 case "createdAt": 8404 ··· 8410 8411 t.CreatedAt = string(sval) 8412 } 8413 + // t.PatchBlob (util.LexBlob) (struct) 8414 + case "patchBlob": 8415 + 8416 + { 8417 + 8418 + b, err := cr.ReadByte() 8419 + if err != nil { 8420 + return err 8421 + } 8422 + if b != cbg.CborNull[0] { 8423 + if err := cr.UnreadByte(); err != nil { 8424 + return err 8425 + } 8426 + t.PatchBlob = new(util.LexBlob) 8427 + if err := t.PatchBlob.UnmarshalCBOR(cr); err != nil { 8428 + return xerrors.Errorf("unmarshaling t.PatchBlob pointer: %w", err) 8429 + } 8430 + } 8431 + 8432 + } 8433 + // t.References ([]string) (slice) 8434 + case "references": 8435 + 8436 + maj, extra, err = cr.ReadHeader() 8437 + if err != nil { 8438 + return err 8439 + } 8440 + 8441 + if extra > 8192 { 8442 + return fmt.Errorf("t.References: array too large (%d)", extra) 8443 + } 8444 + 8445 + if maj != cbg.MajArray { 8446 + return fmt.Errorf("expected cbor array") 8447 + } 8448 + 8449 + if extra > 0 { 8450 + t.References = make([]string, extra) 8451 + } 8452 + 8453 + for i := 0; i < int(extra); i++ { 8454 + { 8455 + var maj byte 8456 + var extra uint64 8457 + var err error 8458 + _ = maj 8459 + _ = extra 8460 + _ = err 8461 + 8462 + { 8463 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8464 + if err != nil { 8465 + return err 8466 + } 8467 + 8468 + t.References[i] = string(sval) 8469 + } 8470 + 8471 + } 8472 + } 8473 8474 default: 8475 // Field doesn't exist on this type, so ignore it ··· 8488 } 8489 8490 cw := cbg.NewCborWriter(w) 8491 + fieldCount := 6 8492 8493 + if t.Mentions == nil { 8494 + fieldCount-- 8495 + } 8496 + 8497 + if t.References == nil { 8498 + fieldCount-- 8499 + } 8500 + 8501 + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 8502 return err 8503 } 8504 ··· 8567 return err 8568 } 8569 8570 + // t.Mentions ([]string) (slice) 8571 + if t.Mentions != nil { 8572 + 8573 + if len("mentions") > 1000000 { 8574 + return xerrors.Errorf("Value in field \"mentions\" was too long") 8575 + } 8576 + 8577 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("mentions"))); err != nil { 8578 + return err 8579 + } 8580 + if _, err := cw.WriteString(string("mentions")); err != nil { 8581 + return err 8582 + } 8583 + 8584 + if len(t.Mentions) > 8192 { 8585 + return xerrors.Errorf("Slice value in field t.Mentions was too long") 8586 + } 8587 + 8588 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Mentions))); err != nil { 8589 + return err 8590 + } 8591 + for _, v := range t.Mentions { 8592 + if len(v) > 1000000 { 8593 + return xerrors.Errorf("Value in field v was too long") 8594 + } 8595 + 8596 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 8597 + return err 8598 + } 8599 + if _, err := cw.WriteString(string(v)); err != nil { 8600 + return err 8601 + } 8602 + 8603 + } 8604 + } 8605 + 8606 // t.CreatedAt (string) (string) 8607 if len("createdAt") > 1000000 { 8608 return xerrors.Errorf("Value in field \"createdAt\" was too long") ··· 8625 if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 8626 return err 8627 } 8628 + 8629 + // t.References ([]string) (slice) 8630 + if t.References != nil { 8631 + 8632 + if len("references") > 1000000 { 8633 + return xerrors.Errorf("Value in field \"references\" was too long") 8634 + } 8635 + 8636 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("references"))); err != nil { 8637 + return err 8638 + } 8639 + if _, err := cw.WriteString(string("references")); err != nil { 8640 + return err 8641 + } 8642 + 8643 + if len(t.References) > 8192 { 8644 + return xerrors.Errorf("Slice value in field t.References was too long") 8645 + } 8646 + 8647 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.References))); err != nil { 8648 + return err 8649 + } 8650 + for _, v := range t.References { 8651 + if len(v) > 1000000 { 8652 + return xerrors.Errorf("Value in field v was too long") 8653 + } 8654 + 8655 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 8656 + return err 8657 + } 8658 + if _, err := cw.WriteString(string(v)); err != nil { 8659 + return err 8660 + } 8661 + 8662 + } 8663 + } 8664 return nil 8665 } 8666 ··· 8689 8690 n := extra 8691 8692 + nameBuf := make([]byte, 10) 8693 for i := uint64(0); i < n; i++ { 8694 nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 8695 if err != nil { ··· 8738 8739 t.LexiconTypeID = string(sval) 8740 } 8741 + // t.Mentions ([]string) (slice) 8742 + case "mentions": 8743 + 8744 + maj, extra, err = cr.ReadHeader() 8745 + if err != nil { 8746 + return err 8747 + } 8748 + 8749 + if extra > 8192 { 8750 + return fmt.Errorf("t.Mentions: array too large (%d)", extra) 8751 + } 8752 + 8753 + if maj != cbg.MajArray { 8754 + return fmt.Errorf("expected cbor array") 8755 + } 8756 + 8757 + if extra > 0 { 8758 + t.Mentions = make([]string, extra) 8759 + } 8760 + 8761 + for i := 0; i < int(extra); i++ { 8762 + { 8763 + var maj byte 8764 + var extra uint64 8765 + var err error 8766 + _ = maj 8767 + _ = extra 8768 + _ = err 8769 + 8770 + { 8771 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8772 + if err != nil { 8773 + return err 8774 + } 8775 + 8776 + t.Mentions[i] = string(sval) 8777 + } 8778 + 8779 + } 8780 + } 8781 // t.CreatedAt (string) (string) 8782 case "createdAt": 8783 ··· 8788 } 8789 8790 t.CreatedAt = string(sval) 8791 + } 8792 + // t.References ([]string) (slice) 8793 + case "references": 8794 + 8795 + maj, extra, err = cr.ReadHeader() 8796 + if err != nil { 8797 + return err 8798 + } 8799 + 8800 + if extra > 8192 { 8801 + return fmt.Errorf("t.References: array too large (%d)", extra) 8802 + } 8803 + 8804 + if maj != cbg.MajArray { 8805 + return fmt.Errorf("expected cbor array") 8806 + } 8807 + 8808 + if extra > 0 { 8809 + t.References = make([]string, extra) 8810 + } 8811 + 8812 + for i := 0; i < int(extra); i++ { 8813 + { 8814 + var maj byte 8815 + var extra uint64 8816 + var err error 8817 + _ = maj 8818 + _ = extra 8819 + _ = err 8820 + 8821 + { 8822 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8823 + if err != nil { 8824 + return err 8825 + } 8826 + 8827 + t.References[i] = string(sval) 8828 + } 8829 + 8830 + } 8831 } 8832 8833 default:
+7 -5
api/tangled/issuecomment.go
··· 17 } // 18 // RECORDTYPE: RepoIssueComment 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"` 25 }
··· 17 } // 18 // RECORDTYPE: RepoIssueComment 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 + 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"` 27 }
+6 -4
api/tangled/pullcomment.go
··· 17 } // 18 // RECORDTYPE: RepoPullComment 19 type RepoPullComment struct { 20 - LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull.comment" cborgen:"$type,const=sh.tangled.repo.pull.comment"` 21 - Body string `json:"body" cborgen:"body"` 22 - CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 - Pull string `json:"pull" cborgen:"pull"` 24 }
··· 17 } // 18 // RECORDTYPE: RepoPullComment 19 type RepoPullComment struct { 20 + LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull.comment" cborgen:"$type,const=sh.tangled.repo.pull.comment"` 21 + Body string `json:"body" cborgen:"body"` 22 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 + Mentions []string `json:"mentions,omitempty" cborgen:"mentions,omitempty"` 24 + Pull string `json:"pull" cborgen:"pull"` 25 + References []string `json:"references,omitempty" cborgen:"references,omitempty"` 26 }
+7 -5
api/tangled/repoissue.go
··· 17 } // 18 // RECORDTYPE: RepoIssue 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"` 25 }
··· 17 } // 18 // RECORDTYPE: RepoIssue 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 + 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"` 27 }
+12 -7
api/tangled/repopull.go
··· 17 } // 18 // RECORDTYPE: RepoPull 19 type RepoPull struct { 20 - LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull" cborgen:"$type,const=sh.tangled.repo.pull"` 21 - Body *string `json:"body,omitempty" cborgen:"body,omitempty"` 22 - CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 - Patch string `json:"patch" cborgen:"patch"` 24 - Source *RepoPull_Source `json:"source,omitempty" cborgen:"source,omitempty"` 25 - Target *RepoPull_Target `json:"target" cborgen:"target"` 26 - Title string `json:"title" cborgen:"title"` 27 } 28 29 // RepoPull_Source is a "source" in the sh.tangled.repo.pull schema.
··· 17 } // 18 // RECORDTYPE: RepoPull 19 type RepoPull struct { 20 + LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull" cborgen:"$type,const=sh.tangled.repo.pull"` 21 + Body *string `json:"body,omitempty" cborgen:"body,omitempty"` 22 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 + Mentions []string `json:"mentions,omitempty" cborgen:"mentions,omitempty"` 24 + // patch: (deprecated) use patchBlob instead 25 + Patch *string `json:"patch,omitempty" cborgen:"patch,omitempty"` 26 + // patchBlob: patch content 27 + PatchBlob *util.LexBlob `json:"patchBlob" cborgen:"patchBlob"` 28 + References []string `json:"references,omitempty" cborgen:"references,omitempty"` 29 + Source *RepoPull_Source `json:"source,omitempty" cborgen:"source,omitempty"` 30 + Target *RepoPull_Target `json:"target" cborgen:"target"` 31 + Title string `json:"title" cborgen:"title"` 32 } 33 34 // RepoPull_Source is a "source" in the sh.tangled.repo.pull schema.
+6 -45
appview/commitverify/verify.go
··· 3 import ( 4 "log" 5 6 - "github.com/go-git/go-git/v5/plumbing/object" 7 "tangled.org/core/appview/db" 8 "tangled.org/core/appview/models" 9 "tangled.org/core/crypto" ··· 35 return "" 36 } 37 38 - func GetVerifiedObjectCommits(e db.Execer, emailToDid map[string]string, commits []*object.Commit) (VerifiedCommits, error) { 39 - ndCommits := []types.NiceDiff{} 40 - for _, commit := range commits { 41 - ndCommits = append(ndCommits, ObjectCommitToNiceDiff(commit)) 42 - } 43 - return GetVerifiedCommits(e, emailToDid, ndCommits) 44 - } 45 - 46 - func GetVerifiedCommits(e db.Execer, emailToDid map[string]string, ndCommits []types.NiceDiff) (VerifiedCommits, error) { 47 vcs := VerifiedCommits{} 48 49 didPubkeyCache := make(map[string][]models.PublicKey) 50 51 for _, commit := range ndCommits { 52 - c := commit.Commit 53 - 54 - committerEmail := c.Committer.Email 55 if did, exists := emailToDid[committerEmail]; exists { 56 // check if we've already fetched public keys for this did 57 pubKeys, ok := didPubkeyCache[did] ··· 67 } 68 69 // try to verify with any associated pubkeys 70 for _, pk := range pubKeys { 71 - if _, ok := crypto.VerifyCommitSignature(pk.Key, commit); ok { 72 73 fp, err := crypto.SSHFingerprint(pk.Key) 74 if err != nil { 75 log.Println("error computing ssh fingerprint:", err) 76 } 77 78 - vc := verifiedCommit{fingerprint: fp, hash: c.This} 79 vcs[vc] = struct{}{} 80 break 81 } ··· 86 87 return vcs, nil 88 } 89 - 90 - // ObjectCommitToNiceDiff is a compatibility function to convert a 91 - // commit object into a NiceDiff structure. 92 - func ObjectCommitToNiceDiff(c *object.Commit) types.NiceDiff { 93 - var niceDiff types.NiceDiff 94 - 95 - // set commit information 96 - niceDiff.Commit.Message = c.Message 97 - niceDiff.Commit.Author = c.Author 98 - niceDiff.Commit.This = c.Hash.String() 99 - niceDiff.Commit.Committer = c.Committer 100 - niceDiff.Commit.Tree = c.TreeHash.String() 101 - niceDiff.Commit.PGPSignature = c.PGPSignature 102 - 103 - changeId, ok := c.ExtraHeaders["change-id"] 104 - if ok { 105 - niceDiff.Commit.ChangedId = string(changeId) 106 - } 107 - 108 - // set parent hash if available 109 - if len(c.ParentHashes) > 0 { 110 - niceDiff.Commit.Parent = c.ParentHashes[0].String() 111 - } 112 - 113 - // XXX: Stats and Diff fields are typically populated 114 - // after fetching the actual diff information, which isn't 115 - // directly available in the commit object itself. 116 - 117 - return niceDiff 118 - }
··· 3 import ( 4 "log" 5 6 "tangled.org/core/appview/db" 7 "tangled.org/core/appview/models" 8 "tangled.org/core/crypto" ··· 34 return "" 35 } 36 37 + func GetVerifiedCommits(e db.Execer, emailToDid map[string]string, ndCommits []types.Commit) (VerifiedCommits, error) { 38 vcs := VerifiedCommits{} 39 40 didPubkeyCache := make(map[string][]models.PublicKey) 41 42 for _, commit := range ndCommits { 43 + committerEmail := commit.Committer.Email 44 if did, exists := emailToDid[committerEmail]; exists { 45 // check if we've already fetched public keys for this did 46 pubKeys, ok := didPubkeyCache[did] ··· 56 } 57 58 // try to verify with any associated pubkeys 59 + payload := commit.Payload() 60 + signature := commit.PGPSignature 61 for _, pk := range pubKeys { 62 + if _, ok := crypto.VerifySignature([]byte(pk.Key), []byte(signature), []byte(payload)); ok { 63 64 fp, err := crypto.SSHFingerprint(pk.Key) 65 if err != nil { 66 log.Println("error computing ssh fingerprint:", err) 67 } 68 69 + vc := verifiedCommit{fingerprint: fp, hash: commit.This} 70 vcs[vc] = struct{}{} 71 break 72 } ··· 77 78 return vcs, nil 79 }
+3 -2
appview/db/artifact.go
··· 8 "github.com/go-git/go-git/v5/plumbing" 9 "github.com/ipfs/go-cid" 10 "tangled.org/core/appview/models" 11 ) 12 13 func AddArtifact(e Execer, artifact models.Artifact) error { ··· 37 return err 38 } 39 40 - func GetArtifact(e Execer, filters ...filter) ([]models.Artifact, error) { 41 var artifacts []models.Artifact 42 43 var conditions []string ··· 109 return artifacts, nil 110 } 111 112 - func DeleteArtifact(e Execer, filters ...filter) error { 113 var conditions []string 114 var args []any 115 for _, filter := range filters {
··· 8 "github.com/go-git/go-git/v5/plumbing" 9 "github.com/ipfs/go-cid" 10 "tangled.org/core/appview/models" 11 + "tangled.org/core/orm" 12 ) 13 14 func AddArtifact(e Execer, artifact models.Artifact) error { ··· 38 return err 39 } 40 41 + func GetArtifact(e Execer, filters ...orm.Filter) ([]models.Artifact, error) { 42 var artifacts []models.Artifact 43 44 var conditions []string ··· 110 return artifacts, nil 111 } 112 113 + func DeleteArtifact(e Execer, filters ...orm.Filter) error { 114 var conditions []string 115 var args []any 116 for _, filter := range filters {
+4 -3
appview/db/collaborators.go
··· 6 "time" 7 8 "tangled.org/core/appview/models" 9 ) 10 11 func AddCollaborator(e Execer, c models.Collaborator) error { ··· 16 return err 17 } 18 19 - func DeleteCollaborator(e Execer, filters ...filter) error { 20 var conditions []string 21 var args []any 22 for _, filter := range filters { ··· 58 return nil, nil 59 } 60 61 - return GetRepos(e, 0, FilterIn("at_uri", repoAts)) 62 } 63 64 - func GetCollaborators(e Execer, filters ...filter) ([]models.Collaborator, error) { 65 var collaborators []models.Collaborator 66 var conditions []string 67 var args []any
··· 6 "time" 7 8 "tangled.org/core/appview/models" 9 + "tangled.org/core/orm" 10 ) 11 12 func AddCollaborator(e Execer, c models.Collaborator) error { ··· 17 return err 18 } 19 20 + func DeleteCollaborator(e Execer, filters ...orm.Filter) error { 21 var conditions []string 22 var args []any 23 for _, filter := range filters { ··· 59 return nil, nil 60 } 61 62 + return GetRepos(e, 0, orm.FilterIn("at_uri", repoAts)) 63 } 64 65 + func GetCollaborators(e Execer, filters ...orm.Filter) ([]models.Collaborator, error) { 66 var collaborators []models.Collaborator 67 var conditions []string 68 var args []any
+69 -136
appview/db/db.go
··· 3 import ( 4 "context" 5 "database/sql" 6 - "fmt" 7 "log/slog" 8 - "reflect" 9 "strings" 10 11 _ "github.com/mattn/go-sqlite3" 12 "tangled.org/core/log" 13 ) 14 15 type DB struct { ··· 561 email_notifications integer not null default 0 562 ); 563 564 create table if not exists migrations ( 565 id integer primary key autoincrement, 566 name text unique ··· 569 -- indexes for better performance 570 create index if not exists idx_notifications_recipient_created on notifications(recipient_did, created desc); 571 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); 574 `) 575 if err != nil { 576 return nil, err 577 } 578 579 // run migrations 580 - runMigration(conn, logger, "add-description-to-repos", func(tx *sql.Tx) error { 581 tx.Exec(` 582 alter table repos add column description text check (length(description) <= 200); 583 `) 584 return nil 585 }) 586 587 - runMigration(conn, logger, "add-rkey-to-pubkeys", func(tx *sql.Tx) error { 588 // add unconstrained column 589 _, err := tx.Exec(` 590 alter table public_keys ··· 607 return nil 608 }) 609 610 - runMigration(conn, logger, "add-rkey-to-comments", func(tx *sql.Tx) error { 611 _, err := tx.Exec(` 612 alter table comments drop column comment_at; 613 alter table comments add column rkey text; ··· 615 return err 616 }) 617 618 - runMigration(conn, logger, "add-deleted-and-edited-to-issue-comments", func(tx *sql.Tx) error { 619 _, err := tx.Exec(` 620 alter table comments add column deleted text; -- timestamp 621 alter table comments add column edited text; -- timestamp ··· 623 return err 624 }) 625 626 - runMigration(conn, logger, "add-source-info-to-pulls-and-submissions", func(tx *sql.Tx) error { 627 _, err := tx.Exec(` 628 alter table pulls add column source_branch text; 629 alter table pulls add column source_repo_at text; ··· 632 return err 633 }) 634 635 - runMigration(conn, logger, "add-source-to-repos", func(tx *sql.Tx) error { 636 _, err := tx.Exec(` 637 alter table repos add column source text; 638 `) ··· 644 // 645 // [0]: https://sqlite.org/pragma.html#pragma_foreign_keys 646 conn.ExecContext(ctx, "pragma foreign_keys = off;") 647 - runMigration(conn, logger, "recreate-pulls-column-for-stacking-support", func(tx *sql.Tx) error { 648 _, err := tx.Exec(` 649 create table pulls_new ( 650 -- identifiers ··· 701 }) 702 conn.ExecContext(ctx, "pragma foreign_keys = on;") 703 704 - runMigration(conn, logger, "add-spindle-to-repos", func(tx *sql.Tx) error { 705 tx.Exec(` 706 alter table repos add column spindle text; 707 `) ··· 711 // drop all knot secrets, add unique constraint to knots 712 // 713 // knots will henceforth use service auth for signed requests 714 - runMigration(conn, logger, "no-more-secrets", func(tx *sql.Tx) error { 715 _, err := tx.Exec(` 716 create table registrations_new ( 717 id integer primary key autoincrement, ··· 734 }) 735 736 // recreate and add rkey + created columns with default constraint 737 - runMigration(conn, logger, "rework-collaborators-table", func(tx *sql.Tx) error { 738 // create new table 739 // - repo_at instead of repo integer 740 // - rkey field ··· 788 return err 789 }) 790 791 - runMigration(conn, logger, "add-rkey-to-issues", func(tx *sql.Tx) error { 792 _, err := tx.Exec(` 793 alter table issues add column rkey text not null default ''; 794 ··· 800 }) 801 802 // repurpose the read-only column to "needs-upgrade" 803 - runMigration(conn, logger, "rename-registrations-read-only-to-needs-upgrade", func(tx *sql.Tx) error { 804 _, err := tx.Exec(` 805 alter table registrations rename column read_only to needs_upgrade; 806 `) ··· 808 }) 809 810 // require all knots to upgrade after the release of total xrpc 811 - runMigration(conn, logger, "migrate-knots-to-total-xrpc", func(tx *sql.Tx) error { 812 _, err := tx.Exec(` 813 update registrations set needs_upgrade = 1; 814 `) ··· 816 }) 817 818 // require all knots to upgrade after the release of total xrpc 819 - runMigration(conn, logger, "migrate-spindles-to-xrpc-owner", func(tx *sql.Tx) error { 820 _, err := tx.Exec(` 821 alter table spindles add column needs_upgrade integer not null default 0; 822 `) ··· 834 // 835 // disable foreign-keys for the next migration 836 conn.ExecContext(ctx, "pragma foreign_keys = off;") 837 - runMigration(conn, logger, "remove-issue-at-from-issues", func(tx *sql.Tx) error { 838 _, err := tx.Exec(` 839 create table if not exists issues_new ( 840 -- identifiers ··· 904 // - new columns 905 // * column "reply_to" which can be any other comment 906 // * column "at-uri" which is a generated column 907 - runMigration(conn, logger, "rework-issue-comments", func(tx *sql.Tx) error { 908 _, err := tx.Exec(` 909 create table if not exists issue_comments ( 910 -- identifiers ··· 964 // 965 // disable foreign-keys for the next migration 966 conn.ExecContext(ctx, "pragma foreign_keys = off;") 967 - runMigration(conn, logger, "add-at-uri-to-pulls", func(tx *sql.Tx) error { 968 _, err := tx.Exec(` 969 create table if not exists pulls_new ( 970 -- identifiers ··· 1045 // 1046 // disable foreign-keys for the next migration 1047 conn.ExecContext(ctx, "pragma foreign_keys = off;") 1048 - runMigration(conn, logger, "remove-repo-at-pull-id-from-pull-submissions", func(tx *sql.Tx) error { 1049 _, err := tx.Exec(` 1050 create table if not exists pull_submissions_new ( 1051 -- identifiers ··· 1099 1100 // knots may report the combined patch for a comparison, we can store that on the appview side 1101 // (but not on the pds record), because calculating the combined patch requires a git index 1102 - runMigration(conn, logger, "add-combined-column-submissions", func(tx *sql.Tx) error { 1103 _, err := tx.Exec(` 1104 alter table pull_submissions add column combined text; 1105 `) 1106 return err 1107 }) 1108 1109 - runMigration(conn, logger, "add-pronouns-profile", func(tx *sql.Tx) error { 1110 _, err := tx.Exec(` 1111 alter table profile add column pronouns text; 1112 `) 1113 return err 1114 }) 1115 1116 - runMigration(conn, logger, "add-meta-column-repos", func(tx *sql.Tx) error { 1117 _, err := tx.Exec(` 1118 alter table repos add column website text; 1119 alter table repos add column topics text; ··· 1121 return err 1122 }) 1123 1124 - runMigration(conn, logger, "add-usermentioned-preference", func(tx *sql.Tx) error { 1125 _, err := tx.Exec(` 1126 alter table notification_preferences add column user_mentioned integer not null default 1; 1127 `) 1128 return err 1129 }) 1130 1131 - return &DB{ 1132 - db, 1133 - logger, 1134 - }, nil 1135 - } 1136 1137 - type migrationFn = func(*sql.Tx) error 1138 1139 - func runMigration(c *sql.Conn, logger *slog.Logger, name string, migrationFn migrationFn) error { 1140 - logger = logger.With("migration", name) 1141 1142 - tx, err := c.BeginTx(context.Background(), nil) 1143 - if err != nil { 1144 - return err 1145 - } 1146 - defer tx.Rollback() 1147 1148 - var exists bool 1149 - err = tx.QueryRow("select exists (select 1 from migrations where name = ?)", name).Scan(&exists) 1150 - if err != nil { 1151 return err 1152 - } 1153 - 1154 - if !exists { 1155 - // run migration 1156 - err = migrationFn(tx) 1157 - if err != nil { 1158 - logger.Error("failed to run migration", "err", err) 1159 - return err 1160 - } 1161 - 1162 - // mark migration as complete 1163 - _, err = tx.Exec("insert into migrations (name) values (?)", name) 1164 - if err != nil { 1165 - logger.Error("failed to mark migration as complete", "err", err) 1166 - return err 1167 - } 1168 1169 - // commit the transaction 1170 - if err := tx.Commit(); err != nil { 1171 - return err 1172 - } 1173 - 1174 - logger.Info("migration applied successfully") 1175 - } else { 1176 - logger.Warn("skipped migration, already applied") 1177 - } 1178 - 1179 - return nil 1180 } 1181 1182 func (d *DB) Close() error { 1183 return d.DB.Close() 1184 } 1185 - 1186 - type filter struct { 1187 - key string 1188 - arg any 1189 - cmp string 1190 - } 1191 - 1192 - func newFilter(key, cmp string, arg any) filter { 1193 - return filter{ 1194 - key: key, 1195 - arg: arg, 1196 - cmp: cmp, 1197 - } 1198 - } 1199 - 1200 - func FilterEq(key string, arg any) filter { return newFilter(key, "=", arg) } 1201 - func FilterNotEq(key string, arg any) filter { return newFilter(key, "<>", arg) } 1202 - func FilterGte(key string, arg any) filter { return newFilter(key, ">=", arg) } 1203 - func FilterLte(key string, arg any) filter { return newFilter(key, "<=", arg) } 1204 - func FilterIs(key string, arg any) filter { return newFilter(key, "is", arg) } 1205 - func FilterIsNot(key string, arg any) filter { return newFilter(key, "is not", arg) } 1206 - func FilterIn(key string, arg any) filter { return newFilter(key, "in", arg) } 1207 - func FilterLike(key string, arg any) filter { return newFilter(key, "like", arg) } 1208 - func FilterNotLike(key string, arg any) filter { return newFilter(key, "not like", arg) } 1209 - func FilterContains(key string, arg any) filter { 1210 - return newFilter(key, "like", fmt.Sprintf("%%%v%%", arg)) 1211 - } 1212 - 1213 - func (f filter) Condition() string { 1214 - rv := reflect.ValueOf(f.arg) 1215 - kind := rv.Kind() 1216 - 1217 - // if we have `FilterIn(k, [1, 2, 3])`, compile it down to `k in (?, ?, ?)` 1218 - if (kind == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8) || kind == reflect.Array { 1219 - if rv.Len() == 0 { 1220 - // always false 1221 - return "1 = 0" 1222 - } 1223 - 1224 - placeholders := make([]string, rv.Len()) 1225 - for i := range placeholders { 1226 - placeholders[i] = "?" 1227 - } 1228 - 1229 - return fmt.Sprintf("%s %s (%s)", f.key, f.cmp, strings.Join(placeholders, ", ")) 1230 - } 1231 - 1232 - return fmt.Sprintf("%s %s ?", f.key, f.cmp) 1233 - } 1234 - 1235 - func (f filter) Arg() []any { 1236 - rv := reflect.ValueOf(f.arg) 1237 - kind := rv.Kind() 1238 - if (kind == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8) || kind == reflect.Array { 1239 - if rv.Len() == 0 { 1240 - return nil 1241 - } 1242 - 1243 - out := make([]any, rv.Len()) 1244 - for i := range rv.Len() { 1245 - out[i] = rv.Index(i).Interface() 1246 - } 1247 - return out 1248 - } 1249 - 1250 - return []any{f.arg} 1251 - }
··· 3 import ( 4 "context" 5 "database/sql" 6 "log/slog" 7 "strings" 8 9 _ "github.com/mattn/go-sqlite3" 10 "tangled.org/core/log" 11 + "tangled.org/core/orm" 12 ) 13 14 type DB struct { ··· 560 email_notifications integer not null default 0 561 ); 562 563 + create table if not exists reference_links ( 564 + id integer primary key autoincrement, 565 + from_at text not null, 566 + to_at text not null, 567 + unique (from_at, to_at) 568 + ); 569 + 570 create table if not exists migrations ( 571 id integer primary key autoincrement, 572 name text unique ··· 575 -- indexes for better performance 576 create index if not exists idx_notifications_recipient_created on notifications(recipient_did, created desc); 577 create index if not exists idx_notifications_recipient_read on notifications(recipient_did, read); 578 + create index if not exists idx_references_from_at on reference_links(from_at); 579 + create index if not exists idx_references_to_at on reference_links(to_at); 580 `) 581 if err != nil { 582 return nil, err 583 } 584 585 // run migrations 586 + orm.RunMigration(conn, logger, "add-description-to-repos", func(tx *sql.Tx) error { 587 tx.Exec(` 588 alter table repos add column description text check (length(description) <= 200); 589 `) 590 return nil 591 }) 592 593 + orm.RunMigration(conn, logger, "add-rkey-to-pubkeys", func(tx *sql.Tx) error { 594 // add unconstrained column 595 _, err := tx.Exec(` 596 alter table public_keys ··· 613 return nil 614 }) 615 616 + orm.RunMigration(conn, logger, "add-rkey-to-comments", func(tx *sql.Tx) error { 617 _, err := tx.Exec(` 618 alter table comments drop column comment_at; 619 alter table comments add column rkey text; ··· 621 return err 622 }) 623 624 + orm.RunMigration(conn, logger, "add-deleted-and-edited-to-issue-comments", func(tx *sql.Tx) error { 625 _, err := tx.Exec(` 626 alter table comments add column deleted text; -- timestamp 627 alter table comments add column edited text; -- timestamp ··· 629 return err 630 }) 631 632 + orm.RunMigration(conn, logger, "add-source-info-to-pulls-and-submissions", func(tx *sql.Tx) error { 633 _, err := tx.Exec(` 634 alter table pulls add column source_branch text; 635 alter table pulls add column source_repo_at text; ··· 638 return err 639 }) 640 641 + orm.RunMigration(conn, logger, "add-source-to-repos", func(tx *sql.Tx) error { 642 _, err := tx.Exec(` 643 alter table repos add column source text; 644 `) ··· 650 // 651 // [0]: https://sqlite.org/pragma.html#pragma_foreign_keys 652 conn.ExecContext(ctx, "pragma foreign_keys = off;") 653 + orm.RunMigration(conn, logger, "recreate-pulls-column-for-stacking-support", func(tx *sql.Tx) error { 654 _, err := tx.Exec(` 655 create table pulls_new ( 656 -- identifiers ··· 707 }) 708 conn.ExecContext(ctx, "pragma foreign_keys = on;") 709 710 + orm.RunMigration(conn, logger, "add-spindle-to-repos", func(tx *sql.Tx) error { 711 tx.Exec(` 712 alter table repos add column spindle text; 713 `) ··· 717 // drop all knot secrets, add unique constraint to knots 718 // 719 // knots will henceforth use service auth for signed requests 720 + orm.RunMigration(conn, logger, "no-more-secrets", func(tx *sql.Tx) error { 721 _, err := tx.Exec(` 722 create table registrations_new ( 723 id integer primary key autoincrement, ··· 740 }) 741 742 // recreate and add rkey + created columns with default constraint 743 + orm.RunMigration(conn, logger, "rework-collaborators-table", func(tx *sql.Tx) error { 744 // create new table 745 // - repo_at instead of repo integer 746 // - rkey field ··· 794 return err 795 }) 796 797 + orm.RunMigration(conn, logger, "add-rkey-to-issues", func(tx *sql.Tx) error { 798 _, err := tx.Exec(` 799 alter table issues add column rkey text not null default ''; 800 ··· 806 }) 807 808 // repurpose the read-only column to "needs-upgrade" 809 + orm.RunMigration(conn, logger, "rename-registrations-read-only-to-needs-upgrade", func(tx *sql.Tx) error { 810 _, err := tx.Exec(` 811 alter table registrations rename column read_only to needs_upgrade; 812 `) ··· 814 }) 815 816 // require all knots to upgrade after the release of total xrpc 817 + orm.RunMigration(conn, logger, "migrate-knots-to-total-xrpc", func(tx *sql.Tx) error { 818 _, err := tx.Exec(` 819 update registrations set needs_upgrade = 1; 820 `) ··· 822 }) 823 824 // require all knots to upgrade after the release of total xrpc 825 + orm.RunMigration(conn, logger, "migrate-spindles-to-xrpc-owner", func(tx *sql.Tx) error { 826 _, err := tx.Exec(` 827 alter table spindles add column needs_upgrade integer not null default 0; 828 `) ··· 840 // 841 // disable foreign-keys for the next migration 842 conn.ExecContext(ctx, "pragma foreign_keys = off;") 843 + orm.RunMigration(conn, logger, "remove-issue-at-from-issues", func(tx *sql.Tx) error { 844 _, err := tx.Exec(` 845 create table if not exists issues_new ( 846 -- identifiers ··· 910 // - new columns 911 // * column "reply_to" which can be any other comment 912 // * column "at-uri" which is a generated column 913 + orm.RunMigration(conn, logger, "rework-issue-comments", func(tx *sql.Tx) error { 914 _, err := tx.Exec(` 915 create table if not exists issue_comments ( 916 -- identifiers ··· 970 // 971 // disable foreign-keys for the next migration 972 conn.ExecContext(ctx, "pragma foreign_keys = off;") 973 + orm.RunMigration(conn, logger, "add-at-uri-to-pulls", func(tx *sql.Tx) error { 974 _, err := tx.Exec(` 975 create table if not exists pulls_new ( 976 -- identifiers ··· 1051 // 1052 // disable foreign-keys for the next migration 1053 conn.ExecContext(ctx, "pragma foreign_keys = off;") 1054 + orm.RunMigration(conn, logger, "remove-repo-at-pull-id-from-pull-submissions", func(tx *sql.Tx) error { 1055 _, err := tx.Exec(` 1056 create table if not exists pull_submissions_new ( 1057 -- identifiers ··· 1105 1106 // knots may report the combined patch for a comparison, we can store that on the appview side 1107 // (but not on the pds record), because calculating the combined patch requires a git index 1108 + orm.RunMigration(conn, logger, "add-combined-column-submissions", func(tx *sql.Tx) error { 1109 _, err := tx.Exec(` 1110 alter table pull_submissions add column combined text; 1111 `) 1112 return err 1113 }) 1114 1115 + orm.RunMigration(conn, logger, "add-pronouns-profile", func(tx *sql.Tx) error { 1116 _, err := tx.Exec(` 1117 alter table profile add column pronouns text; 1118 `) 1119 return err 1120 }) 1121 1122 + orm.RunMigration(conn, logger, "add-meta-column-repos", func(tx *sql.Tx) error { 1123 _, err := tx.Exec(` 1124 alter table repos add column website text; 1125 alter table repos add column topics text; ··· 1127 return err 1128 }) 1129 1130 + orm.RunMigration(conn, logger, "add-usermentioned-preference", func(tx *sql.Tx) error { 1131 _, err := tx.Exec(` 1132 alter table notification_preferences add column user_mentioned integer not null default 1; 1133 `) 1134 return err 1135 }) 1136 1137 + // remove the foreign key constraints from stars. 1138 + orm.RunMigration(conn, logger, "generalize-stars-subject", func(tx *sql.Tx) error { 1139 + _, err := tx.Exec(` 1140 + create table stars_new ( 1141 + id integer primary key autoincrement, 1142 + did text not null, 1143 + rkey text not null, 1144 + 1145 + subject_at text not null, 1146 1147 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 1148 + unique(did, rkey), 1149 + unique(did, subject_at) 1150 + ); 1151 1152 + insert into stars_new ( 1153 + id, 1154 + did, 1155 + rkey, 1156 + subject_at, 1157 + created 1158 + ) 1159 + select 1160 + id, 1161 + starred_by_did, 1162 + rkey, 1163 + repo_at, 1164 + created 1165 + from stars; 1166 1167 + drop table stars; 1168 + alter table stars_new rename to stars; 1169 1170 + create index if not exists idx_stars_created on stars(created); 1171 + create index if not exists idx_stars_subject_at_created on stars(subject_at, created); 1172 + `) 1173 return err 1174 + }) 1175 1176 + return &DB{ 1177 + db, 1178 + logger, 1179 + }, nil 1180 } 1181 1182 func (d *DB) Close() error { 1183 return d.DB.Close() 1184 }
+6 -3
appview/db/follow.go
··· 7 "time" 8 9 "tangled.org/core/appview/models" 10 ) 11 12 func AddFollow(e Execer, follow *models.Follow) error { ··· 134 return result, nil 135 } 136 137 - func GetFollows(e Execer, limit int, filters ...filter) ([]models.Follow, error) { 138 var follows []models.Follow 139 140 var conditions []string ··· 166 if err != nil { 167 return nil, err 168 } 169 for rows.Next() { 170 var follow models.Follow 171 var followedAt string ··· 191 } 192 193 func GetFollowers(e Execer, did string) ([]models.Follow, error) { 194 - return GetFollows(e, 0, FilterEq("subject_did", did)) 195 } 196 197 func GetFollowing(e Execer, did string) ([]models.Follow, error) { 198 - return GetFollows(e, 0, FilterEq("user_did", did)) 199 } 200 201 func getFollowStatuses(e Execer, userDid string, subjectDids []string) (map[string]models.FollowStatus, error) {
··· 7 "time" 8 9 "tangled.org/core/appview/models" 10 + "tangled.org/core/orm" 11 ) 12 13 func AddFollow(e Execer, follow *models.Follow) error { ··· 135 return result, nil 136 } 137 138 + func GetFollows(e Execer, limit int, filters ...orm.Filter) ([]models.Follow, error) { 139 var follows []models.Follow 140 141 var conditions []string ··· 167 if err != nil { 168 return nil, err 169 } 170 + defer rows.Close() 171 + 172 for rows.Next() { 173 var follow models.Follow 174 var followedAt string ··· 194 } 195 196 func GetFollowers(e Execer, did string) ([]models.Follow, error) { 197 + return GetFollows(e, 0, orm.FilterEq("subject_did", did)) 198 } 199 200 func GetFollowing(e Execer, did string) ([]models.Follow, error) { 201 + return GetFollows(e, 0, orm.FilterEq("user_did", did)) 202 } 203 204 func getFollowStatuses(e Execer, userDid string, subjectDids []string) (map[string]models.FollowStatus, error) {
+93 -36
appview/db/issues.go
··· 10 "time" 11 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 "tangled.org/core/appview/models" 14 "tangled.org/core/appview/pagination" 15 ) 16 17 func PutIssue(tx *sql.Tx, issue *models.Issue) error { ··· 26 27 issues, err := GetIssues( 28 tx, 29 - FilterEq("did", issue.Did), 30 - FilterEq("rkey", issue.Rkey), 31 ) 32 switch { 33 case err != nil: ··· 69 returning rowid, issue_id 70 `, issue.RepoAt, issue.Did, issue.Rkey, newIssueId, issue.Title, issue.Body) 71 72 - return row.Scan(&issue.Id, &issue.IssueId) 73 } 74 75 func updateIssue(tx *sql.Tx, issue *models.Issue) error { ··· 79 set title = ?, body = ?, edited = ? 80 where did = ? and rkey = ? 81 `, issue.Title, issue.Body, time.Now().Format(time.RFC3339), issue.Did, issue.Rkey) 82 - return err 83 } 84 85 - func GetIssuesPaginated(e Execer, page pagination.Page, filters ...filter) ([]models.Issue, error) { 86 issueMap := make(map[string]*models.Issue) // at-uri -> issue 87 88 var conditions []string ··· 98 whereClause = " where " + strings.Join(conditions, " and ") 99 } 100 101 - pLower := FilterGte("row_num", page.Offset+1) 102 - pUpper := FilterLte("row_num", page.Offset+page.Limit) 103 104 pageClause := "" 105 if page.Limit > 0 { ··· 189 repoAts = append(repoAts, string(issue.RepoAt)) 190 } 191 192 - repos, err := GetRepos(e, 0, FilterIn("at_uri", repoAts)) 193 if err != nil { 194 return nil, fmt.Errorf("failed to build repo mappings: %w", err) 195 } ··· 212 // collect comments 213 issueAts := slices.Collect(maps.Keys(issueMap)) 214 215 - comments, err := GetIssueComments(e, FilterIn("issue_at", issueAts)) 216 if err != nil { 217 return nil, fmt.Errorf("failed to query comments: %w", err) 218 } ··· 224 } 225 226 // collect allLabels for each issue 227 - allLabels, err := GetLabels(e, FilterIn("subject", issueAts)) 228 if err != nil { 229 return nil, fmt.Errorf("failed to query labels: %w", err) 230 } ··· 234 } 235 } 236 237 var issues []models.Issue 238 for _, i := range issueMap { 239 issues = append(issues, *i) ··· 250 issues, err := GetIssuesPaginated( 251 e, 252 pagination.Page{}, 253 - FilterEq("repo_at", repoAt), 254 - FilterEq("issue_id", issueId), 255 ) 256 if err != nil { 257 return nil, err ··· 263 return &issues[0], nil 264 } 265 266 - func GetIssues(e Execer, filters ...filter) ([]models.Issue, error) { 267 return GetIssuesPaginated(e, pagination.Page{}, filters...) 268 } 269 ··· 271 func GetIssueIDs(e Execer, opts models.IssueSearchOptions) ([]int64, error) { 272 var ids []int64 273 274 - var filters []filter 275 openValue := 0 276 if opts.IsOpen { 277 openValue = 1 278 } 279 - filters = append(filters, FilterEq("open", openValue)) 280 if opts.RepoAt != "" { 281 - filters = append(filters, FilterEq("repo_at", opts.RepoAt)) 282 } 283 284 var conditions []string ··· 323 return ids, nil 324 } 325 326 - func AddIssueComment(e Execer, c models.IssueComment) (int64, error) { 327 - result, err := e.Exec( 328 `insert into issue_comments ( 329 did, 330 rkey, ··· 363 return 0, err 364 } 365 366 return id, nil 367 } 368 369 - func DeleteIssueComments(e Execer, filters ...filter) error { 370 var conditions []string 371 var args []any 372 for _, filter := range filters { ··· 385 return err 386 } 387 388 - func GetIssueComments(e Execer, filters ...filter) ([]models.IssueComment, error) { 389 - var comments []models.IssueComment 390 391 var conditions []string 392 var args []any ··· 420 if err != nil { 421 return nil, err 422 } 423 424 for rows.Next() { 425 var comment models.IssueComment ··· 465 comment.ReplyTo = &replyTo.V 466 } 467 468 - comments = append(comments, comment) 469 } 470 471 if err = rows.Err(); err != nil { 472 return nil, err 473 } 474 475 return comments, nil 476 } 477 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()...) 484 } 485 486 - whereClause := "" 487 - if conditions != nil { 488 - whereClause = " where " + strings.Join(conditions, " and ") 489 } 490 491 - query := fmt.Sprintf(`delete from issues %s`, whereClause) 492 - _, err := e.Exec(query, args...) 493 - return err 494 } 495 496 - func CloseIssues(e Execer, filters ...filter) error { 497 var conditions []string 498 var args []any 499 for _, filter := range filters { ··· 511 return err 512 } 513 514 - func ReopenIssues(e Execer, filters ...filter) error { 515 var conditions []string 516 var args []any 517 for _, filter := range filters {
··· 10 "time" 11 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 + "tangled.org/core/api/tangled" 14 "tangled.org/core/appview/models" 15 "tangled.org/core/appview/pagination" 16 + "tangled.org/core/orm" 17 ) 18 19 func PutIssue(tx *sql.Tx, issue *models.Issue) error { ··· 28 29 issues, err := GetIssues( 30 tx, 31 + orm.FilterEq("did", issue.Did), 32 + orm.FilterEq("rkey", issue.Rkey), 33 ) 34 switch { 35 case err != nil: ··· 71 returning rowid, issue_id 72 `, issue.RepoAt, issue.Did, issue.Rkey, newIssueId, issue.Title, issue.Body) 73 74 + err = row.Scan(&issue.Id, &issue.IssueId) 75 + if err != nil { 76 + return fmt.Errorf("scan row: %w", err) 77 + } 78 + 79 + if err := putReferences(tx, issue.AtUri(), issue.References); err != nil { 80 + return fmt.Errorf("put reference_links: %w", err) 81 + } 82 + return nil 83 } 84 85 func updateIssue(tx *sql.Tx, issue *models.Issue) error { ··· 89 set title = ?, body = ?, edited = ? 90 where did = ? and rkey = ? 91 `, issue.Title, issue.Body, time.Now().Format(time.RFC3339), issue.Did, issue.Rkey) 92 + if err != nil { 93 + return err 94 + } 95 + 96 + if err := putReferences(tx, issue.AtUri(), issue.References); err != nil { 97 + return fmt.Errorf("put reference_links: %w", err) 98 + } 99 + return nil 100 } 101 102 + func GetIssuesPaginated(e Execer, page pagination.Page, filters ...orm.Filter) ([]models.Issue, error) { 103 issueMap := make(map[string]*models.Issue) // at-uri -> issue 104 105 var conditions []string ··· 115 whereClause = " where " + strings.Join(conditions, " and ") 116 } 117 118 + pLower := orm.FilterGte("row_num", page.Offset+1) 119 + pUpper := orm.FilterLte("row_num", page.Offset+page.Limit) 120 121 pageClause := "" 122 if page.Limit > 0 { ··· 206 repoAts = append(repoAts, string(issue.RepoAt)) 207 } 208 209 + repos, err := GetRepos(e, 0, orm.FilterIn("at_uri", repoAts)) 210 if err != nil { 211 return nil, fmt.Errorf("failed to build repo mappings: %w", err) 212 } ··· 229 // collect comments 230 issueAts := slices.Collect(maps.Keys(issueMap)) 231 232 + comments, err := GetIssueComments(e, orm.FilterIn("issue_at", issueAts)) 233 if err != nil { 234 return nil, fmt.Errorf("failed to query comments: %w", err) 235 } ··· 241 } 242 243 // collect allLabels for each issue 244 + allLabels, err := GetLabels(e, orm.FilterIn("subject", issueAts)) 245 if err != nil { 246 return nil, fmt.Errorf("failed to query labels: %w", err) 247 } ··· 251 } 252 } 253 254 + // collect references for each issue 255 + allReferencs, err := GetReferencesAll(e, orm.FilterIn("from_at", issueAts)) 256 + if err != nil { 257 + return nil, fmt.Errorf("failed to query reference_links: %w", err) 258 + } 259 + for issueAt, references := range allReferencs { 260 + if issue, ok := issueMap[issueAt.String()]; ok { 261 + issue.References = references 262 + } 263 + } 264 + 265 var issues []models.Issue 266 for _, i := range issueMap { 267 issues = append(issues, *i) ··· 278 issues, err := GetIssuesPaginated( 279 e, 280 pagination.Page{}, 281 + orm.FilterEq("repo_at", repoAt), 282 + orm.FilterEq("issue_id", issueId), 283 ) 284 if err != nil { 285 return nil, err ··· 291 return &issues[0], nil 292 } 293 294 + func GetIssues(e Execer, filters ...orm.Filter) ([]models.Issue, error) { 295 return GetIssuesPaginated(e, pagination.Page{}, filters...) 296 } 297 ··· 299 func GetIssueIDs(e Execer, opts models.IssueSearchOptions) ([]int64, error) { 300 var ids []int64 301 302 + var filters []orm.Filter 303 openValue := 0 304 if opts.IsOpen { 305 openValue = 1 306 } 307 + filters = append(filters, orm.FilterEq("open", openValue)) 308 if opts.RepoAt != "" { 309 + filters = append(filters, orm.FilterEq("repo_at", opts.RepoAt)) 310 } 311 312 var conditions []string ··· 351 return ids, nil 352 } 353 354 + func AddIssueComment(tx *sql.Tx, c models.IssueComment) (int64, error) { 355 + result, err := tx.Exec( 356 `insert into issue_comments ( 357 did, 358 rkey, ··· 391 return 0, err 392 } 393 394 + if err := putReferences(tx, c.AtUri(), c.References); err != nil { 395 + return 0, fmt.Errorf("put reference_links: %w", err) 396 + } 397 + 398 return id, nil 399 } 400 401 + func DeleteIssueComments(e Execer, filters ...orm.Filter) error { 402 var conditions []string 403 var args []any 404 for _, filter := range filters { ··· 417 return err 418 } 419 420 + func GetIssueComments(e Execer, filters ...orm.Filter) ([]models.IssueComment, error) { 421 + commentMap := make(map[string]*models.IssueComment) 422 423 var conditions []string 424 var args []any ··· 452 if err != nil { 453 return nil, err 454 } 455 + defer rows.Close() 456 457 for rows.Next() { 458 var comment models.IssueComment ··· 498 comment.ReplyTo = &replyTo.V 499 } 500 501 + atUri := comment.AtUri().String() 502 + commentMap[atUri] = &comment 503 } 504 505 if err = rows.Err(); err != nil { 506 return nil, err 507 } 508 509 + // collect references for each comments 510 + commentAts := slices.Collect(maps.Keys(commentMap)) 511 + allReferencs, err := GetReferencesAll(e, orm.FilterIn("from_at", commentAts)) 512 + if err != nil { 513 + return nil, fmt.Errorf("failed to query reference_links: %w", err) 514 + } 515 + for commentAt, references := range allReferencs { 516 + if comment, ok := commentMap[commentAt.String()]; ok { 517 + comment.References = references 518 + } 519 + } 520 + 521 + var comments []models.IssueComment 522 + for _, c := range commentMap { 523 + comments = append(comments, *c) 524 + } 525 + 526 + sort.Slice(comments, func(i, j int) bool { 527 + return comments[i].Created.After(comments[j].Created) 528 + }) 529 + 530 return comments, nil 531 } 532 533 + func DeleteIssues(tx *sql.Tx, did, rkey string) error { 534 + _, err := tx.Exec( 535 + `delete from issues 536 + where did = ? and rkey = ?`, 537 + did, 538 + rkey, 539 + ) 540 + if err != nil { 541 + return fmt.Errorf("delete issue: %w", err) 542 } 543 544 + uri := syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", did, tangled.RepoIssueNSID, rkey)) 545 + err = deleteReferences(tx, uri) 546 + if err != nil { 547 + return fmt.Errorf("delete reference_links: %w", err) 548 } 549 550 + return nil 551 } 552 553 + func CloseIssues(e Execer, filters ...orm.Filter) error { 554 var conditions []string 555 var args []any 556 for _, filter := range filters { ··· 568 return err 569 } 570 571 + func ReopenIssues(e Execer, filters ...orm.Filter) error { 572 var conditions []string 573 var args []any 574 for _, filter := range filters {
+8 -7
appview/db/label.go
··· 10 11 "github.com/bluesky-social/indigo/atproto/syntax" 12 "tangled.org/core/appview/models" 13 ) 14 15 // no updating type for now ··· 59 return id, nil 60 } 61 62 - func DeleteLabelDefinition(e Execer, filters ...filter) error { 63 var conditions []string 64 var args []any 65 for _, filter := range filters { ··· 75 return err 76 } 77 78 - func GetLabelDefinitions(e Execer, filters ...filter) ([]models.LabelDefinition, error) { 79 var labelDefinitions []models.LabelDefinition 80 var conditions []string 81 var args []any ··· 167 } 168 169 // helper to get exactly one label def 170 - func GetLabelDefinition(e Execer, filters ...filter) (*models.LabelDefinition, error) { 171 labels, err := GetLabelDefinitions(e, filters...) 172 if err != nil { 173 return nil, err ··· 227 return id, nil 228 } 229 230 - func GetLabelOps(e Execer, filters ...filter) ([]models.LabelOp, error) { 231 var labelOps []models.LabelOp 232 var conditions []string 233 var args []any ··· 302 } 303 304 // get labels for a given list of subject URIs 305 - func GetLabels(e Execer, filters ...filter) (map[syntax.ATURI]models.LabelState, error) { 306 ops, err := GetLabelOps(e, filters...) 307 if err != nil { 308 return nil, err ··· 322 } 323 labelAts := slices.Collect(maps.Keys(labelAtSet)) 324 325 - actx, err := NewLabelApplicationCtx(e, FilterIn("at_uri", labelAts)) 326 if err != nil { 327 return nil, err 328 } ··· 338 return results, nil 339 } 340 341 - func NewLabelApplicationCtx(e Execer, filters ...filter) (*models.LabelApplicationCtx, error) { 342 labels, err := GetLabelDefinitions(e, filters...) 343 if err != nil { 344 return nil, err
··· 10 11 "github.com/bluesky-social/indigo/atproto/syntax" 12 "tangled.org/core/appview/models" 13 + "tangled.org/core/orm" 14 ) 15 16 // no updating type for now ··· 60 return id, nil 61 } 62 63 + func DeleteLabelDefinition(e Execer, filters ...orm.Filter) error { 64 var conditions []string 65 var args []any 66 for _, filter := range filters { ··· 76 return err 77 } 78 79 + func GetLabelDefinitions(e Execer, filters ...orm.Filter) ([]models.LabelDefinition, error) { 80 var labelDefinitions []models.LabelDefinition 81 var conditions []string 82 var args []any ··· 168 } 169 170 // helper to get exactly one label def 171 + func GetLabelDefinition(e Execer, filters ...orm.Filter) (*models.LabelDefinition, error) { 172 labels, err := GetLabelDefinitions(e, filters...) 173 if err != nil { 174 return nil, err ··· 228 return id, nil 229 } 230 231 + func GetLabelOps(e Execer, filters ...orm.Filter) ([]models.LabelOp, error) { 232 var labelOps []models.LabelOp 233 var conditions []string 234 var args []any ··· 303 } 304 305 // get labels for a given list of subject URIs 306 + func GetLabels(e Execer, filters ...orm.Filter) (map[syntax.ATURI]models.LabelState, error) { 307 ops, err := GetLabelOps(e, filters...) 308 if err != nil { 309 return nil, err ··· 323 } 324 labelAts := slices.Collect(maps.Keys(labelAtSet)) 325 326 + actx, err := NewLabelApplicationCtx(e, orm.FilterIn("at_uri", labelAts)) 327 if err != nil { 328 return nil, err 329 } ··· 339 return results, nil 340 } 341 342 + func NewLabelApplicationCtx(e Execer, filters ...orm.Filter) (*models.LabelApplicationCtx, error) { 343 labels, err := GetLabelDefinitions(e, filters...) 344 if err != nil { 345 return nil, err
+6 -5
appview/db/language.go
··· 7 8 "github.com/bluesky-social/indigo/atproto/syntax" 9 "tangled.org/core/appview/models" 10 ) 11 12 - func GetRepoLanguages(e Execer, filters ...filter) ([]models.RepoLanguage, error) { 13 var conditions []string 14 var args []any 15 for _, filter := range filters { ··· 27 whereClause, 28 ) 29 rows, err := e.Query(query, args...) 30 - 31 if err != nil { 32 return nil, fmt.Errorf("failed to execute query: %w ", err) 33 } 34 35 var langs []models.RepoLanguage 36 for rows.Next() { ··· 85 return nil 86 } 87 88 - func DeleteRepoLanguages(e Execer, filters ...filter) error { 89 var conditions []string 90 var args []any 91 for _, filter := range filters { ··· 107 func UpdateRepoLanguages(tx *sql.Tx, repoAt syntax.ATURI, ref string, langs []models.RepoLanguage) error { 108 err := DeleteRepoLanguages( 109 tx, 110 - FilterEq("repo_at", repoAt), 111 - FilterEq("ref", ref), 112 ) 113 if err != nil { 114 return fmt.Errorf("failed to delete existing languages: %w", err)
··· 7 8 "github.com/bluesky-social/indigo/atproto/syntax" 9 "tangled.org/core/appview/models" 10 + "tangled.org/core/orm" 11 ) 12 13 + func GetRepoLanguages(e Execer, filters ...orm.Filter) ([]models.RepoLanguage, error) { 14 var conditions []string 15 var args []any 16 for _, filter := range filters { ··· 28 whereClause, 29 ) 30 rows, err := e.Query(query, args...) 31 if err != nil { 32 return nil, fmt.Errorf("failed to execute query: %w ", err) 33 } 34 + defer rows.Close() 35 36 var langs []models.RepoLanguage 37 for rows.Next() { ··· 86 return nil 87 } 88 89 + func DeleteRepoLanguages(e Execer, filters ...orm.Filter) error { 90 var conditions []string 91 var args []any 92 for _, filter := range filters { ··· 108 func UpdateRepoLanguages(tx *sql.Tx, repoAt syntax.ATURI, ref string, langs []models.RepoLanguage) error { 109 err := DeleteRepoLanguages( 110 tx, 111 + orm.FilterEq("repo_at", repoAt), 112 + orm.FilterEq("ref", ref), 113 ) 114 if err != nil { 115 return fmt.Errorf("failed to delete existing languages: %w", err)
+14 -13
appview/db/notifications.go
··· 11 "github.com/bluesky-social/indigo/atproto/syntax" 12 "tangled.org/core/appview/models" 13 "tangled.org/core/appview/pagination" 14 ) 15 16 func CreateNotification(e Execer, notification *models.Notification) error { ··· 44 } 45 46 // GetNotificationsPaginated retrieves notifications with filters and pagination 47 - func GetNotificationsPaginated(e Execer, page pagination.Page, filters ...filter) ([]*models.Notification, error) { 48 var conditions []string 49 var args []any 50 ··· 113 } 114 115 // GetNotificationsWithEntities retrieves notifications with their related entities 116 - func GetNotificationsWithEntities(e Execer, page pagination.Page, filters ...filter) ([]*models.NotificationWithEntity, error) { 117 var conditions []string 118 var args []any 119 ··· 256 } 257 258 // GetNotifications retrieves notifications with filters 259 - func GetNotifications(e Execer, filters ...filter) ([]*models.Notification, error) { 260 return GetNotificationsPaginated(e, pagination.FirstPage(), filters...) 261 } 262 263 - func CountNotifications(e Execer, filters ...filter) (int64, error) { 264 var conditions []string 265 var args []any 266 for _, filter := range filters { ··· 285 } 286 287 func MarkNotificationRead(e Execer, notificationID int64, userDID string) error { 288 - idFilter := FilterEq("id", notificationID) 289 - recipientFilter := FilterEq("recipient_did", userDID) 290 291 query := fmt.Sprintf(` 292 UPDATE notifications ··· 314 } 315 316 func MarkAllNotificationsRead(e Execer, userDID string) error { 317 - recipientFilter := FilterEq("recipient_did", userDID) 318 - readFilter := FilterEq("read", 0) 319 320 query := fmt.Sprintf(` 321 UPDATE notifications ··· 334 } 335 336 func DeleteNotification(e Execer, notificationID int64, userDID string) error { 337 - idFilter := FilterEq("id", notificationID) 338 - recipientFilter := FilterEq("recipient_did", userDID) 339 340 query := fmt.Sprintf(` 341 DELETE FROM notifications ··· 362 } 363 364 func GetNotificationPreference(e Execer, userDid string) (*models.NotificationPreferences, error) { 365 - prefs, err := GetNotificationPreferences(e, FilterEq("user_did", userDid)) 366 if err != nil { 367 return nil, err 368 } ··· 375 return p, nil 376 } 377 378 - func GetNotificationPreferences(e Execer, filters ...filter) (map[syntax.DID]*models.NotificationPreferences, error) { 379 prefsMap := make(map[syntax.DID]*models.NotificationPreferences) 380 381 var conditions []string ··· 483 484 func (d *DB) ClearOldNotifications(ctx context.Context, olderThan time.Duration) error { 485 cutoff := time.Now().Add(-olderThan) 486 - createdFilter := FilterLte("created", cutoff) 487 488 query := fmt.Sprintf(` 489 DELETE FROM notifications
··· 11 "github.com/bluesky-social/indigo/atproto/syntax" 12 "tangled.org/core/appview/models" 13 "tangled.org/core/appview/pagination" 14 + "tangled.org/core/orm" 15 ) 16 17 func CreateNotification(e Execer, notification *models.Notification) error { ··· 45 } 46 47 // GetNotificationsPaginated retrieves notifications with filters and pagination 48 + func GetNotificationsPaginated(e Execer, page pagination.Page, filters ...orm.Filter) ([]*models.Notification, error) { 49 var conditions []string 50 var args []any 51 ··· 114 } 115 116 // GetNotificationsWithEntities retrieves notifications with their related entities 117 + func GetNotificationsWithEntities(e Execer, page pagination.Page, filters ...orm.Filter) ([]*models.NotificationWithEntity, error) { 118 var conditions []string 119 var args []any 120 ··· 257 } 258 259 // GetNotifications retrieves notifications with filters 260 + func GetNotifications(e Execer, filters ...orm.Filter) ([]*models.Notification, error) { 261 return GetNotificationsPaginated(e, pagination.FirstPage(), filters...) 262 } 263 264 + func CountNotifications(e Execer, filters ...orm.Filter) (int64, error) { 265 var conditions []string 266 var args []any 267 for _, filter := range filters { ··· 286 } 287 288 func MarkNotificationRead(e Execer, notificationID int64, userDID string) error { 289 + idFilter := orm.FilterEq("id", notificationID) 290 + recipientFilter := orm.FilterEq("recipient_did", userDID) 291 292 query := fmt.Sprintf(` 293 UPDATE notifications ··· 315 } 316 317 func MarkAllNotificationsRead(e Execer, userDID string) error { 318 + recipientFilter := orm.FilterEq("recipient_did", userDID) 319 + readFilter := orm.FilterEq("read", 0) 320 321 query := fmt.Sprintf(` 322 UPDATE notifications ··· 335 } 336 337 func DeleteNotification(e Execer, notificationID int64, userDID string) error { 338 + idFilter := orm.FilterEq("id", notificationID) 339 + recipientFilter := orm.FilterEq("recipient_did", userDID) 340 341 query := fmt.Sprintf(` 342 DELETE FROM notifications ··· 363 } 364 365 func GetNotificationPreference(e Execer, userDid string) (*models.NotificationPreferences, error) { 366 + prefs, err := GetNotificationPreferences(e, orm.FilterEq("user_did", userDid)) 367 if err != nil { 368 return nil, err 369 } ··· 376 return p, nil 377 } 378 379 + func GetNotificationPreferences(e Execer, filters ...orm.Filter) (map[syntax.DID]*models.NotificationPreferences, error) { 380 prefsMap := make(map[syntax.DID]*models.NotificationPreferences) 381 382 var conditions []string ··· 484 485 func (d *DB) ClearOldNotifications(ctx context.Context, olderThan time.Duration) error { 486 cutoff := time.Now().Add(-olderThan) 487 + createdFilter := orm.FilterLte("created", cutoff) 488 489 query := fmt.Sprintf(` 490 DELETE FROM notifications
+6 -5
appview/db/pipeline.go
··· 7 "time" 8 9 "tangled.org/core/appview/models" 10 ) 11 12 - func GetPipelines(e Execer, filters ...filter) ([]models.Pipeline, error) { 13 var pipelines []models.Pipeline 14 15 var conditions []string ··· 168 169 // this is a mega query, but the most useful one: 170 // get N pipelines, for each one get the latest status of its N workflows 171 - func GetPipelineStatuses(e Execer, limit int, filters ...filter) ([]models.Pipeline, error) { 172 var conditions []string 173 var args []any 174 for _, filter := range filters { 175 - filter.key = "p." + filter.key // the table is aliased in the query to `p` 176 conditions = append(conditions, filter.Condition()) 177 args = append(args, filter.Arg()...) 178 } ··· 264 conditions = nil 265 args = nil 266 for _, p := range pipelines { 267 - knotFilter := FilterEq("pipeline_knot", p.Knot) 268 - rkeyFilter := FilterEq("pipeline_rkey", p.Rkey) 269 conditions = append(conditions, fmt.Sprintf("(%s and %s)", knotFilter.Condition(), rkeyFilter.Condition())) 270 args = append(args, p.Knot) 271 args = append(args, p.Rkey)
··· 7 "time" 8 9 "tangled.org/core/appview/models" 10 + "tangled.org/core/orm" 11 ) 12 13 + func GetPipelines(e Execer, filters ...orm.Filter) ([]models.Pipeline, error) { 14 var pipelines []models.Pipeline 15 16 var conditions []string ··· 169 170 // this is a mega query, but the most useful one: 171 // get N pipelines, for each one get the latest status of its N workflows 172 + func GetPipelineStatuses(e Execer, limit int, filters ...orm.Filter) ([]models.Pipeline, error) { 173 var conditions []string 174 var args []any 175 for _, filter := range filters { 176 + filter.Key = "p." + filter.Key // the table is aliased in the query to `p` 177 conditions = append(conditions, filter.Condition()) 178 args = append(args, filter.Arg()...) 179 } ··· 265 conditions = nil 266 args = nil 267 for _, p := range pipelines { 268 + knotFilter := orm.FilterEq("pipeline_knot", p.Knot) 269 + rkeyFilter := orm.FilterEq("pipeline_rkey", p.Rkey) 270 conditions = append(conditions, fmt.Sprintf("(%s and %s)", knotFilter.Condition(), rkeyFilter.Condition())) 271 args = append(args, p.Knot) 272 args = append(args, p.Rkey)
+29 -16
appview/db/profile.go
··· 11 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 "tangled.org/core/appview/models" 14 ) 15 16 const TimeframeMonths = 7 ··· 19 timeline := models.ProfileTimeline{ 20 ByMonth: make([]models.ByMonth, TimeframeMonths), 21 } 22 - currentMonth := time.Now().Month() 23 timeframe := fmt.Sprintf("-%d months", TimeframeMonths) 24 25 pulls, err := GetPullsByOwnerDid(e, forDid, timeframe) ··· 29 30 // group pulls by month 31 for _, pull := range pulls { 32 - pullMonth := pull.Created.Month() 33 34 - if currentMonth-pullMonth >= TimeframeMonths { 35 // shouldn't happen; but times are weird 36 continue 37 } 38 39 - idx := currentMonth - pullMonth 40 items := &timeline.ByMonth[idx].PullEvents.Items 41 42 *items = append(*items, &pull) ··· 44 45 issues, err := GetIssues( 46 e, 47 - FilterEq("did", forDid), 48 - FilterGte("created", time.Now().AddDate(0, -TimeframeMonths, 0)), 49 ) 50 if err != nil { 51 return nil, fmt.Errorf("error getting issues by owner did: %w", err) 52 } 53 54 for _, issue := range issues { 55 - issueMonth := issue.Created.Month() 56 57 - if currentMonth-issueMonth >= TimeframeMonths { 58 // shouldn't happen; but times are weird 59 continue 60 } 61 62 - idx := currentMonth - issueMonth 63 items := &timeline.ByMonth[idx].IssueEvents.Items 64 65 *items = append(*items, &issue) 66 } 67 68 - repos, err := GetRepos(e, 0, FilterEq("did", forDid)) 69 if err != nil { 70 return nil, fmt.Errorf("error getting all repos by did: %w", err) 71 } ··· 76 if repo.Source != "" { 77 sourceRepo, err = GetRepoByAtUri(e, repo.Source) 78 if err != nil { 79 - return nil, err 80 } 81 } 82 83 - repoMonth := repo.Created.Month() 84 85 - if currentMonth-repoMonth >= TimeframeMonths { 86 // shouldn't happen; but times are weird 87 continue 88 } 89 90 - idx := currentMonth - repoMonth 91 92 items := &timeline.ByMonth[idx].RepoEvents 93 *items = append(*items, models.RepoEvent{ ··· 99 return &timeline, nil 100 } 101 102 func UpsertProfile(tx *sql.Tx, profile *models.Profile) error { 103 defer tx.Rollback() 104 ··· 199 return tx.Commit() 200 } 201 202 - func GetProfiles(e Execer, filters ...filter) (map[string]*models.Profile, error) { 203 var conditions []string 204 var args []any 205 for _, filter := range filters { ··· 229 if err != nil { 230 return nil, err 231 } 232 233 profileMap := make(map[string]*models.Profile) 234 for rows.Next() { ··· 269 if err != nil { 270 return nil, err 271 } 272 idxs := make(map[string]int) 273 for did := range profileMap { 274 idxs[did] = 0 ··· 289 if err != nil { 290 return nil, err 291 } 292 idxs = make(map[string]int) 293 for did := range profileMap { 294 idxs[did] = 0 ··· 441 } 442 443 // ensure all pinned repos are either own repos or collaborating repos 444 - repos, err := GetRepos(e, 0, FilterEq("did", profile.Did)) 445 if err != nil { 446 log.Printf("getting repos for %s: %s", profile.Did, err) 447 }
··· 11 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 "tangled.org/core/appview/models" 14 + "tangled.org/core/orm" 15 ) 16 17 const TimeframeMonths = 7 ··· 20 timeline := models.ProfileTimeline{ 21 ByMonth: make([]models.ByMonth, TimeframeMonths), 22 } 23 + now := time.Now() 24 timeframe := fmt.Sprintf("-%d months", TimeframeMonths) 25 26 pulls, err := GetPullsByOwnerDid(e, forDid, timeframe) ··· 30 31 // group pulls by month 32 for _, pull := range pulls { 33 + monthsAgo := monthsBetween(pull.Created, now) 34 35 + if monthsAgo >= TimeframeMonths { 36 // shouldn't happen; but times are weird 37 continue 38 } 39 40 + idx := monthsAgo 41 items := &timeline.ByMonth[idx].PullEvents.Items 42 43 *items = append(*items, &pull) ··· 45 46 issues, err := GetIssues( 47 e, 48 + orm.FilterEq("did", forDid), 49 + orm.FilterGte("created", time.Now().AddDate(0, -TimeframeMonths, 0)), 50 ) 51 if err != nil { 52 return nil, fmt.Errorf("error getting issues by owner did: %w", err) 53 } 54 55 for _, issue := range issues { 56 + monthsAgo := monthsBetween(issue.Created, now) 57 58 + if monthsAgo >= TimeframeMonths { 59 // shouldn't happen; but times are weird 60 continue 61 } 62 63 + idx := monthsAgo 64 items := &timeline.ByMonth[idx].IssueEvents.Items 65 66 *items = append(*items, &issue) 67 } 68 69 + repos, err := GetRepos(e, 0, orm.FilterEq("did", forDid)) 70 if err != nil { 71 return nil, fmt.Errorf("error getting all repos by did: %w", err) 72 } ··· 77 if repo.Source != "" { 78 sourceRepo, err = GetRepoByAtUri(e, repo.Source) 79 if err != nil { 80 + // the source repo was not found, skip this bit 81 + log.Println("profile", "err", err) 82 } 83 } 84 85 + monthsAgo := monthsBetween(repo.Created, now) 86 87 + if monthsAgo >= TimeframeMonths { 88 // shouldn't happen; but times are weird 89 continue 90 } 91 92 + idx := monthsAgo 93 94 items := &timeline.ByMonth[idx].RepoEvents 95 *items = append(*items, models.RepoEvent{ ··· 101 return &timeline, nil 102 } 103 104 + func monthsBetween(from, to time.Time) int { 105 + years := to.Year() - from.Year() 106 + months := int(to.Month() - from.Month()) 107 + return years*12 + months 108 + } 109 + 110 func UpsertProfile(tx *sql.Tx, profile *models.Profile) error { 111 defer tx.Rollback() 112 ··· 207 return tx.Commit() 208 } 209 210 + func GetProfiles(e Execer, filters ...orm.Filter) (map[string]*models.Profile, error) { 211 var conditions []string 212 var args []any 213 for _, filter := range filters { ··· 237 if err != nil { 238 return nil, err 239 } 240 + defer rows.Close() 241 242 profileMap := make(map[string]*models.Profile) 243 for rows.Next() { ··· 278 if err != nil { 279 return nil, err 280 } 281 + defer rows.Close() 282 + 283 idxs := make(map[string]int) 284 for did := range profileMap { 285 idxs[did] = 0 ··· 300 if err != nil { 301 return nil, err 302 } 303 + defer rows.Close() 304 + 305 idxs = make(map[string]int) 306 for did := range profileMap { 307 idxs[did] = 0 ··· 454 } 455 456 // ensure all pinned repos are either own repos or collaborating repos 457 + repos, err := GetRepos(e, 0, orm.FilterEq("did", profile.Did)) 458 if err != nil { 459 log.Printf("getting repos for %s: %s", profile.Did, err) 460 }
+69 -24
appview/db/pulls.go
··· 13 14 "github.com/bluesky-social/indigo/atproto/syntax" 15 "tangled.org/core/appview/models" 16 ) 17 18 func NewPull(tx *sql.Tx, pull *models.Pull) error { ··· 93 insert into pull_submissions (pull_at, round_number, patch, combined, source_rev) 94 values (?, ?, ?, ?, ?) 95 `, pull.AtUri(), 0, pull.Submissions[0].Patch, pull.Submissions[0].Combined, pull.Submissions[0].SourceRev) 96 - return err 97 } 98 99 func GetPullAt(e Execer, repoAt syntax.ATURI, pullId int) (syntax.ATURI, error) { ··· 110 return pullId - 1, err 111 } 112 113 - func GetPullsWithLimit(e Execer, limit int, filters ...filter) ([]*models.Pull, error) { 114 pulls := make(map[syntax.ATURI]*models.Pull) 115 116 var conditions []string ··· 221 for _, p := range pulls { 222 pullAts = append(pullAts, p.AtUri()) 223 } 224 - submissionsMap, err := GetPullSubmissions(e, FilterIn("pull_at", pullAts)) 225 if err != nil { 226 return nil, fmt.Errorf("failed to get submissions: %w", err) 227 } ··· 233 } 234 235 // collect allLabels for each issue 236 - allLabels, err := GetLabels(e, FilterIn("subject", pullAts)) 237 if err != nil { 238 return nil, fmt.Errorf("failed to query labels: %w", err) 239 } ··· 250 sourceAts = append(sourceAts, *p.PullSource.RepoAt) 251 } 252 } 253 - sourceRepos, err := GetRepos(e, 0, FilterIn("at_uri", sourceAts)) 254 if err != nil && !errors.Is(err, sql.ErrNoRows) { 255 return nil, fmt.Errorf("failed to get source repos: %w", err) 256 } ··· 266 } 267 } 268 269 orderedByPullId := []*models.Pull{} 270 for _, p := range pulls { 271 orderedByPullId = append(orderedByPullId, p) ··· 277 return orderedByPullId, nil 278 } 279 280 - func GetPulls(e Execer, filters ...filter) ([]*models.Pull, error) { 281 return GetPullsWithLimit(e, 0, filters...) 282 } 283 284 func GetPullIDs(e Execer, opts models.PullSearchOptions) ([]int64, error) { 285 var ids []int64 286 287 - var filters []filter 288 - filters = append(filters, FilterEq("state", opts.State)) 289 if opts.RepoAt != "" { 290 - filters = append(filters, FilterEq("repo_at", opts.RepoAt)) 291 } 292 293 var conditions []string ··· 343 } 344 345 func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*models.Pull, error) { 346 - pulls, err := GetPullsWithLimit(e, 1, FilterEq("repo_at", repoAt), FilterEq("pull_id", pullId)) 347 if err != nil { 348 return nil, err 349 } ··· 355 } 356 357 // mapping from pull -> pull submissions 358 - func GetPullSubmissions(e Execer, filters ...filter) (map[syntax.ATURI][]*models.PullSubmission, error) { 359 var conditions []string 360 var args []any 361 for _, filter := range filters { ··· 430 431 // Get comments for all submissions using GetPullComments 432 submissionIds := slices.Collect(maps.Keys(submissionMap)) 433 - comments, err := GetPullComments(e, FilterIn("submission_id", submissionIds)) 434 if err != nil { 435 - return nil, err 436 } 437 for _, comment := range comments { 438 if submission, ok := submissionMap[comment.SubmissionId]; ok { ··· 456 return m, nil 457 } 458 459 - func GetPullComments(e Execer, filters ...filter) ([]models.PullComment, error) { 460 var conditions []string 461 var args []any 462 for _, filter := range filters { ··· 492 } 493 defer rows.Close() 494 495 - var comments []models.PullComment 496 for rows.Next() { 497 var comment models.PullComment 498 var createdAt string ··· 514 comment.Created = t 515 } 516 517 - comments = append(comments, comment) 518 } 519 520 if err := rows.Err(); err != nil { 521 return nil, err 522 } 523 524 return comments, nil 525 } 526 ··· 600 return pulls, nil 601 } 602 603 - func NewPullComment(e Execer, comment *models.PullComment) (int64, error) { 604 query := `insert into pull_comments (owner_did, repo_at, submission_id, comment_at, pull_id, body) values (?, ?, ?, ?, ?, ?)` 605 - res, err := e.Exec( 606 query, 607 comment.OwnerDid, 608 comment.RepoAt, ··· 618 i, err := res.LastInsertId() 619 if err != nil { 620 return 0, err 621 } 622 623 return i, nil ··· 664 return err 665 } 666 667 - func SetPullParentChangeId(e Execer, parentChangeId string, filters ...filter) error { 668 var conditions []string 669 var args []any 670 ··· 688 689 // Only used when stacking to update contents in the event of a rebase (the interdiff should be empty). 690 // otherwise submissions are immutable 691 - func UpdatePull(e Execer, newPatch, sourceRev string, filters ...filter) error { 692 var conditions []string 693 var args []any 694 ··· 746 func GetStack(e Execer, stackId string) (models.Stack, error) { 747 unorderedPulls, err := GetPulls( 748 e, 749 - FilterEq("stack_id", stackId), 750 - FilterNotEq("state", models.PullDeleted), 751 ) 752 if err != nil { 753 return nil, err ··· 791 func GetAbandonedPulls(e Execer, stackId string) ([]*models.Pull, error) { 792 pulls, err := GetPulls( 793 e, 794 - FilterEq("stack_id", stackId), 795 - FilterEq("state", models.PullDeleted), 796 ) 797 if err != nil { 798 return nil, err
··· 13 14 "github.com/bluesky-social/indigo/atproto/syntax" 15 "tangled.org/core/appview/models" 16 + "tangled.org/core/orm" 17 ) 18 19 func NewPull(tx *sql.Tx, pull *models.Pull) error { ··· 94 insert into pull_submissions (pull_at, round_number, patch, combined, source_rev) 95 values (?, ?, ?, ?, ?) 96 `, pull.AtUri(), 0, pull.Submissions[0].Patch, pull.Submissions[0].Combined, pull.Submissions[0].SourceRev) 97 + if err != nil { 98 + return err 99 + } 100 + 101 + if err := putReferences(tx, pull.AtUri(), pull.References); err != nil { 102 + return fmt.Errorf("put reference_links: %w", err) 103 + } 104 + 105 + return nil 106 } 107 108 func GetPullAt(e Execer, repoAt syntax.ATURI, pullId int) (syntax.ATURI, error) { ··· 119 return pullId - 1, err 120 } 121 122 + func GetPullsWithLimit(e Execer, limit int, filters ...orm.Filter) ([]*models.Pull, error) { 123 pulls := make(map[syntax.ATURI]*models.Pull) 124 125 var conditions []string ··· 230 for _, p := range pulls { 231 pullAts = append(pullAts, p.AtUri()) 232 } 233 + submissionsMap, err := GetPullSubmissions(e, orm.FilterIn("pull_at", pullAts)) 234 if err != nil { 235 return nil, fmt.Errorf("failed to get submissions: %w", err) 236 } ··· 242 } 243 244 // collect allLabels for each issue 245 + allLabels, err := GetLabels(e, orm.FilterIn("subject", pullAts)) 246 if err != nil { 247 return nil, fmt.Errorf("failed to query labels: %w", err) 248 } ··· 259 sourceAts = append(sourceAts, *p.PullSource.RepoAt) 260 } 261 } 262 + sourceRepos, err := GetRepos(e, 0, orm.FilterIn("at_uri", sourceAts)) 263 if err != nil && !errors.Is(err, sql.ErrNoRows) { 264 return nil, fmt.Errorf("failed to get source repos: %w", err) 265 } ··· 275 } 276 } 277 278 + allReferences, err := GetReferencesAll(e, orm.FilterIn("from_at", pullAts)) 279 + if err != nil { 280 + return nil, fmt.Errorf("failed to query reference_links: %w", err) 281 + } 282 + for pullAt, references := range allReferences { 283 + if pull, ok := pulls[pullAt]; ok { 284 + pull.References = references 285 + } 286 + } 287 + 288 orderedByPullId := []*models.Pull{} 289 for _, p := range pulls { 290 orderedByPullId = append(orderedByPullId, p) ··· 296 return orderedByPullId, nil 297 } 298 299 + func GetPulls(e Execer, filters ...orm.Filter) ([]*models.Pull, error) { 300 return GetPullsWithLimit(e, 0, filters...) 301 } 302 303 func GetPullIDs(e Execer, opts models.PullSearchOptions) ([]int64, error) { 304 var ids []int64 305 306 + var filters []orm.Filter 307 + filters = append(filters, orm.FilterEq("state", opts.State)) 308 if opts.RepoAt != "" { 309 + filters = append(filters, orm.FilterEq("repo_at", opts.RepoAt)) 310 } 311 312 var conditions []string ··· 362 } 363 364 func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*models.Pull, error) { 365 + pulls, err := GetPullsWithLimit(e, 1, orm.FilterEq("repo_at", repoAt), orm.FilterEq("pull_id", pullId)) 366 if err != nil { 367 return nil, err 368 } ··· 374 } 375 376 // mapping from pull -> pull submissions 377 + func GetPullSubmissions(e Execer, filters ...orm.Filter) (map[syntax.ATURI][]*models.PullSubmission, error) { 378 var conditions []string 379 var args []any 380 for _, filter := range filters { ··· 449 450 // Get comments for all submissions using GetPullComments 451 submissionIds := slices.Collect(maps.Keys(submissionMap)) 452 + comments, err := GetPullComments(e, orm.FilterIn("submission_id", submissionIds)) 453 if err != nil { 454 + return nil, fmt.Errorf("failed to get pull comments: %w", err) 455 } 456 for _, comment := range comments { 457 if submission, ok := submissionMap[comment.SubmissionId]; ok { ··· 475 return m, nil 476 } 477 478 + func GetPullComments(e Execer, filters ...orm.Filter) ([]models.PullComment, error) { 479 var conditions []string 480 var args []any 481 for _, filter := range filters { ··· 511 } 512 defer rows.Close() 513 514 + commentMap := make(map[string]*models.PullComment) 515 for rows.Next() { 516 var comment models.PullComment 517 var createdAt string ··· 533 comment.Created = t 534 } 535 536 + atUri := comment.AtUri().String() 537 + commentMap[atUri] = &comment 538 } 539 540 if err := rows.Err(); err != nil { 541 return nil, err 542 } 543 544 + // collect references for each comments 545 + commentAts := slices.Collect(maps.Keys(commentMap)) 546 + allReferencs, err := GetReferencesAll(e, orm.FilterIn("from_at", commentAts)) 547 + if err != nil { 548 + return nil, fmt.Errorf("failed to query reference_links: %w", err) 549 + } 550 + for commentAt, references := range allReferencs { 551 + if comment, ok := commentMap[commentAt.String()]; ok { 552 + comment.References = references 553 + } 554 + } 555 + 556 + var comments []models.PullComment 557 + for _, c := range commentMap { 558 + comments = append(comments, *c) 559 + } 560 + 561 + sort.Slice(comments, func(i, j int) bool { 562 + return comments[i].Created.Before(comments[j].Created) 563 + }) 564 + 565 return comments, nil 566 } 567 ··· 641 return pulls, nil 642 } 643 644 + func NewPullComment(tx *sql.Tx, comment *models.PullComment) (int64, error) { 645 query := `insert into pull_comments (owner_did, repo_at, submission_id, comment_at, pull_id, body) values (?, ?, ?, ?, ?, ?)` 646 + res, err := tx.Exec( 647 query, 648 comment.OwnerDid, 649 comment.RepoAt, ··· 659 i, err := res.LastInsertId() 660 if err != nil { 661 return 0, err 662 + } 663 + 664 + if err := putReferences(tx, comment.AtUri(), comment.References); err != nil { 665 + return 0, fmt.Errorf("put reference_links: %w", err) 666 } 667 668 return i, nil ··· 709 return err 710 } 711 712 + func SetPullParentChangeId(e Execer, parentChangeId string, filters ...orm.Filter) error { 713 var conditions []string 714 var args []any 715 ··· 733 734 // Only used when stacking to update contents in the event of a rebase (the interdiff should be empty). 735 // otherwise submissions are immutable 736 + func UpdatePull(e Execer, newPatch, sourceRev string, filters ...orm.Filter) error { 737 var conditions []string 738 var args []any 739 ··· 791 func GetStack(e Execer, stackId string) (models.Stack, error) { 792 unorderedPulls, err := GetPulls( 793 e, 794 + orm.FilterEq("stack_id", stackId), 795 + orm.FilterNotEq("state", models.PullDeleted), 796 ) 797 if err != nil { 798 return nil, err ··· 836 func GetAbandonedPulls(e Execer, stackId string) ([]*models.Pull, error) { 837 pulls, err := GetPulls( 838 e, 839 + orm.FilterEq("stack_id", stackId), 840 + orm.FilterEq("state", models.PullDeleted), 841 ) 842 if err != nil { 843 return nil, err
+3 -2
appview/db/punchcard.go
··· 7 "time" 8 9 "tangled.org/core/appview/models" 10 ) 11 12 // this adds to the existing count ··· 20 return err 21 } 22 23 - func MakePunchcard(e Execer, filters ...filter) (*models.Punchcard, error) { 24 punchcard := &models.Punchcard{} 25 now := time.Now() 26 startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC) ··· 77 punch.Count = int(count.Int64) 78 } 79 80 - punchcard.Punches[punch.Date.YearDay()] = punch 81 punchcard.Total += punch.Count 82 } 83
··· 7 "time" 8 9 "tangled.org/core/appview/models" 10 + "tangled.org/core/orm" 11 ) 12 13 // this adds to the existing count ··· 21 return err 22 } 23 24 + func MakePunchcard(e Execer, filters ...orm.Filter) (*models.Punchcard, error) { 25 punchcard := &models.Punchcard{} 26 now := time.Now() 27 startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC) ··· 78 punch.Count = int(count.Int64) 79 } 80 81 + punchcard.Punches[punch.Date.YearDay()-1] = punch 82 punchcard.Total += punch.Count 83 } 84
+463
appview/db/reference.go
···
··· 1 + package db 2 + 3 + import ( 4 + "database/sql" 5 + "fmt" 6 + "strings" 7 + 8 + "github.com/bluesky-social/indigo/atproto/syntax" 9 + "tangled.org/core/api/tangled" 10 + "tangled.org/core/appview/models" 11 + "tangled.org/core/orm" 12 + ) 13 + 14 + // ValidateReferenceLinks resolves refLinks to Issue/PR/IssueComment/PullComment ATURIs. 15 + // It will ignore missing refLinks. 16 + func ValidateReferenceLinks(e Execer, refLinks []models.ReferenceLink) ([]syntax.ATURI, error) { 17 + var ( 18 + issueRefs []models.ReferenceLink 19 + pullRefs []models.ReferenceLink 20 + ) 21 + for _, ref := range refLinks { 22 + switch ref.Kind { 23 + case models.RefKindIssue: 24 + issueRefs = append(issueRefs, ref) 25 + case models.RefKindPull: 26 + pullRefs = append(pullRefs, ref) 27 + } 28 + } 29 + issueUris, err := findIssueReferences(e, issueRefs) 30 + if err != nil { 31 + return nil, fmt.Errorf("find issue references: %w", err) 32 + } 33 + pullUris, err := findPullReferences(e, pullRefs) 34 + if err != nil { 35 + return nil, fmt.Errorf("find pull references: %w", err) 36 + } 37 + 38 + return append(issueUris, pullUris...), nil 39 + } 40 + 41 + func findIssueReferences(e Execer, refLinks []models.ReferenceLink) ([]syntax.ATURI, error) { 42 + if len(refLinks) == 0 { 43 + return nil, nil 44 + } 45 + vals := make([]string, len(refLinks)) 46 + args := make([]any, 0, len(refLinks)*4) 47 + for i, ref := range refLinks { 48 + vals[i] = "(?, ?, ?, ?)" 49 + args = append(args, ref.Handle, ref.Repo, ref.SubjectId, ref.CommentId) 50 + } 51 + query := fmt.Sprintf( 52 + `with input(owner_did, name, issue_id, comment_id) as ( 53 + values %s 54 + ) 55 + select 56 + i.did, i.rkey, 57 + c.did, c.rkey 58 + from input inp 59 + join repos r 60 + on r.did = inp.owner_did 61 + and r.name = inp.name 62 + join issues i 63 + on i.repo_at = r.at_uri 64 + and i.issue_id = inp.issue_id 65 + left join issue_comments c 66 + on inp.comment_id is not null 67 + and c.issue_at = i.at_uri 68 + and c.id = inp.comment_id 69 + `, 70 + strings.Join(vals, ","), 71 + ) 72 + rows, err := e.Query(query, args...) 73 + if err != nil { 74 + return nil, err 75 + } 76 + defer rows.Close() 77 + 78 + var uris []syntax.ATURI 79 + 80 + for rows.Next() { 81 + // Scan rows 82 + var issueOwner, issueRkey string 83 + var commentOwner, commentRkey sql.NullString 84 + var uri syntax.ATURI 85 + if err := rows.Scan(&issueOwner, &issueRkey, &commentOwner, &commentRkey); err != nil { 86 + return nil, err 87 + } 88 + if commentOwner.Valid && commentRkey.Valid { 89 + uri = syntax.ATURI(fmt.Sprintf( 90 + "at://%s/%s/%s", 91 + commentOwner.String, 92 + tangled.RepoIssueCommentNSID, 93 + commentRkey.String, 94 + )) 95 + } else { 96 + uri = syntax.ATURI(fmt.Sprintf( 97 + "at://%s/%s/%s", 98 + issueOwner, 99 + tangled.RepoIssueNSID, 100 + issueRkey, 101 + )) 102 + } 103 + uris = append(uris, uri) 104 + } 105 + if err := rows.Err(); err != nil { 106 + return nil, fmt.Errorf("iterate rows: %w", err) 107 + } 108 + 109 + return uris, nil 110 + } 111 + 112 + func findPullReferences(e Execer, refLinks []models.ReferenceLink) ([]syntax.ATURI, error) { 113 + if len(refLinks) == 0 { 114 + return nil, nil 115 + } 116 + vals := make([]string, len(refLinks)) 117 + args := make([]any, 0, len(refLinks)*4) 118 + for i, ref := range refLinks { 119 + vals[i] = "(?, ?, ?, ?)" 120 + args = append(args, ref.Handle, ref.Repo, ref.SubjectId, ref.CommentId) 121 + } 122 + query := fmt.Sprintf( 123 + `with input(owner_did, name, pull_id, comment_id) as ( 124 + values %s 125 + ) 126 + select 127 + p.owner_did, p.rkey, 128 + c.comment_at 129 + from input inp 130 + join repos r 131 + on r.did = inp.owner_did 132 + and r.name = inp.name 133 + join pulls p 134 + on p.repo_at = r.at_uri 135 + and p.pull_id = inp.pull_id 136 + left join pull_comments c 137 + on inp.comment_id is not null 138 + and c.repo_at = r.at_uri and c.pull_id = p.pull_id 139 + and c.id = inp.comment_id 140 + `, 141 + strings.Join(vals, ","), 142 + ) 143 + rows, err := e.Query(query, args...) 144 + if err != nil { 145 + return nil, err 146 + } 147 + defer rows.Close() 148 + 149 + var uris []syntax.ATURI 150 + 151 + for rows.Next() { 152 + // Scan rows 153 + var pullOwner, pullRkey string 154 + var commentUri sql.NullString 155 + var uri syntax.ATURI 156 + if err := rows.Scan(&pullOwner, &pullRkey, &commentUri); err != nil { 157 + return nil, err 158 + } 159 + if commentUri.Valid { 160 + // no-op 161 + uri = syntax.ATURI(commentUri.String) 162 + } else { 163 + uri = syntax.ATURI(fmt.Sprintf( 164 + "at://%s/%s/%s", 165 + pullOwner, 166 + tangled.RepoPullNSID, 167 + pullRkey, 168 + )) 169 + } 170 + uris = append(uris, uri) 171 + } 172 + return uris, nil 173 + } 174 + 175 + func putReferences(tx *sql.Tx, fromAt syntax.ATURI, references []syntax.ATURI) error { 176 + err := deleteReferences(tx, fromAt) 177 + if err != nil { 178 + return fmt.Errorf("delete old reference_links: %w", err) 179 + } 180 + if len(references) == 0 { 181 + return nil 182 + } 183 + 184 + values := make([]string, 0, len(references)) 185 + args := make([]any, 0, len(references)*2) 186 + for _, ref := range references { 187 + values = append(values, "(?, ?)") 188 + args = append(args, fromAt, ref) 189 + } 190 + _, err = tx.Exec( 191 + fmt.Sprintf( 192 + `insert into reference_links (from_at, to_at) 193 + values %s`, 194 + strings.Join(values, ","), 195 + ), 196 + args..., 197 + ) 198 + if err != nil { 199 + return fmt.Errorf("insert new reference_links: %w", err) 200 + } 201 + return nil 202 + } 203 + 204 + func deleteReferences(tx *sql.Tx, fromAt syntax.ATURI) error { 205 + _, err := tx.Exec(`delete from reference_links where from_at = ?`, fromAt) 206 + return err 207 + } 208 + 209 + func GetReferencesAll(e Execer, filters ...orm.Filter) (map[syntax.ATURI][]syntax.ATURI, error) { 210 + var ( 211 + conditions []string 212 + args []any 213 + ) 214 + for _, filter := range filters { 215 + conditions = append(conditions, filter.Condition()) 216 + args = append(args, filter.Arg()...) 217 + } 218 + 219 + whereClause := "" 220 + if conditions != nil { 221 + whereClause = " where " + strings.Join(conditions, " and ") 222 + } 223 + 224 + rows, err := e.Query( 225 + fmt.Sprintf( 226 + `select from_at, to_at from reference_links %s`, 227 + whereClause, 228 + ), 229 + args..., 230 + ) 231 + if err != nil { 232 + return nil, fmt.Errorf("query reference_links: %w", err) 233 + } 234 + defer rows.Close() 235 + 236 + result := make(map[syntax.ATURI][]syntax.ATURI) 237 + 238 + for rows.Next() { 239 + var from, to syntax.ATURI 240 + if err := rows.Scan(&from, &to); err != nil { 241 + return nil, fmt.Errorf("scan row: %w", err) 242 + } 243 + 244 + result[from] = append(result[from], to) 245 + } 246 + if err := rows.Err(); err != nil { 247 + return nil, fmt.Errorf("iterate rows: %w", err) 248 + } 249 + 250 + return result, nil 251 + } 252 + 253 + func GetBacklinks(e Execer, target syntax.ATURI) ([]models.RichReferenceLink, error) { 254 + rows, err := e.Query( 255 + `select from_at from reference_links 256 + where to_at = ?`, 257 + target, 258 + ) 259 + if err != nil { 260 + return nil, fmt.Errorf("query backlinks: %w", err) 261 + } 262 + defer rows.Close() 263 + 264 + var ( 265 + backlinks []models.RichReferenceLink 266 + backlinksMap = make(map[string][]syntax.ATURI) 267 + ) 268 + for rows.Next() { 269 + var from syntax.ATURI 270 + if err := rows.Scan(&from); err != nil { 271 + return nil, fmt.Errorf("scan row: %w", err) 272 + } 273 + nsid := from.Collection().String() 274 + backlinksMap[nsid] = append(backlinksMap[nsid], from) 275 + } 276 + if err := rows.Err(); err != nil { 277 + return nil, fmt.Errorf("iterate rows: %w", err) 278 + } 279 + 280 + var ls []models.RichReferenceLink 281 + ls, err = getIssueBacklinks(e, backlinksMap[tangled.RepoIssueNSID]) 282 + if err != nil { 283 + return nil, fmt.Errorf("get issue backlinks: %w", err) 284 + } 285 + backlinks = append(backlinks, ls...) 286 + ls, err = getIssueCommentBacklinks(e, backlinksMap[tangled.RepoIssueCommentNSID]) 287 + if err != nil { 288 + return nil, fmt.Errorf("get issue_comment backlinks: %w", err) 289 + } 290 + backlinks = append(backlinks, ls...) 291 + ls, err = getPullBacklinks(e, backlinksMap[tangled.RepoPullNSID]) 292 + if err != nil { 293 + return nil, fmt.Errorf("get pull backlinks: %w", err) 294 + } 295 + backlinks = append(backlinks, ls...) 296 + ls, err = getPullCommentBacklinks(e, backlinksMap[tangled.RepoPullCommentNSID]) 297 + if err != nil { 298 + return nil, fmt.Errorf("get pull_comment backlinks: %w", err) 299 + } 300 + backlinks = append(backlinks, ls...) 301 + 302 + return backlinks, nil 303 + } 304 + 305 + func getIssueBacklinks(e Execer, aturis []syntax.ATURI) ([]models.RichReferenceLink, error) { 306 + if len(aturis) == 0 { 307 + return nil, nil 308 + } 309 + vals := make([]string, len(aturis)) 310 + args := make([]any, 0, len(aturis)*2) 311 + for i, aturi := range aturis { 312 + vals[i] = "(?, ?)" 313 + did := aturi.Authority().String() 314 + rkey := aturi.RecordKey().String() 315 + args = append(args, did, rkey) 316 + } 317 + rows, err := e.Query( 318 + fmt.Sprintf( 319 + `select r.did, r.name, i.issue_id, i.title, i.open 320 + from issues i 321 + join repos r 322 + on r.at_uri = i.repo_at 323 + where (i.did, i.rkey) in (%s)`, 324 + strings.Join(vals, ","), 325 + ), 326 + args..., 327 + ) 328 + if err != nil { 329 + return nil, err 330 + } 331 + defer rows.Close() 332 + var refLinks []models.RichReferenceLink 333 + for rows.Next() { 334 + var l models.RichReferenceLink 335 + l.Kind = models.RefKindIssue 336 + if err := rows.Scan(&l.Handle, &l.Repo, &l.SubjectId, &l.Title, &l.State); err != nil { 337 + return nil, err 338 + } 339 + refLinks = append(refLinks, l) 340 + } 341 + if err := rows.Err(); err != nil { 342 + return nil, fmt.Errorf("iterate rows: %w", err) 343 + } 344 + return refLinks, nil 345 + } 346 + 347 + func getIssueCommentBacklinks(e Execer, aturis []syntax.ATURI) ([]models.RichReferenceLink, error) { 348 + if len(aturis) == 0 { 349 + return nil, nil 350 + } 351 + filter := orm.FilterIn("c.at_uri", aturis) 352 + rows, err := e.Query( 353 + fmt.Sprintf( 354 + `select r.did, r.name, i.issue_id, c.id, i.title, i.open 355 + from issue_comments c 356 + join issues i 357 + on i.at_uri = c.issue_at 358 + join repos r 359 + on r.at_uri = i.repo_at 360 + where %s`, 361 + filter.Condition(), 362 + ), 363 + filter.Arg()..., 364 + ) 365 + if err != nil { 366 + return nil, err 367 + } 368 + defer rows.Close() 369 + var refLinks []models.RichReferenceLink 370 + for rows.Next() { 371 + var l models.RichReferenceLink 372 + l.Kind = models.RefKindIssue 373 + l.CommentId = new(int) 374 + if err := rows.Scan(&l.Handle, &l.Repo, &l.SubjectId, l.CommentId, &l.Title, &l.State); err != nil { 375 + return nil, err 376 + } 377 + refLinks = append(refLinks, l) 378 + } 379 + if err := rows.Err(); err != nil { 380 + return nil, fmt.Errorf("iterate rows: %w", err) 381 + } 382 + return refLinks, nil 383 + } 384 + 385 + func getPullBacklinks(e Execer, aturis []syntax.ATURI) ([]models.RichReferenceLink, error) { 386 + if len(aturis) == 0 { 387 + return nil, nil 388 + } 389 + vals := make([]string, len(aturis)) 390 + args := make([]any, 0, len(aturis)*2) 391 + for i, aturi := range aturis { 392 + vals[i] = "(?, ?)" 393 + did := aturi.Authority().String() 394 + rkey := aturi.RecordKey().String() 395 + args = append(args, did, rkey) 396 + } 397 + rows, err := e.Query( 398 + fmt.Sprintf( 399 + `select r.did, r.name, p.pull_id, p.title, p.state 400 + from pulls p 401 + join repos r 402 + on r.at_uri = p.repo_at 403 + where (p.owner_did, p.rkey) in (%s)`, 404 + strings.Join(vals, ","), 405 + ), 406 + args..., 407 + ) 408 + if err != nil { 409 + return nil, err 410 + } 411 + defer rows.Close() 412 + var refLinks []models.RichReferenceLink 413 + for rows.Next() { 414 + var l models.RichReferenceLink 415 + l.Kind = models.RefKindPull 416 + if err := rows.Scan(&l.Handle, &l.Repo, &l.SubjectId, &l.Title, &l.State); err != nil { 417 + return nil, err 418 + } 419 + refLinks = append(refLinks, l) 420 + } 421 + if err := rows.Err(); err != nil { 422 + return nil, fmt.Errorf("iterate rows: %w", err) 423 + } 424 + return refLinks, nil 425 + } 426 + 427 + func getPullCommentBacklinks(e Execer, aturis []syntax.ATURI) ([]models.RichReferenceLink, error) { 428 + if len(aturis) == 0 { 429 + return nil, nil 430 + } 431 + filter := orm.FilterIn("c.comment_at", aturis) 432 + rows, err := e.Query( 433 + fmt.Sprintf( 434 + `select r.did, r.name, p.pull_id, c.id, p.title, p.state 435 + from repos r 436 + join pulls p 437 + on r.at_uri = p.repo_at 438 + join pull_comments c 439 + on r.at_uri = c.repo_at and p.pull_id = c.pull_id 440 + where %s`, 441 + filter.Condition(), 442 + ), 443 + filter.Arg()..., 444 + ) 445 + if err != nil { 446 + return nil, err 447 + } 448 + defer rows.Close() 449 + var refLinks []models.RichReferenceLink 450 + for rows.Next() { 451 + var l models.RichReferenceLink 452 + l.Kind = models.RefKindPull 453 + l.CommentId = new(int) 454 + if err := rows.Scan(&l.Handle, &l.Repo, &l.SubjectId, l.CommentId, &l.Title, &l.State); err != nil { 455 + return nil, err 456 + } 457 + refLinks = append(refLinks, l) 458 + } 459 + if err := rows.Err(); err != nil { 460 + return nil, fmt.Errorf("iterate rows: %w", err) 461 + } 462 + return refLinks, nil 463 + }
+5 -3
appview/db/registration.go
··· 7 "time" 8 9 "tangled.org/core/appview/models" 10 ) 11 12 - func GetRegistrations(e Execer, filters ...filter) ([]models.Registration, error) { 13 var registrations []models.Registration 14 15 var conditions []string ··· 37 if err != nil { 38 return nil, err 39 } 40 41 for rows.Next() { 42 var createdAt string ··· 69 return registrations, nil 70 } 71 72 - func MarkRegistered(e Execer, filters ...filter) error { 73 var conditions []string 74 var args []any 75 for _, filter := range filters { ··· 94 return err 95 } 96 97 - func DeleteKnot(e Execer, filters ...filter) error { 98 var conditions []string 99 var args []any 100 for _, filter := range filters {
··· 7 "time" 8 9 "tangled.org/core/appview/models" 10 + "tangled.org/core/orm" 11 ) 12 13 + func GetRegistrations(e Execer, filters ...orm.Filter) ([]models.Registration, error) { 14 var registrations []models.Registration 15 16 var conditions []string ··· 38 if err != nil { 39 return nil, err 40 } 41 + defer rows.Close() 42 43 for rows.Next() { 44 var createdAt string ··· 71 return registrations, nil 72 } 73 74 + func MarkRegistered(e Execer, filters ...orm.Filter) error { 75 var conditions []string 76 var args []any 77 for _, filter := range filters { ··· 96 return err 97 } 98 99 + func DeleteKnot(e Execer, filters ...orm.Filter) error { 100 var conditions []string 101 var args []any 102 for _, filter := range filters {
+32 -37
appview/db/repos.go
··· 10 "time" 11 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 - securejoin "github.com/cyphar/filepath-securejoin" 14 - "tangled.org/core/api/tangled" 15 "tangled.org/core/appview/models" 16 ) 17 18 - type Repo struct { 19 - Id int64 20 - Did string 21 - Name string 22 - Knot string 23 - Rkey string 24 - Created time.Time 25 - Description string 26 - Spindle string 27 - 28 - // optionally, populate this when querying for reverse mappings 29 - RepoStats *models.RepoStats 30 - 31 - // optional 32 - Source string 33 - } 34 - 35 - func (r Repo) RepoAt() syntax.ATURI { 36 - return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", r.Did, tangled.RepoNSID, r.Rkey)) 37 - } 38 - 39 - func (r Repo) DidSlashRepo() string { 40 - p, _ := securejoin.SecureJoin(r.Did, r.Name) 41 - return p 42 - } 43 - 44 - func GetRepos(e Execer, limit int, filters ...filter) ([]models.Repo, error) { 45 repoMap := make(map[syntax.ATURI]*models.Repo) 46 47 var conditions []string ··· 83 limitClause, 84 ) 85 rows, err := e.Query(repoQuery, args...) 86 - 87 if err != nil { 88 return nil, fmt.Errorf("failed to execute repo query: %w ", err) 89 } 90 91 for rows.Next() { 92 var repo models.Repo ··· 155 if err != nil { 156 return nil, fmt.Errorf("failed to execute labels query: %w ", err) 157 } 158 for rows.Next() { 159 var repoat, labelat string 160 if err := rows.Scan(&repoat, &labelat); err != nil { ··· 183 from repo_languages 184 where repo_at in (%s) 185 and is_default_ref = 1 186 ) 187 where rn = 1 188 `, ··· 192 if err != nil { 193 return nil, fmt.Errorf("failed to execute lang query: %w ", err) 194 } 195 for rows.Next() { 196 var repoat, lang string 197 if err := rows.Scan(&repoat, &lang); err != nil { ··· 208 209 starCountQuery := fmt.Sprintf( 210 `select 211 - repo_at, count(1) 212 from stars 213 - where repo_at in (%s) 214 - group by repo_at`, 215 inClause, 216 ) 217 rows, err = e.Query(starCountQuery, args...) 218 if err != nil { 219 return nil, fmt.Errorf("failed to execute star-count query: %w ", err) 220 } 221 for rows.Next() { 222 var repoat string 223 var count int ··· 247 if err != nil { 248 return nil, fmt.Errorf("failed to execute issue-count query: %w ", err) 249 } 250 for rows.Next() { 251 var repoat string 252 var open, closed int ··· 288 if err != nil { 289 return nil, fmt.Errorf("failed to execute pulls-count query: %w ", err) 290 } 291 for rows.Next() { 292 var repoat string 293 var open, merged, closed, deleted int ··· 322 } 323 324 // helper to get exactly one repo 325 - func GetRepo(e Execer, filters ...filter) (*models.Repo, error) { 326 repos, err := GetRepos(e, 0, filters...) 327 if err != nil { 328 return nil, err ··· 339 return &repos[0], nil 340 } 341 342 - func CountRepos(e Execer, filters ...filter) (int64, error) { 343 var conditions []string 344 var args []any 345 for _, filter := range filters { ··· 439 return nullableSource.String, nil 440 } 441 442 func GetForksByDid(e Execer, did string) ([]models.Repo, error) { 443 var repos []models.Repo 444 ··· 559 return err 560 } 561 562 - func UnsubscribeLabel(e Execer, filters ...filter) error { 563 var conditions []string 564 var args []any 565 for _, filter := range filters { ··· 577 return err 578 } 579 580 - func GetRepoLabels(e Execer, filters ...filter) ([]models.RepoLabel, error) { 581 var conditions []string 582 var args []any 583 for _, filter := range filters {
··· 10 "time" 11 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 "tangled.org/core/appview/models" 14 + "tangled.org/core/orm" 15 ) 16 17 + func GetRepos(e Execer, limit int, filters ...orm.Filter) ([]models.Repo, error) { 18 repoMap := make(map[syntax.ATURI]*models.Repo) 19 20 var conditions []string ··· 56 limitClause, 57 ) 58 rows, err := e.Query(repoQuery, args...) 59 if err != nil { 60 return nil, fmt.Errorf("failed to execute repo query: %w ", err) 61 } 62 + defer rows.Close() 63 64 for rows.Next() { 65 var repo models.Repo ··· 128 if err != nil { 129 return nil, fmt.Errorf("failed to execute labels query: %w ", err) 130 } 131 + defer rows.Close() 132 + 133 for rows.Next() { 134 var repoat, labelat string 135 if err := rows.Scan(&repoat, &labelat); err != nil { ··· 158 from repo_languages 159 where repo_at in (%s) 160 and is_default_ref = 1 161 + and language <> '' 162 ) 163 where rn = 1 164 `, ··· 168 if err != nil { 169 return nil, fmt.Errorf("failed to execute lang query: %w ", err) 170 } 171 + defer rows.Close() 172 + 173 for rows.Next() { 174 var repoat, lang string 175 if err := rows.Scan(&repoat, &lang); err != nil { ··· 186 187 starCountQuery := fmt.Sprintf( 188 `select 189 + subject_at, count(1) 190 from stars 191 + where subject_at in (%s) 192 + group by subject_at`, 193 inClause, 194 ) 195 rows, err = e.Query(starCountQuery, args...) 196 if err != nil { 197 return nil, fmt.Errorf("failed to execute star-count query: %w ", err) 198 } 199 + defer rows.Close() 200 + 201 for rows.Next() { 202 var repoat string 203 var count int ··· 227 if err != nil { 228 return nil, fmt.Errorf("failed to execute issue-count query: %w ", err) 229 } 230 + defer rows.Close() 231 + 232 for rows.Next() { 233 var repoat string 234 var open, closed int ··· 270 if err != nil { 271 return nil, fmt.Errorf("failed to execute pulls-count query: %w ", err) 272 } 273 + defer rows.Close() 274 + 275 for rows.Next() { 276 var repoat string 277 var open, merged, closed, deleted int ··· 306 } 307 308 // helper to get exactly one repo 309 + func GetRepo(e Execer, filters ...orm.Filter) (*models.Repo, error) { 310 repos, err := GetRepos(e, 0, filters...) 311 if err != nil { 312 return nil, err ··· 323 return &repos[0], nil 324 } 325 326 + func CountRepos(e Execer, filters ...orm.Filter) (int64, error) { 327 var conditions []string 328 var args []any 329 for _, filter := range filters { ··· 423 return nullableSource.String, nil 424 } 425 426 + func GetRepoSourceRepo(e Execer, repoAt syntax.ATURI) (*models.Repo, error) { 427 + source, err := GetRepoSource(e, repoAt) 428 + if source == "" || errors.Is(err, sql.ErrNoRows) { 429 + return nil, nil 430 + } 431 + if err != nil { 432 + return nil, err 433 + } 434 + return GetRepoByAtUri(e, source) 435 + } 436 + 437 func GetForksByDid(e Execer, did string) ([]models.Repo, error) { 438 var repos []models.Repo 439 ··· 554 return err 555 } 556 557 + func UnsubscribeLabel(e Execer, filters ...orm.Filter) error { 558 var conditions []string 559 var args []any 560 for _, filter := range filters { ··· 572 return err 573 } 574 575 + func GetRepoLabels(e Execer, filters ...orm.Filter) ([]models.RepoLabel, error) { 576 var conditions []string 577 var args []any 578 for _, filter := range filters {
+6 -5
appview/db/spindle.go
··· 7 "time" 8 9 "tangled.org/core/appview/models" 10 ) 11 12 - func GetSpindles(e Execer, filters ...filter) ([]models.Spindle, error) { 13 var spindles []models.Spindle 14 15 var conditions []string ··· 91 return err 92 } 93 94 - func VerifySpindle(e Execer, filters ...filter) (int64, error) { 95 var conditions []string 96 var args []any 97 for _, filter := range filters { ··· 114 return res.RowsAffected() 115 } 116 117 - func DeleteSpindle(e Execer, filters ...filter) error { 118 var conditions []string 119 var args []any 120 for _, filter := range filters { ··· 144 return err 145 } 146 147 - func RemoveSpindleMember(e Execer, filters ...filter) error { 148 var conditions []string 149 var args []any 150 for _, filter := range filters { ··· 163 return err 164 } 165 166 - func GetSpindleMembers(e Execer, filters ...filter) ([]models.SpindleMember, error) { 167 var members []models.SpindleMember 168 169 var conditions []string
··· 7 "time" 8 9 "tangled.org/core/appview/models" 10 + "tangled.org/core/orm" 11 ) 12 13 + func GetSpindles(e Execer, filters ...orm.Filter) ([]models.Spindle, error) { 14 var spindles []models.Spindle 15 16 var conditions []string ··· 92 return err 93 } 94 95 + func VerifySpindle(e Execer, filters ...orm.Filter) (int64, error) { 96 var conditions []string 97 var args []any 98 for _, filter := range filters { ··· 115 return res.RowsAffected() 116 } 117 118 + func DeleteSpindle(e Execer, filters ...orm.Filter) error { 119 var conditions []string 120 var args []any 121 for _, filter := range filters { ··· 145 return err 146 } 147 148 + func RemoveSpindleMember(e Execer, filters ...orm.Filter) error { 149 var conditions []string 150 var args []any 151 for _, filter := range filters { ··· 164 return err 165 } 166 167 + func GetSpindleMembers(e Execer, filters ...orm.Filter) ([]models.SpindleMember, error) { 168 var members []models.SpindleMember 169 170 var conditions []string
+44 -102
appview/db/star.go
··· 11 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 "tangled.org/core/appview/models" 14 ) 15 16 func AddStar(e Execer, star *models.Star) error { 17 - query := `insert or ignore into stars (starred_by_did, repo_at, rkey) values (?, ?, ?)` 18 _, err := e.Exec( 19 query, 20 - star.StarredByDid, 21 star.RepoAt.String(), 22 star.Rkey, 23 ) ··· 25 } 26 27 // Get a star record 28 - func GetStar(e Execer, starredByDid string, repoAt syntax.ATURI) (*models.Star, error) { 29 query := ` 30 - select starred_by_did, repo_at, created, rkey 31 from stars 32 - where starred_by_did = ? and repo_at = ?` 33 - row := e.QueryRow(query, starredByDid, repoAt) 34 35 var star models.Star 36 var created string 37 - err := row.Scan(&star.StarredByDid, &star.RepoAt, &created, &star.Rkey) 38 if err != nil { 39 return nil, err 40 } ··· 51 } 52 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) 56 return err 57 } 58 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) 62 return err 63 } 64 65 - func GetStarCount(e Execer, repoAt syntax.ATURI) (int, error) { 66 stars := 0 67 err := e.QueryRow( 68 - `select count(starred_by_did) from stars where repo_at = ?`, repoAt).Scan(&stars) 69 if err != nil { 70 return 0, err 71 } ··· 89 } 90 91 query := fmt.Sprintf(` 92 - SELECT repo_at 93 FROM stars 94 - WHERE starred_by_did = ? AND repo_at IN (%s) 95 `, strings.Join(placeholders, ",")) 96 97 rows, err := e.Query(query, args...) ··· 118 return result, nil 119 } 120 121 - func GetStarStatus(e Execer, userDid string, repoAt syntax.ATURI) bool { 122 - statuses, err := getStarStatuses(e, userDid, []syntax.ATURI{repoAt}) 123 if err != nil { 124 return false 125 } 126 - return statuses[repoAt.String()] 127 } 128 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) 132 } 133 - func GetStars(e Execer, limit int, filters ...filter) ([]models.Star, error) { 134 var conditions []string 135 var args []any 136 for _, filter := range filters { ··· 149 } 150 151 repoQuery := fmt.Sprintf( 152 - `select starred_by_did, repo_at, created, rkey 153 from stars 154 %s 155 order by created desc ··· 161 if err != nil { 162 return nil, err 163 } 164 165 starMap := make(map[string][]models.Star) 166 for rows.Next() { 167 var star models.Star 168 var created string 169 - err := rows.Scan(&star.StarredByDid, &star.RepoAt, &created, &star.Rkey) 170 if err != nil { 171 return nil, err 172 } ··· 192 return nil, nil 193 } 194 195 - repos, err := GetRepos(e, 0, FilterIn("at_uri", args)) 196 if err != nil { 197 return nil, err 198 } 199 200 for _, r := range repos { 201 if stars, ok := starMap[string(r.RepoAt())]; ok { 202 - for i := range stars { 203 - stars[i].Repo = &r 204 } 205 } 206 } 207 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 { 214 if a.Created.After(b.Created) { 215 return -1 216 } ··· 220 return 0 221 }) 222 223 - return stars, nil 224 } 225 226 - func CountStars(e Execer, filters ...filter) (int64, error) { 227 var conditions []string 228 var args []any 229 for _, filter := range filters { ··· 247 return count, nil 248 } 249 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 // GetTopStarredReposLastWeek returns the top 8 most starred repositories from the last week 313 func GetTopStarredReposLastWeek(e Execer) ([]models.Repo, error) { 314 // first, get the top repo URIs by star count from the last week 315 query := ` 316 with recent_starred_repos as ( 317 - select distinct repo_at 318 from stars 319 where created >= datetime('now', '-7 days') 320 ), 321 repo_star_counts as ( 322 select 323 - s.repo_at, 324 count(*) as stars_gained_last_week 325 from stars s 326 - join recent_starred_repos rsr on s.repo_at = rsr.repo_at 327 where s.created >= datetime('now', '-7 days') 328 - group by s.repo_at 329 ) 330 - select rsc.repo_at 331 from repo_star_counts rsc 332 order by rsc.stars_gained_last_week desc 333 limit 8 ··· 358 } 359 360 // get full repo data 361 - repos, err := GetRepos(e, 0, FilterIn("at_uri", repoUris)) 362 if err != nil { 363 return nil, err 364 }
··· 11 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 "tangled.org/core/appview/models" 14 + "tangled.org/core/orm" 15 ) 16 17 func AddStar(e Execer, star *models.Star) error { 18 + query := `insert or ignore into stars (did, subject_at, rkey) values (?, ?, ?)` 19 _, err := e.Exec( 20 query, 21 + star.Did, 22 star.RepoAt.String(), 23 star.Rkey, 24 ) ··· 26 } 27 28 // Get a star record 29 + func GetStar(e Execer, did string, subjectAt syntax.ATURI) (*models.Star, error) { 30 query := ` 31 + select did, subject_at, created, rkey 32 from stars 33 + where did = ? and subject_at = ?` 34 + row := e.QueryRow(query, did, subjectAt) 35 36 var star models.Star 37 var created string 38 + err := row.Scan(&star.Did, &star.RepoAt, &created, &star.Rkey) 39 if err != nil { 40 return nil, err 41 } ··· 52 } 53 54 // Remove a star 55 + func DeleteStar(e Execer, did string, subjectAt syntax.ATURI) error { 56 + _, err := e.Exec(`delete from stars where did = ? and subject_at = ?`, did, subjectAt) 57 return err 58 } 59 60 // Remove a star 61 + func DeleteStarByRkey(e Execer, did string, rkey string) error { 62 + _, err := e.Exec(`delete from stars where did = ? and rkey = ?`, did, rkey) 63 return err 64 } 65 66 + func GetStarCount(e Execer, subjectAt syntax.ATURI) (int, error) { 67 stars := 0 68 err := e.QueryRow( 69 + `select count(did) from stars where subject_at = ?`, subjectAt).Scan(&stars) 70 if err != nil { 71 return 0, err 72 } ··· 90 } 91 92 query := fmt.Sprintf(` 93 + SELECT subject_at 94 FROM stars 95 + WHERE did = ? AND subject_at IN (%s) 96 `, strings.Join(placeholders, ",")) 97 98 rows, err := e.Query(query, args...) ··· 119 return result, nil 120 } 121 122 + func GetStarStatus(e Execer, userDid string, subjectAt syntax.ATURI) bool { 123 + statuses, err := getStarStatuses(e, userDid, []syntax.ATURI{subjectAt}) 124 if err != nil { 125 return false 126 } 127 + return statuses[subjectAt.String()] 128 } 129 130 // GetStarStatuses returns a map of repo URIs to star status for a given user 131 + func GetStarStatuses(e Execer, userDid string, subjectAts []syntax.ATURI) (map[string]bool, error) { 132 + return getStarStatuses(e, userDid, subjectAts) 133 } 134 + 135 + // GetRepoStars return a list of stars each holding target repository. 136 + // If there isn't known repo with starred at-uri, those stars will be ignored. 137 + func GetRepoStars(e Execer, limit int, filters ...orm.Filter) ([]models.RepoStar, error) { 138 var conditions []string 139 var args []any 140 for _, filter := range filters { ··· 153 } 154 155 repoQuery := fmt.Sprintf( 156 + `select did, subject_at, created, rkey 157 from stars 158 %s 159 order by created desc ··· 165 if err != nil { 166 return nil, err 167 } 168 + defer rows.Close() 169 170 starMap := make(map[string][]models.Star) 171 for rows.Next() { 172 var star models.Star 173 var created string 174 + err := rows.Scan(&star.Did, &star.RepoAt, &created, &star.Rkey) 175 if err != nil { 176 return nil, err 177 } ··· 197 return nil, nil 198 } 199 200 + repos, err := GetRepos(e, 0, orm.FilterIn("at_uri", args)) 201 if err != nil { 202 return nil, err 203 } 204 205 + var repoStars []models.RepoStar 206 for _, r := range repos { 207 if stars, ok := starMap[string(r.RepoAt())]; ok { 208 + for _, star := range stars { 209 + repoStars = append(repoStars, models.RepoStar{ 210 + Star: star, 211 + Repo: &r, 212 + }) 213 } 214 } 215 } 216 217 + slices.SortFunc(repoStars, func(a, b models.RepoStar) int { 218 if a.Created.After(b.Created) { 219 return -1 220 } ··· 224 return 0 225 }) 226 227 + return repoStars, nil 228 } 229 230 + func CountStars(e Execer, filters ...orm.Filter) (int64, error) { 231 var conditions []string 232 var args []any 233 for _, filter := range filters { ··· 251 return count, nil 252 } 253 254 // GetTopStarredReposLastWeek returns the top 8 most starred repositories from the last week 255 func GetTopStarredReposLastWeek(e Execer) ([]models.Repo, error) { 256 // first, get the top repo URIs by star count from the last week 257 query := ` 258 with recent_starred_repos as ( 259 + select distinct subject_at 260 from stars 261 where created >= datetime('now', '-7 days') 262 ), 263 repo_star_counts as ( 264 select 265 + s.subject_at, 266 count(*) as stars_gained_last_week 267 from stars s 268 + join recent_starred_repos rsr on s.subject_at = rsr.subject_at 269 where s.created >= datetime('now', '-7 days') 270 + group by s.subject_at 271 ) 272 + select rsc.subject_at 273 from repo_star_counts rsc 274 order by rsc.stars_gained_last_week desc 275 limit 8 ··· 300 } 301 302 // get full repo data 303 + repos, err := GetRepos(e, 0, orm.FilterIn("at_uri", repoUris)) 304 if err != nil { 305 return nil, err 306 }
+4 -3
appview/db/strings.go
··· 8 "time" 9 10 "tangled.org/core/appview/models" 11 ) 12 13 func AddString(e Execer, s models.String) error { ··· 44 return err 45 } 46 47 - func GetStrings(e Execer, limit int, filters ...filter) ([]models.String, error) { 48 var all []models.String 49 50 var conditions []string ··· 127 return all, nil 128 } 129 130 - func CountStrings(e Execer, filters ...filter) (int64, error) { 131 var conditions []string 132 var args []any 133 for _, filter := range filters { ··· 151 return count, nil 152 } 153 154 - func DeleteString(e Execer, filters ...filter) error { 155 var conditions []string 156 var args []any 157 for _, filter := range filters {
··· 8 "time" 9 10 "tangled.org/core/appview/models" 11 + "tangled.org/core/orm" 12 ) 13 14 func AddString(e Execer, s models.String) error { ··· 45 return err 46 } 47 48 + func GetStrings(e Execer, limit int, filters ...orm.Filter) ([]models.String, error) { 49 var all []models.String 50 51 var conditions []string ··· 128 return all, nil 129 } 130 131 + func CountStrings(e Execer, filters ...orm.Filter) (int64, error) { 132 var conditions []string 133 var args []any 134 for _, filter := range filters { ··· 152 return count, nil 153 } 154 155 + func DeleteString(e Execer, filters ...orm.Filter) error { 156 var conditions []string 157 var args []any 158 for _, filter := range filters {
+11 -20
appview/db/timeline.go
··· 5 6 "github.com/bluesky-social/indigo/atproto/syntax" 7 "tangled.org/core/appview/models" 8 ) 9 10 // TODO: this gathers heterogenous events from different sources and aggregates ··· 84 } 85 86 func getTimelineRepos(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) { 87 - filters := make([]filter, 0) 88 if userIsFollowing != nil { 89 - filters = append(filters, FilterIn("did", userIsFollowing)) 90 } 91 92 repos, err := GetRepos(e, limit, filters...) ··· 104 105 var origRepos []models.Repo 106 if args != nil { 107 - origRepos, err = GetRepos(e, 0, FilterIn("at_uri", args)) 108 } 109 if err != nil { 110 return nil, err ··· 144 } 145 146 func getTimelineStars(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) { 147 - filters := make([]filter, 0) 148 if userIsFollowing != nil { 149 - filters = append(filters, FilterIn("starred_by_did", userIsFollowing)) 150 } 151 152 - stars, err := GetStars(e, limit, filters...) 153 if err != nil { 154 return nil, err 155 } 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 var repos []models.Repo 168 for _, s := range stars { 169 repos = append(repos, *s.Repo) ··· 179 isStarred, starCount := getRepoStarInfo(s.Repo, starStatuses) 180 181 events = append(events, models.TimelineEvent{ 182 - Star: &s, 183 EventAt: s.Created, 184 IsStarred: isStarred, 185 StarCount: starCount, ··· 190 } 191 192 func getTimelineFollows(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) { 193 - filters := make([]filter, 0) 194 if userIsFollowing != nil { 195 - filters = append(filters, FilterIn("user_did", userIsFollowing)) 196 } 197 198 follows, err := GetFollows(e, limit, filters...) ··· 209 return nil, nil 210 } 211 212 - profiles, err := GetProfiles(e, FilterIn("did", subjects)) 213 if err != nil { 214 return nil, err 215 }
··· 5 6 "github.com/bluesky-social/indigo/atproto/syntax" 7 "tangled.org/core/appview/models" 8 + "tangled.org/core/orm" 9 ) 10 11 // TODO: this gathers heterogenous events from different sources and aggregates ··· 85 } 86 87 func getTimelineRepos(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) { 88 + filters := make([]orm.Filter, 0) 89 if userIsFollowing != nil { 90 + filters = append(filters, orm.FilterIn("did", userIsFollowing)) 91 } 92 93 repos, err := GetRepos(e, limit, filters...) ··· 105 106 var origRepos []models.Repo 107 if args != nil { 108 + origRepos, err = GetRepos(e, 0, orm.FilterIn("at_uri", args)) 109 } 110 if err != nil { 111 return nil, err ··· 145 } 146 147 func getTimelineStars(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) { 148 + filters := make([]orm.Filter, 0) 149 if userIsFollowing != nil { 150 + filters = append(filters, orm.FilterIn("did", userIsFollowing)) 151 } 152 153 + stars, err := GetRepoStars(e, limit, filters...) 154 if err != nil { 155 return nil, err 156 } 157 158 var repos []models.Repo 159 for _, s := range stars { 160 repos = append(repos, *s.Repo) ··· 170 isStarred, starCount := getRepoStarInfo(s.Repo, starStatuses) 171 172 events = append(events, models.TimelineEvent{ 173 + RepoStar: &s, 174 EventAt: s.Created, 175 IsStarred: isStarred, 176 StarCount: starCount, ··· 181 } 182 183 func getTimelineFollows(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) { 184 + filters := make([]orm.Filter, 0) 185 if userIsFollowing != nil { 186 + filters = append(filters, orm.FilterIn("user_did", userIsFollowing)) 187 } 188 189 follows, err := GetFollows(e, limit, filters...) ··· 200 return nil, nil 201 } 202 203 + profiles, err := GetProfiles(e, orm.FilterIn("did", subjects)) 204 if err != nil { 205 return nil, err 206 }
+7 -12
appview/email/email.go
··· 3 import ( 4 "fmt" 5 "net" 6 - "regexp" 7 "strings" 8 9 "github.com/resend/resend-go/v2" ··· 34 } 35 36 func IsValidEmail(email string) bool { 37 - // Basic length check 38 - if len(email) < 3 || len(email) > 254 { 39 return false 40 } 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) { 50 return false 51 } 52 53 // Split email into local and domain parts 54 - parts := strings.Split(email, "@") 55 domain := parts[1] 56 57 mx, err := net.LookupMX(domain)
··· 3 import ( 4 "fmt" 5 "net" 6 + "net/mail" 7 "strings" 8 9 "github.com/resend/resend-go/v2" ··· 34 } 35 36 func IsValidEmail(email string) bool { 37 + // Reject whitespace (ParseAddress normalizes it away) 38 + if strings.ContainsAny(email, " \t\n\r") { 39 return false 40 } 41 42 + // Use stdlib RFC 5322 parser 43 + addr, err := mail.ParseAddress(email) 44 + if err != nil { 45 return false 46 } 47 48 // Split email into local and domain parts 49 + parts := strings.Split(addr.Address, "@") 50 domain := parts[1] 51 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 + }
+50 -32
appview/ingester.go
··· 21 "tangled.org/core/appview/serververify" 22 "tangled.org/core/appview/validator" 23 "tangled.org/core/idresolver" 24 "tangled.org/core/rbac" 25 ) 26 ··· 121 return err 122 } 123 err = db.AddStar(i.Db, &models.Star{ 124 - StarredByDid: did, 125 - RepoAt: subjectUri, 126 - Rkey: e.Commit.RKey, 127 }) 128 case jmodels.CommitOperationDelete: 129 err = db.DeleteStarByRkey(i.Db, did, e.Commit.RKey) ··· 253 254 err = db.AddArtifact(i.Db, artifact) 255 case jmodels.CommitOperationDelete: 256 - err = db.DeleteArtifact(i.Db, db.FilterEq("did", did), db.FilterEq("rkey", e.Commit.RKey)) 257 } 258 259 if err != nil { ··· 350 351 err = db.UpsertProfile(tx, &profile) 352 case jmodels.CommitOperationDelete: 353 - err = db.DeleteArtifact(i.Db, db.FilterEq("did", did), db.FilterEq("rkey", e.Commit.RKey)) 354 } 355 356 if err != nil { ··· 424 // get record from db first 425 members, err := db.GetSpindleMembers( 426 ddb, 427 - db.FilterEq("did", did), 428 - db.FilterEq("rkey", rkey), 429 ) 430 if err != nil || len(members) != 1 { 431 return fmt.Errorf("failed to get member: %w, len(members) = %d", err, len(members)) ··· 440 // remove record by rkey && update enforcer 441 if err = db.RemoveSpindleMember( 442 tx, 443 - db.FilterEq("did", did), 444 - db.FilterEq("rkey", rkey), 445 ); err != nil { 446 return fmt.Errorf("failed to remove from db: %w", err) 447 } ··· 523 // get record from db first 524 spindles, err := db.GetSpindles( 525 ddb, 526 - db.FilterEq("owner", did), 527 - db.FilterEq("instance", instance), 528 ) 529 if err != nil || len(spindles) != 1 { 530 return fmt.Errorf("failed to get spindles: %w, len(spindles) = %d", err, len(spindles)) ··· 543 // remove spindle members first 544 err = db.RemoveSpindleMember( 545 tx, 546 - db.FilterEq("owner", did), 547 - db.FilterEq("instance", instance), 548 ) 549 if err != nil { 550 return err ··· 552 553 err = db.DeleteSpindle( 554 tx, 555 - db.FilterEq("owner", did), 556 - db.FilterEq("instance", instance), 557 ) 558 if err != nil { 559 return err ··· 621 case jmodels.CommitOperationDelete: 622 if err := db.DeleteString( 623 ddb, 624 - db.FilterEq("did", did), 625 - db.FilterEq("rkey", rkey), 626 ); err != nil { 627 l.Error("failed to delete", "err", err) 628 return fmt.Errorf("failed to delete string record: %w", err) ··· 740 // get record from db first 741 registrations, err := db.GetRegistrations( 742 ddb, 743 - db.FilterEq("domain", domain), 744 - db.FilterEq("did", did), 745 ) 746 if err != nil { 747 return fmt.Errorf("failed to get registration: %w", err) ··· 762 763 err = db.DeleteKnot( 764 tx, 765 - db.FilterEq("did", did), 766 - db.FilterEq("domain", domain), 767 ) 768 if err != nil { 769 return err ··· 841 return nil 842 843 case jmodels.CommitOperationDelete: 844 if err := db.DeleteIssues( 845 - ddb, 846 - db.FilterEq("did", did), 847 - db.FilterEq("rkey", rkey), 848 ); err != nil { 849 l.Error("failed to delete", "err", err) 850 return fmt.Errorf("failed to delete issue record: %w", err) 851 } 852 853 return nil ··· 888 return fmt.Errorf("failed to validate comment: %w", err) 889 } 890 891 - _, err = db.AddIssueComment(ddb, *comment) 892 if err != nil { 893 return fmt.Errorf("failed to create issue comment: %w", err) 894 } 895 896 - return nil 897 898 case jmodels.CommitOperationDelete: 899 if err := db.DeleteIssueComments( 900 ddb, 901 - db.FilterEq("did", did), 902 - db.FilterEq("rkey", rkey), 903 ); err != nil { 904 return fmt.Errorf("failed to delete issue comment record: %w", err) 905 } ··· 952 case jmodels.CommitOperationDelete: 953 if err := db.DeleteLabelDefinition( 954 ddb, 955 - db.FilterEq("did", did), 956 - db.FilterEq("rkey", rkey), 957 ); err != nil { 958 return fmt.Errorf("failed to delete labeldef record: %w", err) 959 } ··· 993 var repo *models.Repo 994 switch collection { 995 case tangled.RepoIssueNSID: 996 - i, err := db.GetIssues(ddb, db.FilterEq("at_uri", subject)) 997 if err != nil || len(i) != 1 { 998 return fmt.Errorf("failed to find subject: %w || subject count %d", err, len(i)) 999 } ··· 1002 return fmt.Errorf("unsupport label subject: %s", collection) 1003 } 1004 1005 - actx, err := db.NewLabelApplicationCtx(ddb, db.FilterIn("at_uri", repo.Labels)) 1006 if err != nil { 1007 return fmt.Errorf("failed to build label application ctx: %w", err) 1008 }
··· 21 "tangled.org/core/appview/serververify" 22 "tangled.org/core/appview/validator" 23 "tangled.org/core/idresolver" 24 + "tangled.org/core/orm" 25 "tangled.org/core/rbac" 26 ) 27 ··· 122 return err 123 } 124 err = db.AddStar(i.Db, &models.Star{ 125 + Did: did, 126 + RepoAt: subjectUri, 127 + Rkey: e.Commit.RKey, 128 }) 129 case jmodels.CommitOperationDelete: 130 err = db.DeleteStarByRkey(i.Db, did, e.Commit.RKey) ··· 254 255 err = db.AddArtifact(i.Db, artifact) 256 case jmodels.CommitOperationDelete: 257 + err = db.DeleteArtifact(i.Db, orm.FilterEq("did", did), orm.FilterEq("rkey", e.Commit.RKey)) 258 } 259 260 if err != nil { ··· 351 352 err = db.UpsertProfile(tx, &profile) 353 case jmodels.CommitOperationDelete: 354 + err = db.DeleteArtifact(i.Db, orm.FilterEq("did", did), orm.FilterEq("rkey", e.Commit.RKey)) 355 } 356 357 if err != nil { ··· 425 // get record from db first 426 members, err := db.GetSpindleMembers( 427 ddb, 428 + orm.FilterEq("did", did), 429 + orm.FilterEq("rkey", rkey), 430 ) 431 if err != nil || len(members) != 1 { 432 return fmt.Errorf("failed to get member: %w, len(members) = %d", err, len(members)) ··· 441 // remove record by rkey && update enforcer 442 if err = db.RemoveSpindleMember( 443 tx, 444 + orm.FilterEq("did", did), 445 + orm.FilterEq("rkey", rkey), 446 ); err != nil { 447 return fmt.Errorf("failed to remove from db: %w", err) 448 } ··· 524 // get record from db first 525 spindles, err := db.GetSpindles( 526 ddb, 527 + orm.FilterEq("owner", did), 528 + orm.FilterEq("instance", instance), 529 ) 530 if err != nil || len(spindles) != 1 { 531 return fmt.Errorf("failed to get spindles: %w, len(spindles) = %d", err, len(spindles)) ··· 544 // remove spindle members first 545 err = db.RemoveSpindleMember( 546 tx, 547 + orm.FilterEq("owner", did), 548 + orm.FilterEq("instance", instance), 549 ) 550 if err != nil { 551 return err ··· 553 554 err = db.DeleteSpindle( 555 tx, 556 + orm.FilterEq("owner", did), 557 + orm.FilterEq("instance", instance), 558 ) 559 if err != nil { 560 return err ··· 622 case jmodels.CommitOperationDelete: 623 if err := db.DeleteString( 624 ddb, 625 + orm.FilterEq("did", did), 626 + orm.FilterEq("rkey", rkey), 627 ); err != nil { 628 l.Error("failed to delete", "err", err) 629 return fmt.Errorf("failed to delete string record: %w", err) ··· 741 // get record from db first 742 registrations, err := db.GetRegistrations( 743 ddb, 744 + orm.FilterEq("domain", domain), 745 + orm.FilterEq("did", did), 746 ) 747 if err != nil { 748 return fmt.Errorf("failed to get registration: %w", err) ··· 763 764 err = db.DeleteKnot( 765 tx, 766 + orm.FilterEq("did", did), 767 + orm.FilterEq("domain", domain), 768 ) 769 if err != nil { 770 return err ··· 842 return nil 843 844 case jmodels.CommitOperationDelete: 845 + tx, err := ddb.BeginTx(ctx, nil) 846 + if err != nil { 847 + l.Error("failed to begin transaction", "err", err) 848 + return err 849 + } 850 + defer tx.Rollback() 851 + 852 if err := db.DeleteIssues( 853 + tx, 854 + did, 855 + rkey, 856 ); err != nil { 857 l.Error("failed to delete", "err", err) 858 return fmt.Errorf("failed to delete issue record: %w", err) 859 + } 860 + if err := tx.Commit(); err != nil { 861 + l.Error("failed to commit txn", "err", err) 862 + return err 863 } 864 865 return nil ··· 900 return fmt.Errorf("failed to validate comment: %w", err) 901 } 902 903 + tx, err := ddb.Begin() 904 + if err != nil { 905 + return fmt.Errorf("failed to start transaction: %w", err) 906 + } 907 + defer tx.Rollback() 908 + 909 + _, err = db.AddIssueComment(tx, *comment) 910 if err != nil { 911 return fmt.Errorf("failed to create issue comment: %w", err) 912 } 913 914 + return tx.Commit() 915 916 case jmodels.CommitOperationDelete: 917 if err := db.DeleteIssueComments( 918 ddb, 919 + orm.FilterEq("did", did), 920 + orm.FilterEq("rkey", rkey), 921 ); err != nil { 922 return fmt.Errorf("failed to delete issue comment record: %w", err) 923 } ··· 970 case jmodels.CommitOperationDelete: 971 if err := db.DeleteLabelDefinition( 972 ddb, 973 + orm.FilterEq("did", did), 974 + orm.FilterEq("rkey", rkey), 975 ); err != nil { 976 return fmt.Errorf("failed to delete labeldef record: %w", err) 977 } ··· 1011 var repo *models.Repo 1012 switch collection { 1013 case tangled.RepoIssueNSID: 1014 + i, err := db.GetIssues(ddb, orm.FilterEq("at_uri", subject)) 1015 if err != nil || len(i) != 1 { 1016 return fmt.Errorf("failed to find subject: %w || subject count %d", err, len(i)) 1017 } ··· 1020 return fmt.Errorf("unsupport label subject: %s", collection) 1021 } 1022 1023 + actx, err := db.NewLabelApplicationCtx(ddb, orm.FilterIn("at_uri", repo.Labels)) 1024 if err != nil { 1025 return fmt.Errorf("failed to build label application ctx: %w", err) 1026 }
+152 -135
appview/issues/issues.go
··· 7 "fmt" 8 "log/slog" 9 "net/http" 10 - "slices" 11 "time" 12 13 comatproto "github.com/bluesky-social/indigo/api/atproto" ··· 20 "tangled.org/core/appview/config" 21 "tangled.org/core/appview/db" 22 issues_indexer "tangled.org/core/appview/indexer/issues" 23 "tangled.org/core/appview/models" 24 "tangled.org/core/appview/notify" 25 "tangled.org/core/appview/oauth" 26 "tangled.org/core/appview/pages" 27 - "tangled.org/core/appview/pages/markup" 28 "tangled.org/core/appview/pagination" 29 "tangled.org/core/appview/reporesolver" 30 "tangled.org/core/appview/validator" 31 "tangled.org/core/idresolver" 32 "tangled.org/core/tid" 33 ) 34 35 type Issues struct { 36 - oauth *oauth.OAuth 37 - repoResolver *reporesolver.RepoResolver 38 - pages *pages.Pages 39 - idResolver *idresolver.Resolver 40 - db *db.DB 41 - config *config.Config 42 - notifier notify.Notifier 43 - logger *slog.Logger 44 - validator *validator.Validator 45 - indexer *issues_indexer.Indexer 46 } 47 48 func New( 49 oauth *oauth.OAuth, 50 repoResolver *reporesolver.RepoResolver, 51 pages *pages.Pages, 52 idResolver *idresolver.Resolver, 53 db *db.DB, 54 config *config.Config, 55 notifier notify.Notifier, ··· 58 logger *slog.Logger, 59 ) *Issues { 60 return &Issues{ 61 - oauth: oauth, 62 - repoResolver: repoResolver, 63 - pages: pages, 64 - idResolver: idResolver, 65 - db: db, 66 - config: config, 67 - notifier: notifier, 68 - logger: logger, 69 - validator: validator, 70 - indexer: indexer, 71 } 72 } 73 ··· 97 userReactions = db.GetReactionStatusMap(rp.db, user.Did, issue.AtUri()) 98 } 99 100 labelDefs, err := db.GetLabelDefinitions( 101 rp.db, 102 - db.FilterIn("at_uri", f.Repo.Labels), 103 - db.FilterContains("scope", tangled.RepoIssueNSID), 104 ) 105 if err != nil { 106 l.Error("failed to fetch labels", "err", err) ··· 115 116 rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{ 117 LoggedInUser: user, 118 - RepoInfo: f.RepoInfo(user), 119 Issue: issue, 120 CommentList: issue.CommentList(), 121 OrderedReactionKinds: models.OrderedReactionKinds, 122 Reactions: reactionMap, 123 UserReacted: userReactions, ··· 128 func (rp *Issues) EditIssue(w http.ResponseWriter, r *http.Request) { 129 l := rp.logger.With("handler", "EditIssue") 130 user := rp.oauth.GetUser(r) 131 - f, err := rp.repoResolver.Resolve(r) 132 - if err != nil { 133 - l.Error("failed to get repo and knot", "err", err) 134 - return 135 - } 136 137 issue, ok := r.Context().Value("issue").(*models.Issue) 138 if !ok { ··· 145 case http.MethodGet: 146 rp.pages.EditIssueFragment(w, pages.EditIssueParams{ 147 LoggedInUser: user, 148 - RepoInfo: f.RepoInfo(user), 149 Issue: issue, 150 }) 151 case http.MethodPost: ··· 153 newIssue := issue 154 newIssue.Title = r.FormValue("title") 155 newIssue.Body = r.FormValue("body") 156 157 if err := rp.validator.ValidateIssue(newIssue); err != nil { 158 l.Error("validation error", "err", err) ··· 222 l := rp.logger.With("handler", "DeleteIssue") 223 noticeId := "issue-actions-error" 224 225 - user := rp.oauth.GetUser(r) 226 - 227 f, err := rp.repoResolver.Resolve(r) 228 if err != nil { 229 l.Error("failed to get repo and knot", "err", err) ··· 238 } 239 l = l.With("did", issue.Did, "rkey", issue.Rkey) 240 241 // delete from PDS 242 client, err := rp.oauth.AuthorizedClient(r) 243 if err != nil { ··· 258 } 259 260 // delete from db 261 - if err := db.DeleteIssues(rp.db, db.FilterEq("id", issue.Id)); err != nil { 262 l.Error("failed to delete issue", "err", err) 263 rp.pages.Notice(w, noticeId, "Failed to delete issue.") 264 return 265 } 266 267 rp.notifier.DeleteIssue(r.Context(), issue) 268 269 // return to all issues page 270 - rp.pages.HxRedirect(w, "/"+f.RepoInfo(user).FullName()+"/issues") 271 } 272 273 func (rp *Issues) CloseIssue(w http.ResponseWriter, r *http.Request) { ··· 286 return 287 } 288 289 - collaborators, err := f.Collaborators(r.Context()) 290 - if err != nil { 291 - l.Error("failed to fetch repo collaborators", "err", err) 292 - } 293 - isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 294 - return user.Did == collab.Did 295 - }) 296 isIssueOwner := user.Did == issue.Did 297 298 // TODO: make this more granular 299 - if isIssueOwner || isCollaborator { 300 err = db.CloseIssues( 301 rp.db, 302 - db.FilterEq("id", issue.Id), 303 ) 304 if err != nil { 305 l.Error("failed to close issue", "err", err) ··· 312 // notify about the issue closure 313 rp.notifier.NewIssueState(r.Context(), syntax.DID(user.Did), issue) 314 315 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 316 return 317 } else { 318 l.Error("user is not permitted to close issue") ··· 337 return 338 } 339 340 - collaborators, err := f.Collaborators(r.Context()) 341 - if err != nil { 342 - l.Error("failed to fetch repo collaborators", "err", err) 343 - } 344 - isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 345 - return user.Did == collab.Did 346 - }) 347 isIssueOwner := user.Did == issue.Did 348 349 - if isCollaborator || isIssueOwner { 350 err := db.ReopenIssues( 351 rp.db, 352 - db.FilterEq("id", issue.Id), 353 ) 354 if err != nil { 355 l.Error("failed to reopen issue", "err", err) ··· 362 // notify about the issue reopen 363 rp.notifier.NewIssueState(r.Context(), syntax.DID(user.Did), issue) 364 365 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 366 return 367 } else { 368 l.Error("user is not the owner of the repo") ··· 398 if replyToUri != "" { 399 replyTo = &replyToUri 400 } 401 402 comment := models.IssueComment{ 403 - Did: user.Did, 404 - Rkey: tid.TID(), 405 - IssueAt: issue.AtUri().String(), 406 - ReplyTo: replyTo, 407 - Body: body, 408 - Created: time.Now(), 409 } 410 if err = rp.validator.ValidateIssueComment(&comment); err != nil { 411 l.Error("failed to validate comment", "err", err) ··· 442 } 443 }() 444 445 - commentId, err := db.AddIssueComment(rp.db, comment) 446 if err != nil { 447 l.Error("failed to create comment", "err", err) 448 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 449 return 450 } 451 452 // reset atUri to make rollback a no-op 453 atUri = "" ··· 455 // notify about the new comment 456 comment.Id = commentId 457 458 - rawMentions := markup.FindUserMentions(comment.Body) 459 - idents := rp.idResolver.ResolveIdents(r.Context(), rawMentions) 460 - l.Debug("parsed mentions", "raw", rawMentions, "idents", idents) 461 - var mentions []syntax.DID 462 - for _, ident := range idents { 463 - if ident != nil && !ident.Handle.IsInvalidHandle() { 464 - mentions = append(mentions, ident.DID) 465 - } 466 - } 467 rp.notifier.NewIssueComment(r.Context(), &comment, mentions) 468 469 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issue.IssueId, commentId)) 470 } 471 472 func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) { 473 l := rp.logger.With("handler", "IssueComment") 474 user := rp.oauth.GetUser(r) 475 - f, err := rp.repoResolver.Resolve(r) 476 - if err != nil { 477 - l.Error("failed to get repo and knot", "err", err) 478 - return 479 - } 480 481 issue, ok := r.Context().Value("issue").(*models.Issue) 482 if !ok { ··· 488 commentId := chi.URLParam(r, "commentId") 489 comments, err := db.GetIssueComments( 490 rp.db, 491 - db.FilterEq("id", commentId), 492 ) 493 if err != nil { 494 l.Error("failed to fetch comment", "id", commentId) ··· 504 505 rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 506 LoggedInUser: user, 507 - RepoInfo: f.RepoInfo(user), 508 Issue: issue, 509 Comment: &comment, 510 }) ··· 513 func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) { 514 l := rp.logger.With("handler", "EditIssueComment") 515 user := rp.oauth.GetUser(r) 516 - f, err := rp.repoResolver.Resolve(r) 517 - if err != nil { 518 - l.Error("failed to get repo and knot", "err", err) 519 - return 520 - } 521 522 issue, ok := r.Context().Value("issue").(*models.Issue) 523 if !ok { ··· 529 commentId := chi.URLParam(r, "commentId") 530 comments, err := db.GetIssueComments( 531 rp.db, 532 - db.FilterEq("id", commentId), 533 ) 534 if err != nil { 535 l.Error("failed to fetch comment", "id", commentId) ··· 553 case http.MethodGet: 554 rp.pages.EditIssueCommentFragment(w, pages.EditIssueCommentParams{ 555 LoggedInUser: user, 556 - RepoInfo: f.RepoInfo(user), 557 Issue: issue, 558 Comment: &comment, 559 }) ··· 571 newComment := comment 572 newComment.Body = newBody 573 newComment.Edited = &now 574 record := newComment.AsRecord() 575 576 - _, err = db.AddIssueComment(rp.db, newComment) 577 if err != nil { 578 l.Error("failed to perferom update-description query", "err", err) 579 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 580 return 581 } 582 583 // rkey is optional, it was introduced later 584 if newComment.Rkey != "" { ··· 607 // return new comment body with htmx 608 rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 609 LoggedInUser: user, 610 - RepoInfo: f.RepoInfo(user), 611 Issue: issue, 612 Comment: &newComment, 613 }) ··· 617 func (rp *Issues) ReplyIssueCommentPlaceholder(w http.ResponseWriter, r *http.Request) { 618 l := rp.logger.With("handler", "ReplyIssueCommentPlaceholder") 619 user := rp.oauth.GetUser(r) 620 - f, err := rp.repoResolver.Resolve(r) 621 - if err != nil { 622 - l.Error("failed to get repo and knot", "err", err) 623 - return 624 - } 625 626 issue, ok := r.Context().Value("issue").(*models.Issue) 627 if !ok { ··· 633 commentId := chi.URLParam(r, "commentId") 634 comments, err := db.GetIssueComments( 635 rp.db, 636 - db.FilterEq("id", commentId), 637 ) 638 if err != nil { 639 l.Error("failed to fetch comment", "id", commentId) ··· 649 650 rp.pages.ReplyIssueCommentPlaceholderFragment(w, pages.ReplyIssueCommentPlaceholderParams{ 651 LoggedInUser: user, 652 - RepoInfo: f.RepoInfo(user), 653 Issue: issue, 654 Comment: &comment, 655 }) ··· 658 func (rp *Issues) ReplyIssueComment(w http.ResponseWriter, r *http.Request) { 659 l := rp.logger.With("handler", "ReplyIssueComment") 660 user := rp.oauth.GetUser(r) 661 - f, err := rp.repoResolver.Resolve(r) 662 - if err != nil { 663 - l.Error("failed to get repo and knot", "err", err) 664 - return 665 - } 666 667 issue, ok := r.Context().Value("issue").(*models.Issue) 668 if !ok { ··· 674 commentId := chi.URLParam(r, "commentId") 675 comments, err := db.GetIssueComments( 676 rp.db, 677 - db.FilterEq("id", commentId), 678 ) 679 if err != nil { 680 l.Error("failed to fetch comment", "id", commentId) ··· 690 691 rp.pages.ReplyIssueCommentFragment(w, pages.ReplyIssueCommentParams{ 692 LoggedInUser: user, 693 - RepoInfo: f.RepoInfo(user), 694 Issue: issue, 695 Comment: &comment, 696 }) ··· 699 func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { 700 l := rp.logger.With("handler", "DeleteIssueComment") 701 user := rp.oauth.GetUser(r) 702 - f, err := rp.repoResolver.Resolve(r) 703 - if err != nil { 704 - l.Error("failed to get repo and knot", "err", err) 705 - return 706 - } 707 708 issue, ok := r.Context().Value("issue").(*models.Issue) 709 if !ok { ··· 715 commentId := chi.URLParam(r, "commentId") 716 comments, err := db.GetIssueComments( 717 rp.db, 718 - db.FilterEq("id", commentId), 719 ) 720 if err != nil { 721 l.Error("failed to fetch comment", "id", commentId) ··· 742 743 // optimistic deletion 744 deleted := time.Now() 745 - err = db.DeleteIssueComments(rp.db, db.FilterEq("id", comment.Id)) 746 if err != nil { 747 l.Error("failed to delete comment", "err", err) 748 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment") ··· 774 // htmx fragment of comment after deletion 775 rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 776 LoggedInUser: user, 777 - RepoInfo: f.RepoInfo(user), 778 Issue: issue, 779 Comment: &comment, 780 }) ··· 804 return 805 } 806 807 keyword := params.Get("q") 808 809 var issues []models.Issue ··· 820 return 821 } 822 l.Debug("searched issues with indexer", "count", len(res.Hits)) 823 824 issues, err = db.GetIssues( 825 rp.db, 826 - db.FilterIn("id", res.Hits), 827 ) 828 if err != nil { 829 l.Error("failed to get issues", "err", err) ··· 839 issues, err = db.GetIssuesPaginated( 840 rp.db, 841 page, 842 - db.FilterEq("repo_at", f.RepoAt()), 843 - db.FilterEq("open", openInt), 844 ) 845 if err != nil { 846 l.Error("failed to get issues", "err", err) ··· 851 852 labelDefs, err := db.GetLabelDefinitions( 853 rp.db, 854 - db.FilterIn("at_uri", f.Repo.Labels), 855 - db.FilterContains("scope", tangled.RepoIssueNSID), 856 ) 857 if err != nil { 858 l.Error("failed to fetch labels", "err", err) ··· 867 868 rp.pages.RepoIssues(w, pages.RepoIssuesParams{ 869 LoggedInUser: rp.oauth.GetUser(r), 870 - RepoInfo: f.RepoInfo(user), 871 Issues: issues, 872 LabelDefs: defs, 873 FilteringByOpen: isOpen, 874 FilterQuery: keyword, ··· 890 case http.MethodGet: 891 rp.pages.RepoNewIssue(w, pages.RepoNewIssueParams{ 892 LoggedInUser: user, 893 - RepoInfo: f.RepoInfo(user), 894 }) 895 case http.MethodPost: 896 issue := &models.Issue{ 897 - RepoAt: f.RepoAt(), 898 - Rkey: tid.TID(), 899 - Title: r.FormValue("title"), 900 - Body: r.FormValue("body"), 901 - Open: true, 902 - Did: user.Did, 903 - Created: time.Now(), 904 - Repo: &f.Repo, 905 } 906 907 if err := rp.validator.ValidateIssue(issue); err != nil { ··· 969 // everything is successful, do not rollback the atproto record 970 atUri = "" 971 972 - rawMentions := markup.FindUserMentions(issue.Body) 973 - idents := rp.idResolver.ResolveIdents(r.Context(), rawMentions) 974 - l.Debug("parsed mentions", "raw", rawMentions, "idents", idents) 975 - var mentions []syntax.DID 976 - for _, ident := range idents { 977 - if ident != nil && !ident.Handle.IsInvalidHandle() { 978 - mentions = append(mentions, ident.DID) 979 - } 980 - } 981 rp.notifier.NewIssue(r.Context(), issue, mentions) 982 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 983 return 984 } 985 }
··· 7 "fmt" 8 "log/slog" 9 "net/http" 10 "time" 11 12 comatproto "github.com/bluesky-social/indigo/api/atproto" ··· 19 "tangled.org/core/appview/config" 20 "tangled.org/core/appview/db" 21 issues_indexer "tangled.org/core/appview/indexer/issues" 22 + "tangled.org/core/appview/mentions" 23 "tangled.org/core/appview/models" 24 "tangled.org/core/appview/notify" 25 "tangled.org/core/appview/oauth" 26 "tangled.org/core/appview/pages" 27 + "tangled.org/core/appview/pages/repoinfo" 28 "tangled.org/core/appview/pagination" 29 "tangled.org/core/appview/reporesolver" 30 "tangled.org/core/appview/validator" 31 "tangled.org/core/idresolver" 32 + "tangled.org/core/orm" 33 + "tangled.org/core/rbac" 34 "tangled.org/core/tid" 35 ) 36 37 type Issues struct { 38 + oauth *oauth.OAuth 39 + repoResolver *reporesolver.RepoResolver 40 + enforcer *rbac.Enforcer 41 + pages *pages.Pages 42 + idResolver *idresolver.Resolver 43 + mentionsResolver *mentions.Resolver 44 + db *db.DB 45 + config *config.Config 46 + notifier notify.Notifier 47 + logger *slog.Logger 48 + validator *validator.Validator 49 + indexer *issues_indexer.Indexer 50 } 51 52 func New( 53 oauth *oauth.OAuth, 54 repoResolver *reporesolver.RepoResolver, 55 + enforcer *rbac.Enforcer, 56 pages *pages.Pages, 57 idResolver *idresolver.Resolver, 58 + mentionsResolver *mentions.Resolver, 59 db *db.DB, 60 config *config.Config, 61 notifier notify.Notifier, ··· 64 logger *slog.Logger, 65 ) *Issues { 66 return &Issues{ 67 + oauth: oauth, 68 + repoResolver: repoResolver, 69 + enforcer: enforcer, 70 + pages: pages, 71 + idResolver: idResolver, 72 + mentionsResolver: mentionsResolver, 73 + db: db, 74 + config: config, 75 + notifier: notifier, 76 + logger: logger, 77 + validator: validator, 78 + indexer: indexer, 79 } 80 } 81 ··· 105 userReactions = db.GetReactionStatusMap(rp.db, user.Did, issue.AtUri()) 106 } 107 108 + backlinks, err := db.GetBacklinks(rp.db, issue.AtUri()) 109 + if err != nil { 110 + l.Error("failed to fetch backlinks", "err", err) 111 + rp.pages.Error503(w) 112 + return 113 + } 114 + 115 labelDefs, err := db.GetLabelDefinitions( 116 rp.db, 117 + orm.FilterIn("at_uri", f.Labels), 118 + orm.FilterContains("scope", tangled.RepoIssueNSID), 119 ) 120 if err != nil { 121 l.Error("failed to fetch labels", "err", err) ··· 130 131 rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{ 132 LoggedInUser: user, 133 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 134 Issue: issue, 135 CommentList: issue.CommentList(), 136 + Backlinks: backlinks, 137 OrderedReactionKinds: models.OrderedReactionKinds, 138 Reactions: reactionMap, 139 UserReacted: userReactions, ··· 144 func (rp *Issues) EditIssue(w http.ResponseWriter, r *http.Request) { 145 l := rp.logger.With("handler", "EditIssue") 146 user := rp.oauth.GetUser(r) 147 148 issue, ok := r.Context().Value("issue").(*models.Issue) 149 if !ok { ··· 156 case http.MethodGet: 157 rp.pages.EditIssueFragment(w, pages.EditIssueParams{ 158 LoggedInUser: user, 159 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 160 Issue: issue, 161 }) 162 case http.MethodPost: ··· 164 newIssue := issue 165 newIssue.Title = r.FormValue("title") 166 newIssue.Body = r.FormValue("body") 167 + newIssue.Mentions, newIssue.References = rp.mentionsResolver.Resolve(r.Context(), newIssue.Body) 168 169 if err := rp.validator.ValidateIssue(newIssue); err != nil { 170 l.Error("validation error", "err", err) ··· 234 l := rp.logger.With("handler", "DeleteIssue") 235 noticeId := "issue-actions-error" 236 237 f, err := rp.repoResolver.Resolve(r) 238 if err != nil { 239 l.Error("failed to get repo and knot", "err", err) ··· 248 } 249 l = l.With("did", issue.Did, "rkey", issue.Rkey) 250 251 + tx, err := rp.db.Begin() 252 + if err != nil { 253 + l.Error("failed to start transaction", "err", err) 254 + rp.pages.Notice(w, "issue-comment", "Failed to create comment, try again later.") 255 + return 256 + } 257 + defer tx.Rollback() 258 + 259 // delete from PDS 260 client, err := rp.oauth.AuthorizedClient(r) 261 if err != nil { ··· 276 } 277 278 // delete from db 279 + if err := db.DeleteIssues(tx, issue.Did, issue.Rkey); err != nil { 280 l.Error("failed to delete issue", "err", err) 281 rp.pages.Notice(w, noticeId, "Failed to delete issue.") 282 return 283 } 284 + tx.Commit() 285 286 rp.notifier.DeleteIssue(r.Context(), issue) 287 288 // return to all issues page 289 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 290 + rp.pages.HxRedirect(w, "/"+ownerSlashRepo+"/issues") 291 } 292 293 func (rp *Issues) CloseIssue(w http.ResponseWriter, r *http.Request) { ··· 306 return 307 } 308 309 + roles := repoinfo.RolesInRepo{Roles: rp.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())} 310 + isRepoOwner := roles.IsOwner() 311 + isCollaborator := roles.IsCollaborator() 312 isIssueOwner := user.Did == issue.Did 313 314 // TODO: make this more granular 315 + if isIssueOwner || isRepoOwner || isCollaborator { 316 err = db.CloseIssues( 317 rp.db, 318 + orm.FilterEq("id", issue.Id), 319 ) 320 if err != nil { 321 l.Error("failed to close issue", "err", err) ··· 328 // notify about the issue closure 329 rp.notifier.NewIssueState(r.Context(), syntax.DID(user.Did), issue) 330 331 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 332 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId)) 333 return 334 } else { 335 l.Error("user is not permitted to close issue") ··· 354 return 355 } 356 357 + roles := repoinfo.RolesInRepo{Roles: rp.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())} 358 + isRepoOwner := roles.IsOwner() 359 + isCollaborator := roles.IsCollaborator() 360 isIssueOwner := user.Did == issue.Did 361 362 + if isCollaborator || isRepoOwner || isIssueOwner { 363 err := db.ReopenIssues( 364 rp.db, 365 + orm.FilterEq("id", issue.Id), 366 ) 367 if err != nil { 368 l.Error("failed to reopen issue", "err", err) ··· 375 // notify about the issue reopen 376 rp.notifier.NewIssueState(r.Context(), syntax.DID(user.Did), issue) 377 378 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 379 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId)) 380 return 381 } else { 382 l.Error("user is not the owner of the repo") ··· 412 if replyToUri != "" { 413 replyTo = &replyToUri 414 } 415 + 416 + mentions, references := rp.mentionsResolver.Resolve(r.Context(), body) 417 418 comment := models.IssueComment{ 419 + Did: user.Did, 420 + Rkey: tid.TID(), 421 + IssueAt: issue.AtUri().String(), 422 + ReplyTo: replyTo, 423 + Body: body, 424 + Created: time.Now(), 425 + Mentions: mentions, 426 + References: references, 427 } 428 if err = rp.validator.ValidateIssueComment(&comment); err != nil { 429 l.Error("failed to validate comment", "err", err) ··· 460 } 461 }() 462 463 + tx, err := rp.db.Begin() 464 + if err != nil { 465 + l.Error("failed to start transaction", "err", err) 466 + rp.pages.Notice(w, "issue-comment", "Failed to create comment, try again later.") 467 + return 468 + } 469 + defer tx.Rollback() 470 + 471 + commentId, err := db.AddIssueComment(tx, comment) 472 if err != nil { 473 l.Error("failed to create comment", "err", err) 474 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 475 return 476 } 477 + err = tx.Commit() 478 + if err != nil { 479 + l.Error("failed to commit transaction", "err", err) 480 + rp.pages.Notice(w, "issue-comment", "Failed to create comment, try again later.") 481 + return 482 + } 483 484 // reset atUri to make rollback a no-op 485 atUri = "" ··· 487 // notify about the new comment 488 comment.Id = commentId 489 490 rp.notifier.NewIssueComment(r.Context(), &comment, mentions) 491 492 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 493 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", ownerSlashRepo, issue.IssueId, commentId)) 494 } 495 496 func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) { 497 l := rp.logger.With("handler", "IssueComment") 498 user := rp.oauth.GetUser(r) 499 500 issue, ok := r.Context().Value("issue").(*models.Issue) 501 if !ok { ··· 507 commentId := chi.URLParam(r, "commentId") 508 comments, err := db.GetIssueComments( 509 rp.db, 510 + orm.FilterEq("id", commentId), 511 ) 512 if err != nil { 513 l.Error("failed to fetch comment", "id", commentId) ··· 523 524 rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 525 LoggedInUser: user, 526 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 527 Issue: issue, 528 Comment: &comment, 529 }) ··· 532 func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) { 533 l := rp.logger.With("handler", "EditIssueComment") 534 user := rp.oauth.GetUser(r) 535 536 issue, ok := r.Context().Value("issue").(*models.Issue) 537 if !ok { ··· 543 commentId := chi.URLParam(r, "commentId") 544 comments, err := db.GetIssueComments( 545 rp.db, 546 + orm.FilterEq("id", commentId), 547 ) 548 if err != nil { 549 l.Error("failed to fetch comment", "id", commentId) ··· 567 case http.MethodGet: 568 rp.pages.EditIssueCommentFragment(w, pages.EditIssueCommentParams{ 569 LoggedInUser: user, 570 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 571 Issue: issue, 572 Comment: &comment, 573 }) ··· 585 newComment := comment 586 newComment.Body = newBody 587 newComment.Edited = &now 588 + newComment.Mentions, newComment.References = rp.mentionsResolver.Resolve(r.Context(), newBody) 589 + 590 record := newComment.AsRecord() 591 592 + tx, err := rp.db.Begin() 593 + if err != nil { 594 + l.Error("failed to start transaction", "err", err) 595 + rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 596 + return 597 + } 598 + defer tx.Rollback() 599 + 600 + _, err = db.AddIssueComment(tx, newComment) 601 if err != nil { 602 l.Error("failed to perferom update-description query", "err", err) 603 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 604 return 605 } 606 + tx.Commit() 607 608 // rkey is optional, it was introduced later 609 if newComment.Rkey != "" { ··· 632 // return new comment body with htmx 633 rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 634 LoggedInUser: user, 635 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 636 Issue: issue, 637 Comment: &newComment, 638 }) ··· 642 func (rp *Issues) ReplyIssueCommentPlaceholder(w http.ResponseWriter, r *http.Request) { 643 l := rp.logger.With("handler", "ReplyIssueCommentPlaceholder") 644 user := rp.oauth.GetUser(r) 645 646 issue, ok := r.Context().Value("issue").(*models.Issue) 647 if !ok { ··· 653 commentId := chi.URLParam(r, "commentId") 654 comments, err := db.GetIssueComments( 655 rp.db, 656 + orm.FilterEq("id", commentId), 657 ) 658 if err != nil { 659 l.Error("failed to fetch comment", "id", commentId) ··· 669 670 rp.pages.ReplyIssueCommentPlaceholderFragment(w, pages.ReplyIssueCommentPlaceholderParams{ 671 LoggedInUser: user, 672 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 673 Issue: issue, 674 Comment: &comment, 675 }) ··· 678 func (rp *Issues) ReplyIssueComment(w http.ResponseWriter, r *http.Request) { 679 l := rp.logger.With("handler", "ReplyIssueComment") 680 user := rp.oauth.GetUser(r) 681 682 issue, ok := r.Context().Value("issue").(*models.Issue) 683 if !ok { ··· 689 commentId := chi.URLParam(r, "commentId") 690 comments, err := db.GetIssueComments( 691 rp.db, 692 + orm.FilterEq("id", commentId), 693 ) 694 if err != nil { 695 l.Error("failed to fetch comment", "id", commentId) ··· 705 706 rp.pages.ReplyIssueCommentFragment(w, pages.ReplyIssueCommentParams{ 707 LoggedInUser: user, 708 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 709 Issue: issue, 710 Comment: &comment, 711 }) ··· 714 func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { 715 l := rp.logger.With("handler", "DeleteIssueComment") 716 user := rp.oauth.GetUser(r) 717 718 issue, ok := r.Context().Value("issue").(*models.Issue) 719 if !ok { ··· 725 commentId := chi.URLParam(r, "commentId") 726 comments, err := db.GetIssueComments( 727 rp.db, 728 + orm.FilterEq("id", commentId), 729 ) 730 if err != nil { 731 l.Error("failed to fetch comment", "id", commentId) ··· 752 753 // optimistic deletion 754 deleted := time.Now() 755 + err = db.DeleteIssueComments(rp.db, orm.FilterEq("id", comment.Id)) 756 if err != nil { 757 l.Error("failed to delete comment", "err", err) 758 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment") ··· 784 // htmx fragment of comment after deletion 785 rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 786 LoggedInUser: user, 787 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 788 Issue: issue, 789 Comment: &comment, 790 }) ··· 814 return 815 } 816 817 + totalIssues := 0 818 + if isOpen { 819 + totalIssues = f.RepoStats.IssueCount.Open 820 + } else { 821 + totalIssues = f.RepoStats.IssueCount.Closed 822 + } 823 + 824 keyword := params.Get("q") 825 826 var issues []models.Issue ··· 837 return 838 } 839 l.Debug("searched issues with indexer", "count", len(res.Hits)) 840 + totalIssues = int(res.Total) 841 842 issues, err = db.GetIssues( 843 rp.db, 844 + orm.FilterIn("id", res.Hits), 845 ) 846 if err != nil { 847 l.Error("failed to get issues", "err", err) ··· 857 issues, err = db.GetIssuesPaginated( 858 rp.db, 859 page, 860 + orm.FilterEq("repo_at", f.RepoAt()), 861 + orm.FilterEq("open", openInt), 862 ) 863 if err != nil { 864 l.Error("failed to get issues", "err", err) ··· 869 870 labelDefs, err := db.GetLabelDefinitions( 871 rp.db, 872 + orm.FilterIn("at_uri", f.Labels), 873 + orm.FilterContains("scope", tangled.RepoIssueNSID), 874 ) 875 if err != nil { 876 l.Error("failed to fetch labels", "err", err) ··· 885 886 rp.pages.RepoIssues(w, pages.RepoIssuesParams{ 887 LoggedInUser: rp.oauth.GetUser(r), 888 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 889 Issues: issues, 890 + IssueCount: totalIssues, 891 LabelDefs: defs, 892 FilteringByOpen: isOpen, 893 FilterQuery: keyword, ··· 909 case http.MethodGet: 910 rp.pages.RepoNewIssue(w, pages.RepoNewIssueParams{ 911 LoggedInUser: user, 912 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 913 }) 914 case http.MethodPost: 915 + body := r.FormValue("body") 916 + mentions, references := rp.mentionsResolver.Resolve(r.Context(), body) 917 + 918 issue := &models.Issue{ 919 + RepoAt: f.RepoAt(), 920 + Rkey: tid.TID(), 921 + Title: r.FormValue("title"), 922 + Body: body, 923 + Open: true, 924 + Did: user.Did, 925 + Created: time.Now(), 926 + Mentions: mentions, 927 + References: references, 928 + Repo: f, 929 } 930 931 if err := rp.validator.ValidateIssue(issue); err != nil { ··· 993 // everything is successful, do not rollback the atproto record 994 atUri = "" 995 996 rp.notifier.NewIssue(r.Context(), issue, mentions) 997 + 998 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 999 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId)) 1000 return 1001 } 1002 }
+5 -5
appview/issues/opengraph.go
··· 193 dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2) 194 dollyY := statsY + iconBaselineOffset - dollySize/2 + 25 195 dollyColor := color.RGBA{180, 180, 180, 255} // light gray 196 - err = dollyArea.DrawDollySilhouette(dollyX, dollyY, dollySize, dollyColor) 197 if err != nil { 198 - log.Printf("dolly silhouette not available (this is ok): %v", err) 199 } 200 201 // Draw "opened by @author" and date at the bottom with more spacing ··· 232 233 // Get owner handle for avatar 234 var ownerHandle string 235 - owner, err := rp.idResolver.ResolveIdent(r.Context(), f.Repo.Did) 236 if err != nil { 237 - ownerHandle = f.Repo.Did 238 } else { 239 ownerHandle = "@" + owner.Handle.String() 240 } 241 242 - card, err := rp.drawIssueSummaryCard(issue, &f.Repo, commentCount, ownerHandle) 243 if err != nil { 244 log.Println("failed to draw issue summary card", err) 245 http.Error(w, "failed to draw issue summary card", http.StatusInternalServerError)
··· 193 dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2) 194 dollyY := statsY + iconBaselineOffset - dollySize/2 + 25 195 dollyColor := color.RGBA{180, 180, 180, 255} // light gray 196 + err = dollyArea.DrawDolly(dollyX, dollyY, dollySize, dollyColor) 197 if err != nil { 198 + log.Printf("dolly not available (this is ok): %v", err) 199 } 200 201 // Draw "opened by @author" and date at the bottom with more spacing ··· 232 233 // Get owner handle for avatar 234 var ownerHandle string 235 + owner, err := rp.idResolver.ResolveIdent(r.Context(), f.Did) 236 if err != nil { 237 + ownerHandle = f.Did 238 } else { 239 ownerHandle = "@" + owner.Handle.String() 240 } 241 242 + card, err := rp.drawIssueSummaryCard(issue, f, commentCount, ownerHandle) 243 if err != nil { 244 log.Println("failed to draw issue summary card", err) 245 http.Error(w, "failed to draw issue summary card", http.StatusInternalServerError)
+37 -24
appview/knots/knots.go
··· 21 "tangled.org/core/appview/xrpcclient" 22 "tangled.org/core/eventconsumer" 23 "tangled.org/core/idresolver" 24 "tangled.org/core/rbac" 25 "tangled.org/core/tid" 26 ··· 39 Knotstream *eventconsumer.Consumer 40 } 41 42 func (k *Knots) Router() http.Handler { 43 r := chi.NewRouter() 44 ··· 59 user := k.OAuth.GetUser(r) 60 registrations, err := db.GetRegistrations( 61 k.Db, 62 - db.FilterEq("did", user.Did), 63 ) 64 if err != nil { 65 k.Logger.Error("failed to fetch knot registrations", "err", err) ··· 70 k.Pages.Knots(w, pages.KnotsParams{ 71 LoggedInUser: user, 72 Registrations: registrations, 73 }) 74 } 75 ··· 87 88 registrations, err := db.GetRegistrations( 89 k.Db, 90 - db.FilterEq("did", user.Did), 91 - db.FilterEq("domain", domain), 92 ) 93 if err != nil { 94 l.Error("failed to get registrations", "err", err) ··· 112 repos, err := db.GetRepos( 113 k.Db, 114 0, 115 - db.FilterEq("knot", domain), 116 ) 117 if err != nil { 118 l.Error("failed to get knot repos", "err", err) ··· 132 Members: members, 133 Repos: repoMap, 134 IsOwner: true, 135 }) 136 } 137 ··· 276 // get record from db first 277 registrations, err := db.GetRegistrations( 278 k.Db, 279 - db.FilterEq("did", user.Did), 280 - db.FilterEq("domain", domain), 281 ) 282 if err != nil { 283 l.Error("failed to get registration", "err", err) ··· 304 305 err = db.DeleteKnot( 306 tx, 307 - db.FilterEq("did", user.Did), 308 - db.FilterEq("domain", domain), 309 ) 310 if err != nil { 311 l.Error("failed to delete registration", "err", err) ··· 385 // get record from db first 386 registrations, err := db.GetRegistrations( 387 k.Db, 388 - db.FilterEq("did", user.Did), 389 - db.FilterEq("domain", domain), 390 ) 391 if err != nil { 392 l.Error("failed to get registration", "err", err) ··· 476 // Get updated registration to show 477 registrations, err = db.GetRegistrations( 478 k.Db, 479 - db.FilterEq("did", user.Did), 480 - db.FilterEq("domain", domain), 481 ) 482 if err != nil { 483 l.Error("failed to get registration", "err", err) ··· 512 513 registrations, err := db.GetRegistrations( 514 k.Db, 515 - db.FilterEq("did", user.Did), 516 - db.FilterEq("domain", domain), 517 - db.FilterIsNot("registered", "null"), 518 ) 519 if err != nil { 520 l.Error("failed to get registration", "err", err) ··· 596 } 597 598 // success 599 - k.Pages.HxRedirect(w, fmt.Sprintf("/knots/%s", domain)) 600 } 601 602 func (k *Knots) removeMember(w http.ResponseWriter, r *http.Request) { ··· 620 621 registrations, err := db.GetRegistrations( 622 k.Db, 623 - db.FilterEq("did", user.Did), 624 - db.FilterEq("domain", domain), 625 - db.FilterIsNot("registered", "null"), 626 ) 627 if err != nil { 628 l.Error("failed to get registration", "err", err) ··· 645 memberId, err := k.IdResolver.ResolveIdent(r.Context(), member) 646 if err != nil { 647 l.Error("failed to resolve member identity to handle", "err", err) 648 - k.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 649 - return 650 - } 651 - if memberId.Handle.IsInvalidHandle() { 652 - l.Error("failed to resolve member identity to handle") 653 k.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 654 return 655 }
··· 21 "tangled.org/core/appview/xrpcclient" 22 "tangled.org/core/eventconsumer" 23 "tangled.org/core/idresolver" 24 + "tangled.org/core/orm" 25 "tangled.org/core/rbac" 26 "tangled.org/core/tid" 27 ··· 40 Knotstream *eventconsumer.Consumer 41 } 42 43 + type tab = map[string]any 44 + 45 + var ( 46 + knotsTabs []tab = []tab{ 47 + {"Name": "profile", "Icon": "user"}, 48 + {"Name": "keys", "Icon": "key"}, 49 + {"Name": "emails", "Icon": "mail"}, 50 + {"Name": "notifications", "Icon": "bell"}, 51 + {"Name": "knots", "Icon": "volleyball"}, 52 + {"Name": "spindles", "Icon": "spool"}, 53 + } 54 + ) 55 + 56 func (k *Knots) Router() http.Handler { 57 r := chi.NewRouter() 58 ··· 73 user := k.OAuth.GetUser(r) 74 registrations, err := db.GetRegistrations( 75 k.Db, 76 + orm.FilterEq("did", user.Did), 77 ) 78 if err != nil { 79 k.Logger.Error("failed to fetch knot registrations", "err", err) ··· 84 k.Pages.Knots(w, pages.KnotsParams{ 85 LoggedInUser: user, 86 Registrations: registrations, 87 + Tabs: knotsTabs, 88 + Tab: "knots", 89 }) 90 } 91 ··· 103 104 registrations, err := db.GetRegistrations( 105 k.Db, 106 + orm.FilterEq("did", user.Did), 107 + orm.FilterEq("domain", domain), 108 ) 109 if err != nil { 110 l.Error("failed to get registrations", "err", err) ··· 128 repos, err := db.GetRepos( 129 k.Db, 130 0, 131 + orm.FilterEq("knot", domain), 132 ) 133 if err != nil { 134 l.Error("failed to get knot repos", "err", err) ··· 148 Members: members, 149 Repos: repoMap, 150 IsOwner: true, 151 + Tabs: knotsTabs, 152 + Tab: "knots", 153 }) 154 } 155 ··· 294 // get record from db first 295 registrations, err := db.GetRegistrations( 296 k.Db, 297 + orm.FilterEq("did", user.Did), 298 + orm.FilterEq("domain", domain), 299 ) 300 if err != nil { 301 l.Error("failed to get registration", "err", err) ··· 322 323 err = db.DeleteKnot( 324 tx, 325 + orm.FilterEq("did", user.Did), 326 + orm.FilterEq("domain", domain), 327 ) 328 if err != nil { 329 l.Error("failed to delete registration", "err", err) ··· 403 // get record from db first 404 registrations, err := db.GetRegistrations( 405 k.Db, 406 + orm.FilterEq("did", user.Did), 407 + orm.FilterEq("domain", domain), 408 ) 409 if err != nil { 410 l.Error("failed to get registration", "err", err) ··· 494 // Get updated registration to show 495 registrations, err = db.GetRegistrations( 496 k.Db, 497 + orm.FilterEq("did", user.Did), 498 + orm.FilterEq("domain", domain), 499 ) 500 if err != nil { 501 l.Error("failed to get registration", "err", err) ··· 530 531 registrations, err := db.GetRegistrations( 532 k.Db, 533 + orm.FilterEq("did", user.Did), 534 + orm.FilterEq("domain", domain), 535 + orm.FilterIsNot("registered", "null"), 536 ) 537 if err != nil { 538 l.Error("failed to get registration", "err", err) ··· 614 } 615 616 // success 617 + k.Pages.HxRedirect(w, fmt.Sprintf("/settings/knots/%s", domain)) 618 } 619 620 func (k *Knots) removeMember(w http.ResponseWriter, r *http.Request) { ··· 638 639 registrations, err := db.GetRegistrations( 640 k.Db, 641 + orm.FilterEq("did", user.Did), 642 + orm.FilterEq("domain", domain), 643 + orm.FilterIsNot("registered", "null"), 644 ) 645 if err != nil { 646 l.Error("failed to get registration", "err", err) ··· 663 memberId, err := k.IdResolver.ResolveIdent(r.Context(), member) 664 if err != nil { 665 l.Error("failed to resolve member identity to handle", "err", err) 666 k.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 667 return 668 }
+5 -4
appview/labels/labels.go
··· 16 "tangled.org/core/appview/oauth" 17 "tangled.org/core/appview/pages" 18 "tangled.org/core/appview/validator" 19 "tangled.org/core/rbac" 20 "tangled.org/core/tid" 21 ··· 88 repoAt := r.Form.Get("repo") 89 subjectUri := r.Form.Get("subject") 90 91 - repo, err := db.GetRepo(l.db, db.FilterEq("at_uri", repoAt)) 92 if err != nil { 93 fail("Failed to get repository.", err) 94 return 95 } 96 97 // find all the labels that this repo subscribes to 98 - repoLabels, err := db.GetRepoLabels(l.db, db.FilterEq("repo_at", repoAt)) 99 if err != nil { 100 fail("Failed to get labels for this repository.", err) 101 return ··· 106 labelAts = append(labelAts, rl.LabelAt.String()) 107 } 108 109 - actx, err := db.NewLabelApplicationCtx(l.db, db.FilterIn("at_uri", labelAts)) 110 if err != nil { 111 fail("Invalid form data.", err) 112 return 113 } 114 115 // calculate the start state by applying already known labels 116 - existingOps, err := db.GetLabelOps(l.db, db.FilterEq("subject", subjectUri)) 117 if err != nil { 118 fail("Invalid form data.", err) 119 return
··· 16 "tangled.org/core/appview/oauth" 17 "tangled.org/core/appview/pages" 18 "tangled.org/core/appview/validator" 19 + "tangled.org/core/orm" 20 "tangled.org/core/rbac" 21 "tangled.org/core/tid" 22 ··· 89 repoAt := r.Form.Get("repo") 90 subjectUri := r.Form.Get("subject") 91 92 + repo, err := db.GetRepo(l.db, orm.FilterEq("at_uri", repoAt)) 93 if err != nil { 94 fail("Failed to get repository.", err) 95 return 96 } 97 98 // find all the labels that this repo subscribes to 99 + repoLabels, err := db.GetRepoLabels(l.db, orm.FilterEq("repo_at", repoAt)) 100 if err != nil { 101 fail("Failed to get labels for this repository.", err) 102 return ··· 107 labelAts = append(labelAts, rl.LabelAt.String()) 108 } 109 110 + actx, err := db.NewLabelApplicationCtx(l.db, orm.FilterIn("at_uri", labelAts)) 111 if err != nil { 112 fail("Invalid form data.", err) 113 return 114 } 115 116 // calculate the start state by applying already known labels 117 + existingOps, err := db.GetLabelOps(l.db, orm.FilterEq("subject", subjectUri)) 118 if err != nil { 119 fail("Invalid form data.", err) 120 return
+67
appview/mentions/resolver.go
···
··· 1 + package mentions 2 + 3 + import ( 4 + "context" 5 + "log/slog" 6 + 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 + "tangled.org/core/appview/config" 9 + "tangled.org/core/appview/db" 10 + "tangled.org/core/appview/models" 11 + "tangled.org/core/appview/pages/markup" 12 + "tangled.org/core/idresolver" 13 + ) 14 + 15 + type Resolver struct { 16 + config *config.Config 17 + idResolver *idresolver.Resolver 18 + execer db.Execer 19 + logger *slog.Logger 20 + } 21 + 22 + func New( 23 + config *config.Config, 24 + idResolver *idresolver.Resolver, 25 + execer db.Execer, 26 + logger *slog.Logger, 27 + ) *Resolver { 28 + return &Resolver{ 29 + config, 30 + idResolver, 31 + execer, 32 + logger, 33 + } 34 + } 35 + 36 + func (r *Resolver) Resolve(ctx context.Context, source string) ([]syntax.DID, []syntax.ATURI) { 37 + l := r.logger.With("method", "Resolve") 38 + 39 + rawMentions, rawRefs := markup.FindReferences(r.config.Core.AppviewHost, source) 40 + l.Debug("found possible references", "mentions", rawMentions, "refs", rawRefs) 41 + 42 + idents := r.idResolver.ResolveIdents(ctx, rawMentions) 43 + var mentions []syntax.DID 44 + for _, ident := range idents { 45 + if ident != nil && !ident.Handle.IsInvalidHandle() { 46 + mentions = append(mentions, ident.DID) 47 + } 48 + } 49 + l.Debug("found mentions", "mentions", mentions) 50 + 51 + var resolvedRefs []models.ReferenceLink 52 + for _, rawRef := range rawRefs { 53 + ident, err := r.idResolver.ResolveIdent(ctx, rawRef.Handle) 54 + if err != nil || ident == nil || ident.Handle.IsInvalidHandle() { 55 + continue 56 + } 57 + rawRef.Handle = string(ident.DID) 58 + resolvedRefs = append(resolvedRefs, rawRef) 59 + } 60 + aturiRefs, err := db.ValidateReferenceLinks(r.execer, resolvedRefs) 61 + if err != nil { 62 + l.Error("failed running query", "err", err) 63 + } 64 + l.Debug("found references", "refs", aturiRefs) 65 + 66 + return mentions, aturiRefs 67 + }
+9 -4
appview/middleware/middleware.go
··· 18 "tangled.org/core/appview/pagination" 19 "tangled.org/core/appview/reporesolver" 20 "tangled.org/core/idresolver" 21 "tangled.org/core/rbac" 22 ) 23 ··· 164 ok, err := mw.enforcer.E.Enforce(actor.Did, f.Knot, f.DidSlashRepo(), requiredPerm) 165 if err != nil || !ok { 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()) 168 http.Error(w, "Forbiden", http.StatusUnauthorized) 169 return 170 } ··· 217 218 repo, err := db.GetRepo( 219 mw.db, 220 - db.FilterEq("did", id.DID.String()), 221 - db.FilterEq("name", repoName), 222 ) 223 if err != nil { 224 log.Println("failed to resolve repo", "err", err) 225 mw.pages.ErrorKnot404(w) 226 return 227 } ··· 239 f, err := mw.repoResolver.Resolve(r) 240 if err != nil { 241 log.Println("failed to fully resolve repo", err) 242 mw.pages.ErrorKnot404(w) 243 return 244 } ··· 287 f, err := mw.repoResolver.Resolve(r) 288 if err != nil { 289 log.Println("failed to fully resolve repo", err) 290 mw.pages.ErrorKnot404(w) 291 return 292 } ··· 323 f, err := mw.repoResolver.Resolve(r) 324 if err != nil { 325 log.Println("failed to fully resolve repo", err) 326 mw.pages.ErrorKnot404(w) 327 return 328 } 329 330 - fullName := f.OwnerHandle() + "/" + f.Name 331 332 if r.Header.Get("User-Agent") == "Go-http-client/1.1" { 333 if r.URL.Query().Get("go-get") == "1" {
··· 18 "tangled.org/core/appview/pagination" 19 "tangled.org/core/appview/reporesolver" 20 "tangled.org/core/idresolver" 21 + "tangled.org/core/orm" 22 "tangled.org/core/rbac" 23 ) 24 ··· 165 ok, err := mw.enforcer.E.Enforce(actor.Did, f.Knot, f.DidSlashRepo(), requiredPerm) 166 if err != nil || !ok { 167 // we need a logged in user 168 + log.Printf("%s does not have perms of a %s in repo %s", actor.Did, requiredPerm, f.DidSlashRepo()) 169 http.Error(w, "Forbiden", http.StatusUnauthorized) 170 return 171 } ··· 218 219 repo, err := db.GetRepo( 220 mw.db, 221 + orm.FilterEq("did", id.DID.String()), 222 + orm.FilterEq("name", repoName), 223 ) 224 if err != nil { 225 log.Println("failed to resolve repo", "err", err) 226 + w.WriteHeader(http.StatusNotFound) 227 mw.pages.ErrorKnot404(w) 228 return 229 } ··· 241 f, err := mw.repoResolver.Resolve(r) 242 if err != nil { 243 log.Println("failed to fully resolve repo", err) 244 + w.WriteHeader(http.StatusNotFound) 245 mw.pages.ErrorKnot404(w) 246 return 247 } ··· 290 f, err := mw.repoResolver.Resolve(r) 291 if err != nil { 292 log.Println("failed to fully resolve repo", err) 293 + w.WriteHeader(http.StatusNotFound) 294 mw.pages.ErrorKnot404(w) 295 return 296 } ··· 327 f, err := mw.repoResolver.Resolve(r) 328 if err != nil { 329 log.Println("failed to fully resolve repo", err) 330 + w.WriteHeader(http.StatusNotFound) 331 mw.pages.ErrorKnot404(w) 332 return 333 } 334 335 + fullName := reporesolver.GetBaseRepoPath(r, f) 336 337 if r.Header.Get("User-Agent") == "Go-http-client/1.1" { 338 if r.URL.Query().Get("go-get") == "1" {
+70 -34
appview/models/issue.go
··· 10 ) 11 12 type Issue struct { 13 - Id int64 14 - Did string 15 - Rkey string 16 - RepoAt syntax.ATURI 17 - IssueId int 18 - Created time.Time 19 - Edited *time.Time 20 - Deleted *time.Time 21 - Title string 22 - Body string 23 - Open bool 24 25 // optionally, populate this when querying for reverse mappings 26 // like comment counts, parent repo etc. ··· 34 } 35 36 func (i *Issue) AsRecord() tangled.RepoIssue { 37 return tangled.RepoIssue{ 38 - Repo: i.RepoAt.String(), 39 - Title: i.Title, 40 - Body: &i.Body, 41 - CreatedAt: i.Created.Format(time.RFC3339), 42 } 43 } 44 ··· 161 } 162 163 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 173 } 174 175 func (i *IssueComment) AtUri() syntax.ATURI { ··· 177 } 178 179 func (i *IssueComment) AsRecord() tangled.RepoIssueComment { 180 return tangled.RepoIssueComment{ 181 - Body: i.Body, 182 - Issue: i.IssueAt, 183 - CreatedAt: i.Created.Format(time.RFC3339), 184 - ReplyTo: i.ReplyTo, 185 } 186 } 187 ··· 205 return nil, err 206 } 207 208 comment := IssueComment{ 209 - Did: ownerDid, 210 - Rkey: rkey, 211 - Body: record.Body, 212 - IssueAt: record.Issue, 213 - ReplyTo: record.ReplyTo, 214 - Created: created, 215 } 216 217 return &comment, nil
··· 10 ) 11 12 type Issue struct { 13 + Id int64 14 + Did string 15 + Rkey string 16 + RepoAt syntax.ATURI 17 + IssueId int 18 + Created time.Time 19 + Edited *time.Time 20 + Deleted *time.Time 21 + Title string 22 + Body string 23 + Open bool 24 + Mentions []syntax.DID 25 + References []syntax.ATURI 26 27 // optionally, populate this when querying for reverse mappings 28 // like comment counts, parent repo etc. ··· 36 } 37 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 + } 47 return tangled.RepoIssue{ 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), 54 } 55 } 56 ··· 173 } 174 175 type IssueComment struct { 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 187 } 188 189 func (i *IssueComment) AtUri() syntax.ATURI { ··· 191 } 192 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 + } 202 return tangled.RepoIssueComment{ 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, 209 } 210 } 211 ··· 229 return nil, err 230 } 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 + 242 comment := IssueComment{ 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, 251 } 252 253 return &comment, nil
+3 -1
appview/models/profile.go
··· 111 } 112 113 type ByMonth struct { 114 RepoEvents []RepoEvent 115 IssueEvents IssueEvents 116 PullEvents PullEvents ··· 119 func (b ByMonth) IsEmpty() bool { 120 return len(b.RepoEvents) == 0 && 121 len(b.IssueEvents.Items) == 0 && 122 - len(b.PullEvents.Items) == 0 123 } 124 125 type IssueEvents struct {
··· 111 } 112 113 type ByMonth struct { 114 + Commits int 115 RepoEvents []RepoEvent 116 IssueEvents IssueEvents 117 PullEvents PullEvents ··· 120 func (b ByMonth) IsEmpty() bool { 121 return len(b.RepoEvents) == 0 && 122 len(b.IssueEvents.Items) == 0 && 123 + len(b.PullEvents.Items) == 0 && 124 + b.Commits == 0 125 } 126 127 type IssueEvents struct {
+42 -4
appview/models/pull.go
··· 66 TargetBranch string 67 State PullState 68 Submissions []*PullSubmission 69 70 // stacking 71 StackId string // nullable string ··· 81 Repo *Repo 82 } 83 84 func (p Pull) AsRecord() tangled.RepoPull { 85 var source *tangled.RepoPull_Source 86 if p.PullSource != nil { ··· 92 source.Repo = &s 93 } 94 } 95 96 record := tangled.RepoPull{ 97 - Title: p.Title, 98 - Body: &p.Body, 99 - CreatedAt: p.Created.Format(time.RFC3339), 100 Target: &tangled.RepoPull_Target{ 101 Repo: p.RepoAt.String(), 102 Branch: p.TargetBranch, 103 }, 104 - Patch: p.LatestPatch(), 105 Source: source, 106 } 107 return record ··· 148 Body string 149 150 // meta 151 Created time.Time 152 } 153 154 func (p *Pull) LastRoundNumber() int { 155 return len(p.Submissions) - 1
··· 66 TargetBranch string 67 State PullState 68 Submissions []*PullSubmission 69 + Mentions []syntax.DID 70 + References []syntax.ATURI 71 72 // stacking 73 StackId string // nullable string ··· 83 Repo *Repo 84 } 85 86 + // NOTE: This method does not include patch blob in returned atproto record 87 func (p Pull) AsRecord() tangled.RepoPull { 88 var source *tangled.RepoPull_Source 89 if p.PullSource != nil { ··· 95 source.Repo = &s 96 } 97 } 98 + mentions := make([]string, len(p.Mentions)) 99 + for i, did := range p.Mentions { 100 + mentions[i] = string(did) 101 + } 102 + references := make([]string, len(p.References)) 103 + for i, uri := range p.References { 104 + references[i] = string(uri) 105 + } 106 107 record := tangled.RepoPull{ 108 + Title: p.Title, 109 + Body: &p.Body, 110 + Mentions: mentions, 111 + References: references, 112 + CreatedAt: p.Created.Format(time.RFC3339), 113 Target: &tangled.RepoPull_Target{ 114 Repo: p.RepoAt.String(), 115 Branch: p.TargetBranch, 116 }, 117 Source: source, 118 } 119 return record ··· 160 Body string 161 162 // meta 163 + Mentions []syntax.DID 164 + References []syntax.ATURI 165 + 166 + // meta 167 Created time.Time 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 + // } 191 192 func (p *Pull) LastRoundNumber() int { 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 + }
+14 -5
appview/models/star.go
··· 7 ) 8 9 type Star struct { 10 - StarredByDid string 11 - RepoAt syntax.ATURI 12 - Created time.Time 13 - Rkey string 14 15 - // optionally, populate this when querying for reverse mappings 16 Repo *Repo 17 }
··· 7 ) 8 9 type Star struct { 10 + Did string 11 + RepoAt syntax.ATURI 12 + Created time.Time 13 + Rkey string 14 + } 15 16 + // RepoStar is used for reverse mapping to repos 17 + type RepoStar struct { 18 + Star 19 Repo *Repo 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 Edited *time.Time 23 } 24 25 - func (s *String) StringAt() syntax.ATURI { 26 return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", s.Did, tangled.StringNSID, s.Rkey)) 27 } 28
··· 22 Edited *time.Time 23 } 24 25 + func (s *String) AtUri() syntax.ATURI { 26 return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", s.Did, tangled.StringNSID, s.Rkey)) 27 } 28
+1 -1
appview/models/timeline.go
··· 5 type TimelineEvent struct { 6 *Repo 7 *Follow 8 - *Star 9 10 EventAt time.Time 11
··· 5 type TimelineEvent struct { 6 *Repo 7 *Follow 8 + *RepoStar 9 10 EventAt time.Time 11
+5 -4
appview/notifications/notifications.go
··· 11 "tangled.org/core/appview/oauth" 12 "tangled.org/core/appview/pages" 13 "tangled.org/core/appview/pagination" 14 ) 15 16 type Notifications struct { ··· 53 54 total, err := db.CountNotifications( 55 n.db, 56 - db.FilterEq("recipient_did", user.Did), 57 ) 58 if err != nil { 59 l.Error("failed to get total notifications", "err", err) ··· 64 notifications, err := db.GetNotificationsWithEntities( 65 n.db, 66 page, 67 - db.FilterEq("recipient_did", user.Did), 68 ) 69 if err != nil { 70 l.Error("failed to get notifications", "err", err) ··· 96 97 count, err := db.CountNotifications( 98 n.db, 99 - db.FilterEq("recipient_did", user.Did), 100 - db.FilterEq("read", 0), 101 ) 102 if err != nil { 103 http.Error(w, "Failed to get unread count", http.StatusInternalServerError)
··· 11 "tangled.org/core/appview/oauth" 12 "tangled.org/core/appview/pages" 13 "tangled.org/core/appview/pagination" 14 + "tangled.org/core/orm" 15 ) 16 17 type Notifications struct { ··· 54 55 total, err := db.CountNotifications( 56 n.db, 57 + orm.FilterEq("recipient_did", user.Did), 58 ) 59 if err != nil { 60 l.Error("failed to get total notifications", "err", err) ··· 65 notifications, err := db.GetNotificationsWithEntities( 66 n.db, 67 page, 68 + orm.FilterEq("recipient_did", user.Did), 69 ) 70 if err != nil { 71 l.Error("failed to get notifications", "err", err) ··· 97 98 count, err := db.CountNotifications( 99 n.db, 100 + orm.FilterEq("recipient_did", user.Did), 101 + orm.FilterEq("read", 0), 102 ) 103 if err != nil { 104 http.Error(w, "Failed to get unread count", http.StatusInternalServerError)
+83 -67
appview/notify/db/db.go
··· 3 import ( 4 "context" 5 "log" 6 - "maps" 7 "slices" 8 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 "tangled.org/core/appview/db" 11 "tangled.org/core/appview/models" 12 "tangled.org/core/appview/notify" 13 "tangled.org/core/idresolver" 14 ) 15 16 const ( 17 - maxMentions = 5 18 ) 19 20 type databaseNotifier struct { ··· 36 } 37 38 func (n *databaseNotifier) NewStar(ctx context.Context, star *models.Star) { 39 var err error 40 - repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(star.RepoAt))) 41 if err != nil { 42 log.Printf("NewStar: failed to get repos: %v", err) 43 return 44 } 45 46 - actorDid := syntax.DID(star.StarredByDid) 47 - recipients := []syntax.DID{syntax.DID(repo.Did)} 48 eventType := models.NotificationTypeRepoStarred 49 entityType := "repo" 50 entityId := star.RepoAt.String() ··· 69 } 70 71 func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) { 72 - 73 - // build the recipients list 74 - // - owner of the repo 75 - // - collaborators in the repo 76 - var recipients []syntax.DID 77 - recipients = append(recipients, syntax.DID(issue.Repo.Did)) 78 - collaborators, err := db.GetCollaborators(n.db, db.FilterEq("repo_at", issue.Repo.RepoAt())) 79 if err != nil { 80 log.Printf("failed to fetch collaborators: %v", err) 81 return 82 } 83 for _, c := range collaborators { 84 - recipients = append(recipients, c.SubjectDid) 85 } 86 87 actorDid := syntax.DID(issue.Did) ··· 103 ) 104 n.notifyEvent( 105 actorDid, 106 - mentions, 107 models.NotificationTypeUserMentioned, 108 entityType, 109 entityId, ··· 114 } 115 116 func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { 117 - issues, err := db.GetIssues(n.db, db.FilterEq("at_uri", comment.IssueAt)) 118 if err != nil { 119 log.Printf("NewIssueComment: failed to get issues: %v", err) 120 return ··· 125 } 126 issue := issues[0] 127 128 - var recipients []syntax.DID 129 - recipients = append(recipients, syntax.DID(issue.Repo.Did)) 130 131 if comment.IsReply() { 132 // if this comment is a reply, then notify everybody in that thread 133 parentAtUri := *comment.ReplyTo 134 - allThreads := issue.CommentList() 135 136 // find the parent thread, and add all DIDs from here to the recipient list 137 - for _, t := range allThreads { 138 if t.Self.AtUri().String() == parentAtUri { 139 - recipients = append(recipients, t.Participants()...) 140 } 141 } 142 } else { 143 // not a reply, notify just the issue author 144 - recipients = append(recipients, syntax.DID(issue.Did)) 145 } 146 147 actorDid := syntax.DID(comment.Did) ··· 163 ) 164 n.notifyEvent( 165 actorDid, 166 - mentions, 167 models.NotificationTypeUserMentioned, 168 entityType, 169 entityId, ··· 179 180 func (n *databaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) { 181 actorDid := syntax.DID(follow.UserDid) 182 - recipients := []syntax.DID{syntax.DID(follow.SubjectDid)} 183 eventType := models.NotificationTypeFollowed 184 entityType := "follow" 185 entityId := follow.UserDid ··· 202 } 203 204 func (n *databaseNotifier) NewPull(ctx context.Context, pull *models.Pull) { 205 - repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt))) 206 if err != nil { 207 log.Printf("NewPull: failed to get repos: %v", err) 208 return 209 } 210 - 211 - // build the recipients list 212 - // - owner of the repo 213 - // - collaborators in the repo 214 - var recipients []syntax.DID 215 - recipients = append(recipients, syntax.DID(repo.Did)) 216 - collaborators, err := db.GetCollaborators(n.db, db.FilterEq("repo_at", repo.RepoAt())) 217 if err != nil { 218 log.Printf("failed to fetch collaborators: %v", err) 219 return 220 } 221 for _, c := range collaborators { 222 - recipients = append(recipients, c.SubjectDid) 223 } 224 225 actorDid := syntax.DID(pull.OwnerDid) ··· 253 return 254 } 255 256 - repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", comment.RepoAt)) 257 if err != nil { 258 log.Printf("NewPullComment: failed to get repos: %v", err) 259 return ··· 262 // build up the recipients list: 263 // - repo owner 264 // - all pull participants 265 - var recipients []syntax.DID 266 - recipients = append(recipients, syntax.DID(repo.Did)) 267 for _, p := range pull.Participants() { 268 - recipients = append(recipients, syntax.DID(p)) 269 } 270 271 actorDid := syntax.DID(comment.OwnerDid) ··· 289 ) 290 n.notifyEvent( 291 actorDid, 292 - mentions, 293 models.NotificationTypeUserMentioned, 294 entityType, 295 entityId, ··· 316 } 317 318 func (n *databaseNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) { 319 - // build up the recipients list: 320 - // - repo owner 321 - // - repo collaborators 322 - // - all issue participants 323 - var recipients []syntax.DID 324 - recipients = append(recipients, syntax.DID(issue.Repo.Did)) 325 - collaborators, err := db.GetCollaborators(n.db, db.FilterEq("repo_at", issue.Repo.RepoAt())) 326 if err != nil { 327 log.Printf("failed to fetch collaborators: %v", err) 328 return 329 } 330 for _, c := range collaborators { 331 - recipients = append(recipients, c.SubjectDid) 332 } 333 for _, p := range issue.Participants() { 334 - recipients = append(recipients, syntax.DID(p)) 335 } 336 337 entityType := "pull" ··· 361 362 func (n *databaseNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) { 363 // Get repo details 364 - repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt))) 365 if err != nil { 366 log.Printf("NewPullState: failed to get repos: %v", err) 367 return 368 } 369 370 - // build up the recipients list: 371 - // - repo owner 372 - // - all pull participants 373 - var recipients []syntax.DID 374 - recipients = append(recipients, syntax.DID(repo.Did)) 375 - collaborators, err := db.GetCollaborators(n.db, db.FilterEq("repo_at", repo.RepoAt())) 376 if err != nil { 377 log.Printf("failed to fetch collaborators: %v", err) 378 return 379 } 380 for _, c := range collaborators { 381 - recipients = append(recipients, c.SubjectDid) 382 } 383 for _, p := range pull.Participants() { 384 - recipients = append(recipients, syntax.DID(p)) 385 } 386 387 entityType := "pull" ··· 417 418 func (n *databaseNotifier) notifyEvent( 419 actorDid syntax.DID, 420 - recipients []syntax.DID, 421 eventType models.NotificationType, 422 entityType string, 423 entityId string, ··· 425 issueId *int64, 426 pullId *int64, 427 ) { 428 - if eventType == models.NotificationTypeUserMentioned && len(recipients) > maxMentions { 429 - recipients = recipients[:maxMentions] 430 - } 431 - recipientSet := make(map[syntax.DID]struct{}) 432 - for _, did := range recipients { 433 - // everybody except actor themselves 434 - if did != actorDid { 435 - recipientSet[did] = struct{}{} 436 - } 437 } 438 439 prefMap, err := db.GetNotificationPreferences( 440 n.db, 441 - db.FilterIn("user_did", slices.Collect(maps.Keys(recipientSet))), 442 ) 443 if err != nil { 444 // failed to get prefs for users ··· 454 defer tx.Rollback() 455 456 // filter based on preferences 457 - for recipientDid := range recipientSet { 458 prefs, ok := prefMap[recipientDid] 459 if !ok { 460 prefs = models.DefaultNotificationPreferences(recipientDid)
··· 3 import ( 4 "context" 5 "log" 6 "slices" 7 8 "github.com/bluesky-social/indigo/atproto/syntax" 9 + "tangled.org/core/api/tangled" 10 "tangled.org/core/appview/db" 11 "tangled.org/core/appview/models" 12 "tangled.org/core/appview/notify" 13 "tangled.org/core/idresolver" 14 + "tangled.org/core/orm" 15 + "tangled.org/core/sets" 16 ) 17 18 const ( 19 + maxMentions = 8 20 ) 21 22 type databaseNotifier struct { ··· 38 } 39 40 func (n *databaseNotifier) NewStar(ctx context.Context, star *models.Star) { 41 + if star.RepoAt.Collection().String() != tangled.RepoNSID { 42 + // skip string stars for now 43 + return 44 + } 45 var err error 46 + repo, err := db.GetRepo(n.db, orm.FilterEq("at_uri", string(star.RepoAt))) 47 if err != nil { 48 log.Printf("NewStar: failed to get repos: %v", err) 49 return 50 } 51 52 + actorDid := syntax.DID(star.Did) 53 + recipients := sets.Singleton(syntax.DID(repo.Did)) 54 eventType := models.NotificationTypeRepoStarred 55 entityType := "repo" 56 entityId := star.RepoAt.String() ··· 75 } 76 77 func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) { 78 + collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_at", issue.Repo.RepoAt())) 79 if err != nil { 80 log.Printf("failed to fetch collaborators: %v", err) 81 return 82 } 83 + 84 + // build the recipients list 85 + // - owner of the repo 86 + // - collaborators in the repo 87 + // - remove users already mentioned 88 + recipients := sets.Singleton(syntax.DID(issue.Repo.Did)) 89 for _, c := range collaborators { 90 + recipients.Insert(c.SubjectDid) 91 + } 92 + for _, m := range mentions { 93 + recipients.Remove(m) 94 } 95 96 actorDid := syntax.DID(issue.Did) ··· 112 ) 113 n.notifyEvent( 114 actorDid, 115 + sets.Collect(slices.Values(mentions)), 116 models.NotificationTypeUserMentioned, 117 entityType, 118 entityId, ··· 123 } 124 125 func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { 126 + issues, err := db.GetIssues(n.db, orm.FilterEq("at_uri", comment.IssueAt)) 127 if err != nil { 128 log.Printf("NewIssueComment: failed to get issues: %v", err) 129 return ··· 134 } 135 issue := issues[0] 136 137 + // built the recipients list: 138 + // - the owner of the repo 139 + // - | if the comment is a reply -> everybody on that thread 140 + // | if the comment is a top level -> just the issue owner 141 + // - remove mentioned users from the recipients list 142 + recipients := sets.Singleton(syntax.DID(issue.Repo.Did)) 143 144 if comment.IsReply() { 145 // if this comment is a reply, then notify everybody in that thread 146 parentAtUri := *comment.ReplyTo 147 148 // find the parent thread, and add all DIDs from here to the recipient list 149 + for _, t := range issue.CommentList() { 150 if t.Self.AtUri().String() == parentAtUri { 151 + for _, p := range t.Participants() { 152 + recipients.Insert(p) 153 + } 154 } 155 } 156 } else { 157 // not a reply, notify just the issue author 158 + recipients.Insert(syntax.DID(issue.Did)) 159 + } 160 + 161 + for _, m := range mentions { 162 + recipients.Remove(m) 163 } 164 165 actorDid := syntax.DID(comment.Did) ··· 181 ) 182 n.notifyEvent( 183 actorDid, 184 + sets.Collect(slices.Values(mentions)), 185 models.NotificationTypeUserMentioned, 186 entityType, 187 entityId, ··· 197 198 func (n *databaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) { 199 actorDid := syntax.DID(follow.UserDid) 200 + recipients := sets.Singleton(syntax.DID(follow.SubjectDid)) 201 eventType := models.NotificationTypeFollowed 202 entityType := "follow" 203 entityId := follow.UserDid ··· 220 } 221 222 func (n *databaseNotifier) NewPull(ctx context.Context, pull *models.Pull) { 223 + repo, err := db.GetRepo(n.db, orm.FilterEq("at_uri", string(pull.RepoAt))) 224 if err != nil { 225 log.Printf("NewPull: failed to get repos: %v", err) 226 return 227 } 228 + collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_at", repo.RepoAt())) 229 if err != nil { 230 log.Printf("failed to fetch collaborators: %v", err) 231 return 232 } 233 + 234 + // build the recipients list 235 + // - owner of the repo 236 + // - collaborators in the repo 237 + recipients := sets.Singleton(syntax.DID(repo.Did)) 238 for _, c := range collaborators { 239 + recipients.Insert(c.SubjectDid) 240 } 241 242 actorDid := syntax.DID(pull.OwnerDid) ··· 270 return 271 } 272 273 + repo, err := db.GetRepo(n.db, orm.FilterEq("at_uri", comment.RepoAt)) 274 if err != nil { 275 log.Printf("NewPullComment: failed to get repos: %v", err) 276 return ··· 279 // build up the recipients list: 280 // - repo owner 281 // - all pull participants 282 + // - remove those already mentioned 283 + recipients := sets.Singleton(syntax.DID(repo.Did)) 284 for _, p := range pull.Participants() { 285 + recipients.Insert(syntax.DID(p)) 286 + } 287 + for _, m := range mentions { 288 + recipients.Remove(m) 289 } 290 291 actorDid := syntax.DID(comment.OwnerDid) ··· 309 ) 310 n.notifyEvent( 311 actorDid, 312 + sets.Collect(slices.Values(mentions)), 313 models.NotificationTypeUserMentioned, 314 entityType, 315 entityId, ··· 336 } 337 338 func (n *databaseNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) { 339 + collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_at", issue.Repo.RepoAt())) 340 if err != nil { 341 log.Printf("failed to fetch collaborators: %v", err) 342 return 343 } 344 + 345 + // build up the recipients list: 346 + // - repo owner 347 + // - repo collaborators 348 + // - all issue participants 349 + recipients := sets.Singleton(syntax.DID(issue.Repo.Did)) 350 for _, c := range collaborators { 351 + recipients.Insert(c.SubjectDid) 352 } 353 for _, p := range issue.Participants() { 354 + recipients.Insert(syntax.DID(p)) 355 } 356 357 entityType := "pull" ··· 381 382 func (n *databaseNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) { 383 // Get repo details 384 + repo, err := db.GetRepo(n.db, orm.FilterEq("at_uri", string(pull.RepoAt))) 385 if err != nil { 386 log.Printf("NewPullState: failed to get repos: %v", err) 387 return 388 } 389 390 + collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_at", repo.RepoAt())) 391 if err != nil { 392 log.Printf("failed to fetch collaborators: %v", err) 393 return 394 } 395 + 396 + // build up the recipients list: 397 + // - repo owner 398 + // - all pull participants 399 + recipients := sets.Singleton(syntax.DID(repo.Did)) 400 for _, c := range collaborators { 401 + recipients.Insert(c.SubjectDid) 402 } 403 for _, p := range pull.Participants() { 404 + recipients.Insert(syntax.DID(p)) 405 } 406 407 entityType := "pull" ··· 437 438 func (n *databaseNotifier) notifyEvent( 439 actorDid syntax.DID, 440 + recipients sets.Set[syntax.DID], 441 eventType models.NotificationType, 442 entityType string, 443 entityId string, ··· 445 issueId *int64, 446 pullId *int64, 447 ) { 448 + // if the user is attempting to mention >maxMentions users, this is probably spam, do not mention anybody 449 + if eventType == models.NotificationTypeUserMentioned && recipients.Len() > maxMentions { 450 + return 451 } 452 453 + recipients.Remove(actorDid) 454 + 455 prefMap, err := db.GetNotificationPreferences( 456 n.db, 457 + orm.FilterIn("user_did", slices.Collect(recipients.All())), 458 ) 459 if err != nil { 460 // failed to get prefs for users ··· 470 defer tx.Rollback() 471 472 // filter based on preferences 473 + for recipientDid := range recipients.All() { 474 prefs, ok := prefMap[recipientDid] 475 if !ok { 476 prefs = models.DefaultNotificationPreferences(recipientDid)
-1
appview/notify/merged_notifier.go
··· 39 v.Call(in) 40 }(n) 41 } 42 - wg.Wait() 43 } 44 45 func (m *mergedNotifier) NewRepo(ctx context.Context, repo *models.Repo) {
··· 39 v.Call(in) 40 }(n) 41 } 42 } 43 44 func (m *mergedNotifier) NewRepo(ctx context.Context, repo *models.Repo) {
+2 -2
appview/notify/posthog/notifier.go
··· 37 38 func (n *posthogNotifier) NewStar(ctx context.Context, star *models.Star) { 39 err := n.client.Enqueue(posthog.Capture{ 40 - DistinctId: star.StarredByDid, 41 Event: "star", 42 Properties: posthog.Properties{"repo_at": star.RepoAt.String()}, 43 }) ··· 48 49 func (n *posthogNotifier) DeleteStar(ctx context.Context, star *models.Star) { 50 err := n.client.Enqueue(posthog.Capture{ 51 - DistinctId: star.StarredByDid, 52 Event: "unstar", 53 Properties: posthog.Properties{"repo_at": star.RepoAt.String()}, 54 })
··· 37 38 func (n *posthogNotifier) NewStar(ctx context.Context, star *models.Star) { 39 err := n.client.Enqueue(posthog.Capture{ 40 + DistinctId: star.Did, 41 Event: "star", 42 Properties: posthog.Properties{"repo_at": star.RepoAt.String()}, 43 }) ··· 48 49 func (n *posthogNotifier) DeleteStar(ctx context.Context, star *models.Star) { 50 err := n.client.Enqueue(posthog.Capture{ 51 + DistinctId: star.Did, 52 Event: "unstar", 53 Properties: posthog.Properties{"repo_at": star.RepoAt.String()}, 54 })
+3 -2
appview/oauth/handler.go
··· 16 "tangled.org/core/api/tangled" 17 "tangled.org/core/appview/db" 18 "tangled.org/core/consts" 19 "tangled.org/core/tid" 20 ) 21 ··· 97 // and create an sh.tangled.spindle.member record with that 98 spindleMembers, err := db.GetSpindleMembers( 99 o.Db, 100 - db.FilterEq("instance", "spindle.tangled.sh"), 101 - db.FilterEq("subject", did), 102 ) 103 if err != nil { 104 l.Error("failed to get spindle members", "err", err)
··· 16 "tangled.org/core/api/tangled" 17 "tangled.org/core/appview/db" 18 "tangled.org/core/consts" 19 + "tangled.org/core/orm" 20 "tangled.org/core/tid" 21 ) 22 ··· 98 // and create an sh.tangled.spindle.member record with that 99 spindleMembers, err := db.GetSpindleMembers( 100 o.Db, 101 + orm.FilterEq("instance", "spindle.tangled.sh"), 102 + orm.FilterEq("subject", did), 103 ) 104 if err != nil { 105 l.Error("failed to get spindle members", "err", err)
+15 -2
appview/oauth/oauth.go
··· 202 exp int64 203 lxm string 204 dev bool 205 } 206 207 type ServiceClientOpt func(*ServiceClientOpts) 208 209 func WithService(service string) ServiceClientOpt { 210 return func(s *ServiceClientOpts) { ··· 233 } 234 } 235 236 func (s *ServiceClientOpts) Audience() string { 237 return fmt.Sprintf("did:web:%s", s.service) 238 } ··· 247 } 248 249 func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*xrpc.Client, error) { 250 - opts := ServiceClientOpts{} 251 for _, o := range os { 252 o(&opts) 253 } ··· 274 }, 275 Host: opts.Host(), 276 Client: &http.Client{ 277 - Timeout: time.Second * 5, 278 }, 279 }, nil 280 }
··· 202 exp int64 203 lxm string 204 dev bool 205 + timeout time.Duration 206 } 207 208 type ServiceClientOpt func(*ServiceClientOpts) 209 + 210 + func DefaultServiceClientOpts() ServiceClientOpts { 211 + return ServiceClientOpts{ 212 + timeout: time.Second * 5, 213 + } 214 + } 215 216 func WithService(service string) ServiceClientOpt { 217 return func(s *ServiceClientOpts) { ··· 240 } 241 } 242 243 + func WithTimeout(timeout time.Duration) ServiceClientOpt { 244 + return func(s *ServiceClientOpts) { 245 + s.timeout = timeout 246 + } 247 + } 248 + 249 func (s *ServiceClientOpts) Audience() string { 250 return fmt.Sprintf("did:web:%s", s.service) 251 } ··· 260 } 261 262 func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*xrpc.Client, error) { 263 + opts := DefaultServiceClientOpts() 264 for _, o := range os { 265 o(&opts) 266 } ··· 287 }, 288 Host: opts.Host(), 289 Client: &http.Client{ 290 + Timeout: opts.timeout, 291 }, 292 }, nil 293 }
+9 -9
appview/ogcard/card.go
··· 334 return nil 335 } 336 337 - func (c *Card) DrawDollySilhouette(x, y, size int, iconColor color.Color) error { 338 tpl, err := template.New("dolly"). 339 - ParseFS(pages.Files, "templates/fragments/dolly/silhouette.html") 340 if err != nil { 341 - return fmt.Errorf("failed to read dolly silhouette template: %w", err) 342 } 343 344 var svgData bytes.Buffer 345 - if err = tpl.ExecuteTemplate(&svgData, "fragments/dolly/silhouette", nil); err != nil { 346 - return fmt.Errorf("failed to execute dolly silhouette template: %w", err) 347 } 348 349 icon, err := BuildSVGIconFromData(svgData.Bytes(), iconColor) ··· 453 454 // Handle SVG separately 455 if contentType == "image/svg+xml" || strings.HasSuffix(url, ".svg") { 456 - return c.convertSVGToPNG(bodyBytes) 457 } 458 459 // Support content types are in-sync with the allowed custom avatar file types ··· 493 } 494 495 // convertSVGToPNG converts SVG data to a PNG image 496 - func (c *Card) convertSVGToPNG(svgData []byte) (image.Image, bool) { 497 // Parse the SVG 498 icon, err := oksvg.ReadIconStream(bytes.NewReader(svgData)) 499 if err != nil { ··· 547 draw.CatmullRom.Scale(scaledImg, scaledImg.Bounds(), img, srcBounds, draw.Src, nil) 548 549 // Draw the image with circular clipping 550 - for cy := 0; cy < size; cy++ { 551 - for cx := 0; cx < size; cx++ { 552 // Calculate distance from center 553 dx := float64(cx - center) 554 dy := float64(cy - center)
··· 334 return nil 335 } 336 337 + func (c *Card) DrawDolly(x, y, size int, iconColor color.Color) error { 338 tpl, err := template.New("dolly"). 339 + ParseFS(pages.Files, "templates/fragments/dolly/logo.html") 340 if err != nil { 341 + return fmt.Errorf("failed to read dolly template: %w", err) 342 } 343 344 var svgData bytes.Buffer 345 + if err = tpl.ExecuteTemplate(&svgData, "fragments/dolly/logo", nil); err != nil { 346 + return fmt.Errorf("failed to execute dolly template: %w", err) 347 } 348 349 icon, err := BuildSVGIconFromData(svgData.Bytes(), iconColor) ··· 453 454 // Handle SVG separately 455 if contentType == "image/svg+xml" || strings.HasSuffix(url, ".svg") { 456 + return convertSVGToPNG(bodyBytes) 457 } 458 459 // Support content types are in-sync with the allowed custom avatar file types ··· 493 } 494 495 // convertSVGToPNG converts SVG data to a PNG image 496 + func convertSVGToPNG(svgData []byte) (image.Image, bool) { 497 // Parse the SVG 498 icon, err := oksvg.ReadIconStream(bytes.NewReader(svgData)) 499 if err != nil { ··· 547 draw.CatmullRom.Scale(scaledImg, scaledImg.Bounds(), img, srcBounds, draw.Src, nil) 548 549 // Draw the image with circular clipping 550 + for cy := range size { 551 + for cx := range size { 552 // Calculate distance from center 553 dx := float64(cx - center) 554 dy := float64(cy - center)
+44 -10
appview/pages/funcmap.go
··· 22 chromahtml "github.com/alecthomas/chroma/v2/formatters/html" 23 "github.com/alecthomas/chroma/v2/lexers" 24 "github.com/alecthomas/chroma/v2/styles" 25 - "github.com/bluesky-social/indigo/atproto/syntax" 26 "github.com/dustin/go-humanize" 27 "github.com/go-enry/go-enry/v2" 28 "github.com/yuin/goldmark" 29 "tangled.org/core/appview/filetree" 30 "tangled.org/core/appview/pages/markup" 31 "tangled.org/core/crypto" 32 ) ··· 71 } 72 73 return identity.Handle.String() 74 }, 75 "truncateAt30": func(s string) string { 76 if len(s) <= 30 { ··· 100 "sub": func(a, b int) int { 101 return a - b 102 }, 103 "f64": func(a int) float64 { 104 return float64(a) 105 }, ··· 132 133 return b 134 }, 135 - "didOrHandle": func(did, handle string) string { 136 - if handle != "" && handle != syntax.HandleInvalid.String() { 137 - return handle 138 - } else { 139 - return did 140 - } 141 - }, 142 "assoc": func(values ...string) ([][]string, error) { 143 if len(values)%2 != 0 { 144 return nil, fmt.Errorf("invalid assoc call, must have an even number of arguments") ··· 149 } 150 return pairs, nil 151 }, 152 - "append": func(s []string, values ...string) []string { 153 s = append(s, values...) 154 return s 155 }, ··· 248 }, 249 "description": func(text string) template.HTML { 250 p.rctx.RendererType = markup.RendererTypeDefault 251 - htmlString := p.rctx.RenderMarkdownWith(text, goldmark.New()) 252 sanitized := p.rctx.SanitizeDescription(htmlString) 253 return template.HTML(sanitized) 254 }, ··· 370 } 371 } 372 373 func (p *Pages) AvatarUrl(handle, size string) string { 374 handle = strings.TrimPrefix(handle, "@") 375 376 secret := p.avatar.SharedSecret 377 h := hmac.New(sha256.New, []byte(secret))
··· 22 chromahtml "github.com/alecthomas/chroma/v2/formatters/html" 23 "github.com/alecthomas/chroma/v2/lexers" 24 "github.com/alecthomas/chroma/v2/styles" 25 "github.com/dustin/go-humanize" 26 "github.com/go-enry/go-enry/v2" 27 "github.com/yuin/goldmark" 28 + emoji "github.com/yuin/goldmark-emoji" 29 "tangled.org/core/appview/filetree" 30 + "tangled.org/core/appview/models" 31 "tangled.org/core/appview/pages/markup" 32 "tangled.org/core/crypto" 33 ) ··· 72 } 73 74 return identity.Handle.String() 75 + }, 76 + "ownerSlashRepo": func(repo *models.Repo) string { 77 + ownerId, err := p.resolver.ResolveIdent(context.Background(), repo.Did) 78 + if err != nil { 79 + return repo.DidSlashRepo() 80 + } 81 + handle := ownerId.Handle 82 + if handle != "" && !handle.IsInvalidHandle() { 83 + return string(handle) + "/" + repo.Name 84 + } 85 + return repo.DidSlashRepo() 86 }, 87 "truncateAt30": func(s string) string { 88 if len(s) <= 30 { ··· 112 "sub": func(a, b int) int { 113 return a - b 114 }, 115 + "mul": func(a, b int) int { 116 + return a * b 117 + }, 118 + "div": func(a, b int) int { 119 + return a / b 120 + }, 121 + "mod": func(a, b int) int { 122 + return a % b 123 + }, 124 "f64": func(a int) float64 { 125 return float64(a) 126 }, ··· 153 154 return b 155 }, 156 "assoc": func(values ...string) ([][]string, error) { 157 if len(values)%2 != 0 { 158 return nil, fmt.Errorf("invalid assoc call, must have an even number of arguments") ··· 163 } 164 return pairs, nil 165 }, 166 + "append": func(s []any, values ...any) []any { 167 s = append(s, values...) 168 return s 169 }, ··· 262 }, 263 "description": func(text string) template.HTML { 264 p.rctx.RendererType = markup.RendererTypeDefault 265 + htmlString := p.rctx.RenderMarkdownWith(text, goldmark.New( 266 + goldmark.WithExtensions( 267 + emoji.Emoji, 268 + ), 269 + )) 270 sanitized := p.rctx.SanitizeDescription(htmlString) 271 return template.HTML(sanitized) 272 }, ··· 388 } 389 } 390 391 + func (p *Pages) resolveDid(did string) string { 392 + identity, err := p.resolver.ResolveIdent(context.Background(), did) 393 + 394 + if err != nil { 395 + return did 396 + } 397 + 398 + if identity.Handle.IsInvalidHandle() { 399 + return "handle.invalid" 400 + } 401 + 402 + return identity.Handle.String() 403 + } 404 + 405 func (p *Pages) AvatarUrl(handle, size string) string { 406 handle = strings.TrimPrefix(handle, "@") 407 + 408 + handle = p.resolveDid(handle) 409 410 secret := p.avatar.SharedSecret 411 h := hmac.New(sha256.New, []byte(secret))
+12 -2
appview/pages/markup/extension/atlink.go
··· 35 return KindAt 36 } 37 38 - var atRegexp = regexp.MustCompile(`(^|\s|\()(@)([a-zA-Z0-9.-]+)(\b)`) 39 40 type atParser struct{} 41 ··· 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{} ··· 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">`) 93 } else {
··· 35 return KindAt 36 } 37 38 + var atRegexp = regexp.MustCompile(`(^|\s|\()(@)([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\b)`) 39 + var markdownLinkRegexp = regexp.MustCompile(`(?ms)\[.*\]\(.*\)`) 40 41 type atParser struct{} 42 ··· 56 if m == nil { 57 return nil 58 } 59 + 60 + // Check for all links in the markdown to see if the handle found is inside one 61 + linksIndexes := markdownLinkRegexp.FindAllIndex(block.Source(), -1) 62 + for _, linkMatch := range linksIndexes { 63 + if linkMatch[0] < segment.Start && segment.Start < linkMatch[1] { 64 + return nil 65 + } 66 + } 67 + 68 atSegment := text.NewSegment(segment.Start, segment.Start+m[1]) 69 block.Advance(m[1]) 70 node := &AtNode{} ··· 97 98 func (r *atHtmlRenderer) renderAt(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) { 99 if entering { 100 + w.WriteString(`<a href="/`) 101 w.WriteString(n.(*AtNode).Handle) 102 w.WriteString(`" class="mention">`) 103 } else {
+2 -26
appview/pages/markup/markdown.go
··· 12 13 chromahtml "github.com/alecthomas/chroma/v2/formatters/html" 14 "github.com/alecthomas/chroma/v2/styles" 15 - treeblood "github.com/wyatt915/goldmark-treeblood" 16 "github.com/yuin/goldmark" 17 highlighting "github.com/yuin/goldmark-highlighting/v2" 18 "github.com/yuin/goldmark/ast" 19 "github.com/yuin/goldmark/extension" ··· 65 extension.NewFootnote( 66 extension.WithFootnoteIDPrefix([]byte("footnote")), 67 ), 68 - treeblood.MathML(), 69 callout.CalloutExtention, 70 textension.AtExt, 71 ), 72 goldmark.WithParserOptions( 73 parser.WithAutoHeadingID(), ··· 302 } 303 304 return path.Join(rctx.CurrentDir, dst) 305 - } 306 - 307 - // FindUserMentions returns Set of user handles from given markup soruce. 308 - // It doesn't guarntee unique DIDs 309 - func FindUserMentions(source string) []string { 310 - var ( 311 - mentions []string 312 - mentionsSet = make(map[string]struct{}) 313 - md = NewMarkdown() 314 - sourceBytes = []byte(source) 315 - root = md.Parser().Parse(text.NewReader(sourceBytes)) 316 - ) 317 - ast.Walk(root, func(n ast.Node, entering bool) (ast.WalkStatus, error) { 318 - if entering && n.Kind() == textension.KindAt { 319 - handle := n.(*textension.AtNode).Handle 320 - mentionsSet[handle] = struct{}{} 321 - return ast.WalkSkipChildren, nil 322 - } 323 - return ast.WalkContinue, nil 324 - }) 325 - for handle := range mentionsSet { 326 - mentions = append(mentions, handle) 327 - } 328 - return mentions 329 } 330 331 func isAbsoluteUrl(link string) bool {
··· 12 13 chromahtml "github.com/alecthomas/chroma/v2/formatters/html" 14 "github.com/alecthomas/chroma/v2/styles" 15 "github.com/yuin/goldmark" 16 + "github.com/yuin/goldmark-emoji" 17 highlighting "github.com/yuin/goldmark-highlighting/v2" 18 "github.com/yuin/goldmark/ast" 19 "github.com/yuin/goldmark/extension" ··· 65 extension.NewFootnote( 66 extension.WithFootnoteIDPrefix([]byte("footnote")), 67 ), 68 callout.CalloutExtention, 69 textension.AtExt, 70 + emoji.Emoji, 71 ), 72 goldmark.WithParserOptions( 73 parser.WithAutoHeadingID(), ··· 302 } 303 304 return path.Join(rctx.CurrentDir, dst) 305 } 306 307 func isAbsoluteUrl(link string) bool {
+121
appview/pages/markup/markdown_test.go
···
··· 1 + package markup 2 + 3 + import ( 4 + "bytes" 5 + "testing" 6 + ) 7 + 8 + func TestAtExtension_Rendering(t *testing.T) { 9 + tests := []struct { 10 + name string 11 + markdown string 12 + expected string 13 + }{ 14 + { 15 + name: "renders simple at mention", 16 + markdown: "Hello @user.tngl.sh!", 17 + expected: `<p>Hello <a href="/user.tngl.sh" class="mention">@user.tngl.sh</a>!</p>`, 18 + }, 19 + { 20 + name: "renders multiple at mentions", 21 + markdown: "Hi @alice.tngl.sh and @bob.example.com", 22 + expected: `<p>Hi <a href="/alice.tngl.sh" class="mention">@alice.tngl.sh</a> and <a href="/bob.example.com" class="mention">@bob.example.com</a></p>`, 23 + }, 24 + { 25 + name: "renders at mention in parentheses", 26 + markdown: "Check this out (@user.tngl.sh)", 27 + expected: `<p>Check this out (<a href="/user.tngl.sh" class="mention">@user.tngl.sh</a>)</p>`, 28 + }, 29 + { 30 + name: "does not render email", 31 + markdown: "Contact me at test@example.com", 32 + expected: `<p>Contact me at <a href="mailto:test@example.com">test@example.com</a></p>`, 33 + }, 34 + { 35 + name: "renders at mention with hyphen", 36 + markdown: "Follow @user-name.tngl.sh", 37 + expected: `<p>Follow <a href="/user-name.tngl.sh" class="mention">@user-name.tngl.sh</a></p>`, 38 + }, 39 + { 40 + name: "renders at mention with numbers", 41 + markdown: "@user123.test456.social", 42 + expected: `<p><a href="/user123.test456.social" class="mention">@user123.test456.social</a></p>`, 43 + }, 44 + { 45 + name: "at mention at start of line", 46 + markdown: "@user.tngl.sh is cool", 47 + expected: `<p><a href="/user.tngl.sh" class="mention">@user.tngl.sh</a> is cool</p>`, 48 + }, 49 + } 50 + 51 + for _, tt := range tests { 52 + t.Run(tt.name, func(t *testing.T) { 53 + md := NewMarkdown() 54 + 55 + var buf bytes.Buffer 56 + if err := md.Convert([]byte(tt.markdown), &buf); err != nil { 57 + t.Fatalf("failed to convert markdown: %v", err) 58 + } 59 + 60 + result := buf.String() 61 + if result != tt.expected+"\n" { 62 + t.Errorf("expected:\n%s\ngot:\n%s", tt.expected, result) 63 + } 64 + }) 65 + } 66 + } 67 + 68 + func TestAtExtension_WithOtherMarkdown(t *testing.T) { 69 + tests := []struct { 70 + name string 71 + markdown string 72 + contains string 73 + }{ 74 + { 75 + name: "at mention with bold", 76 + markdown: "**Hello @user.tngl.sh**", 77 + contains: `<strong>Hello <a href="/user.tngl.sh" class="mention">@user.tngl.sh</a></strong>`, 78 + }, 79 + { 80 + name: "at mention with italic", 81 + markdown: "*Check @user.tngl.sh*", 82 + contains: `<em>Check <a href="/user.tngl.sh" class="mention">@user.tngl.sh</a></em>`, 83 + }, 84 + { 85 + name: "at mention in list", 86 + markdown: "- Item 1\n- @user.tngl.sh\n- Item 3", 87 + contains: `<a href="/user.tngl.sh" class="mention">@user.tngl.sh</a>`, 88 + }, 89 + { 90 + name: "at mention in link", 91 + markdown: "[@regnault.dev](https://regnault.dev)", 92 + contains: `<a href="https://regnault.dev">@regnault.dev</a>`, 93 + }, 94 + { 95 + name: "at mention in link again", 96 + markdown: "[check out @regnault.dev](https://regnault.dev)", 97 + contains: `<a href="https://regnault.dev">check out @regnault.dev</a>`, 98 + }, 99 + { 100 + name: "at mention in link again, multiline", 101 + markdown: "[\ncheck out @regnault.dev](https://regnault.dev)", 102 + contains: "<a href=\"https://regnault.dev\">\ncheck out @regnault.dev</a>", 103 + }, 104 + } 105 + 106 + for _, tt := range tests { 107 + t.Run(tt.name, func(t *testing.T) { 108 + md := NewMarkdown() 109 + 110 + var buf bytes.Buffer 111 + if err := md.Convert([]byte(tt.markdown), &buf); err != nil { 112 + t.Fatalf("failed to convert markdown: %v", err) 113 + } 114 + 115 + result := buf.String() 116 + if !bytes.Contains([]byte(result), []byte(tt.contains)) { 117 + t.Errorf("expected output to contain:\n%s\ngot:\n%s", tt.contains, result) 118 + } 119 + }) 120 + } 121 + }
+124
appview/pages/markup/reference_link.go
···
··· 1 + package markup 2 + 3 + import ( 4 + "maps" 5 + "net/url" 6 + "path" 7 + "slices" 8 + "strconv" 9 + "strings" 10 + 11 + "github.com/yuin/goldmark/ast" 12 + "github.com/yuin/goldmark/text" 13 + "tangled.org/core/appview/models" 14 + textension "tangled.org/core/appview/pages/markup/extension" 15 + ) 16 + 17 + // FindReferences collects all links referencing tangled-related objects 18 + // like issues, PRs, comments or even @-mentions 19 + // This funciton doesn't actually check for the existence of records in the DB 20 + // or the PDS; it merely returns a list of what are presumed to be references. 21 + func FindReferences(baseUrl string, source string) ([]string, []models.ReferenceLink) { 22 + var ( 23 + refLinkSet = make(map[models.ReferenceLink]struct{}) 24 + mentionsSet = make(map[string]struct{}) 25 + md = NewMarkdown() 26 + sourceBytes = []byte(source) 27 + root = md.Parser().Parse(text.NewReader(sourceBytes)) 28 + ) 29 + // trim url scheme. the SSL shouldn't matter 30 + baseUrl = strings.TrimPrefix(baseUrl, "https://") 31 + baseUrl = strings.TrimPrefix(baseUrl, "http://") 32 + 33 + ast.Walk(root, func(n ast.Node, entering bool) (ast.WalkStatus, error) { 34 + if !entering { 35 + return ast.WalkContinue, nil 36 + } 37 + switch n.Kind() { 38 + case textension.KindAt: 39 + handle := n.(*textension.AtNode).Handle 40 + mentionsSet[handle] = struct{}{} 41 + return ast.WalkSkipChildren, nil 42 + case ast.KindLink: 43 + dest := string(n.(*ast.Link).Destination) 44 + ref := parseTangledLink(baseUrl, dest) 45 + if ref != nil { 46 + refLinkSet[*ref] = struct{}{} 47 + } 48 + return ast.WalkSkipChildren, nil 49 + case ast.KindAutoLink: 50 + an := n.(*ast.AutoLink) 51 + if an.AutoLinkType == ast.AutoLinkURL { 52 + dest := string(an.URL(sourceBytes)) 53 + ref := parseTangledLink(baseUrl, dest) 54 + if ref != nil { 55 + refLinkSet[*ref] = struct{}{} 56 + } 57 + } 58 + return ast.WalkSkipChildren, nil 59 + } 60 + return ast.WalkContinue, nil 61 + }) 62 + mentions := slices.Collect(maps.Keys(mentionsSet)) 63 + references := slices.Collect(maps.Keys(refLinkSet)) 64 + return mentions, references 65 + } 66 + 67 + func parseTangledLink(baseHost string, urlStr string) *models.ReferenceLink { 68 + u, err := url.Parse(urlStr) 69 + if err != nil { 70 + return nil 71 + } 72 + 73 + if u.Host != "" && !strings.EqualFold(u.Host, baseHost) { 74 + return nil 75 + } 76 + 77 + p := path.Clean(u.Path) 78 + parts := strings.FieldsFunc(p, func(r rune) bool { return r == '/' }) 79 + if len(parts) < 4 { 80 + // need at least: handle / repo / kind / id 81 + return nil 82 + } 83 + 84 + var ( 85 + handle = parts[0] 86 + repo = parts[1] 87 + kindSeg = parts[2] 88 + subjectSeg = parts[3] 89 + ) 90 + 91 + handle = strings.TrimPrefix(handle, "@") 92 + 93 + var kind models.RefKind 94 + switch kindSeg { 95 + case "issues": 96 + kind = models.RefKindIssue 97 + case "pulls": 98 + kind = models.RefKindPull 99 + default: 100 + return nil 101 + } 102 + 103 + subjectId, err := strconv.Atoi(subjectSeg) 104 + if err != nil { 105 + return nil 106 + } 107 + var commentId *int 108 + if u.Fragment != "" { 109 + if strings.HasPrefix(u.Fragment, "comment-") { 110 + commentIdStr := u.Fragment[len("comment-"):] 111 + if id, err := strconv.Atoi(commentIdStr); err == nil { 112 + commentId = &id 113 + } 114 + } 115 + } 116 + 117 + return &models.ReferenceLink{ 118 + Handle: handle, 119 + Repo: repo, 120 + Kind: kind, 121 + SubjectId: subjectId, 122 + CommentId: commentId, 123 + } 124 + }
+41 -19
appview/pages/pages.go
··· 31 "github.com/bluesky-social/indigo/atproto/identity" 32 "github.com/bluesky-social/indigo/atproto/syntax" 33 "github.com/go-git/go-git/v5/plumbing" 34 - "github.com/go-git/go-git/v5/plumbing/object" 35 ) 36 37 //go:embed templates/* static legal ··· 211 return tpl.ExecuteTemplate(w, "layouts/base", params) 212 } 213 214 func (p *Pages) Favicon(w io.Writer) error { 215 - return p.executePlain("fragments/dolly/silhouette", w, nil) 216 } 217 218 type LoginParams struct { ··· 407 type KnotsParams struct { 408 LoggedInUser *oauth.User 409 Registrations []models.Registration 410 } 411 412 func (p *Pages) Knots(w io.Writer, params KnotsParams) error { ··· 419 Members []string 420 Repos map[string][]models.Repo 421 IsOwner bool 422 } 423 424 func (p *Pages) Knot(w io.Writer, params KnotParams) error { ··· 436 type SpindlesParams struct { 437 LoggedInUser *oauth.User 438 Spindles []models.Spindle 439 } 440 441 func (p *Pages) Spindles(w io.Writer, params SpindlesParams) error { ··· 444 445 type SpindleListingParams struct { 446 models.Spindle 447 } 448 449 func (p *Pages) SpindleListing(w io.Writer, params SpindleListingParams) error { ··· 455 Spindle models.Spindle 456 Members []string 457 Repos map[string][]models.Repo 458 } 459 460 func (p *Pages) SpindleDashboard(w io.Writer, params SpindleDashboardParams) error { ··· 482 483 type ProfileCard struct { 484 UserDid string 485 - UserHandle string 486 FollowStatus models.FollowStatus 487 Punchcard *models.Punchcard 488 Profile *models.Profile ··· 625 return p.executePlain("user/fragments/editPins", w, params) 626 } 627 628 - type RepoStarFragmentParams struct { 629 IsStarred bool 630 - RepoAt syntax.ATURI 631 - Stats models.RepoStats 632 } 633 634 - func (p *Pages) RepoStarFragment(w io.Writer, params RepoStarFragmentParams) error { 635 - return p.executePlain("repo/fragments/repoStar", w, params) 636 } 637 638 type RepoIndexParams struct { ··· 640 RepoInfo repoinfo.RepoInfo 641 Active string 642 TagMap map[string][]string 643 - CommitsTrunc []*object.Commit 644 TagsTrunc []*types.TagReference 645 BranchesTrunc []types.Branch 646 // ForkInfo *types.ForkInfo ··· 831 } 832 833 type Collaborator struct { 834 - Did string 835 - Handle string 836 - Role string 837 } 838 839 type RepoSettingsParams struct { ··· 908 RepoInfo repoinfo.RepoInfo 909 Active string 910 Issues []models.Issue 911 LabelDefs map[string]*models.LabelDefinition 912 Page pagination.Page 913 FilteringByOpen bool ··· 925 Active string 926 Issue *models.Issue 927 CommentList []models.CommentListItem 928 LabelDefs map[string]*models.LabelDefinition 929 930 OrderedReactionKinds []models.ReactionKind ··· 1078 Pull *models.Pull 1079 Stack models.Stack 1080 AbandonedPulls []*models.Pull 1081 BranchDeleteStatus *models.BranchDeleteStatus 1082 MergeCheck types.MergeCheckResponse 1083 ResubmitCheck ResubmitResult ··· 1249 return p.executePlain("repo/fragments/compareAllowPull", w, params) 1250 } 1251 1252 - type RepoCompareDiffParams struct { 1253 - LoggedInUser *oauth.User 1254 - RepoInfo repoinfo.RepoInfo 1255 - Diff types.NiceDiff 1256 } 1257 1258 - func (p *Pages) RepoCompareDiff(w io.Writer, params RepoCompareDiffParams) error { 1259 - return p.executePlain("repo/fragments/diff", w, []any{params.RepoInfo.FullName, &params.Diff}) 1260 } 1261 1262 type LabelPanelParams struct { ··· 1376 ShowRendered bool 1377 RenderToggle bool 1378 RenderedContents template.HTML 1379 - String models.String 1380 Stats models.StringStats 1381 Owner identity.Identity 1382 } 1383
··· 31 "github.com/bluesky-social/indigo/atproto/identity" 32 "github.com/bluesky-social/indigo/atproto/syntax" 33 "github.com/go-git/go-git/v5/plumbing" 34 ) 35 36 //go:embed templates/* static legal ··· 210 return tpl.ExecuteTemplate(w, "layouts/base", params) 211 } 212 213 + type DollyParams struct { 214 + Classes string 215 + FillColor string 216 + } 217 + 218 + func (p *Pages) Dolly(w io.Writer, params DollyParams) error { 219 + return p.executePlain("fragments/dolly/logo", w, params) 220 + } 221 + 222 func (p *Pages) Favicon(w io.Writer) error { 223 + return p.Dolly(w, DollyParams{ 224 + Classes: "text-black dark:text-white", 225 + }) 226 } 227 228 type LoginParams struct { ··· 417 type KnotsParams struct { 418 LoggedInUser *oauth.User 419 Registrations []models.Registration 420 + Tabs []map[string]any 421 + Tab string 422 } 423 424 func (p *Pages) Knots(w io.Writer, params KnotsParams) error { ··· 431 Members []string 432 Repos map[string][]models.Repo 433 IsOwner bool 434 + Tabs []map[string]any 435 + Tab string 436 } 437 438 func (p *Pages) Knot(w io.Writer, params KnotParams) error { ··· 450 type SpindlesParams struct { 451 LoggedInUser *oauth.User 452 Spindles []models.Spindle 453 + Tabs []map[string]any 454 + Tab string 455 } 456 457 func (p *Pages) Spindles(w io.Writer, params SpindlesParams) error { ··· 460 461 type SpindleListingParams struct { 462 models.Spindle 463 + Tabs []map[string]any 464 + Tab string 465 } 466 467 func (p *Pages) SpindleListing(w io.Writer, params SpindleListingParams) error { ··· 473 Spindle models.Spindle 474 Members []string 475 Repos map[string][]models.Repo 476 + Tabs []map[string]any 477 + Tab string 478 } 479 480 func (p *Pages) SpindleDashboard(w io.Writer, params SpindleDashboardParams) error { ··· 502 503 type ProfileCard struct { 504 UserDid string 505 FollowStatus models.FollowStatus 506 Punchcard *models.Punchcard 507 Profile *models.Profile ··· 644 return p.executePlain("user/fragments/editPins", w, params) 645 } 646 647 + type StarBtnFragmentParams struct { 648 IsStarred bool 649 + SubjectAt syntax.ATURI 650 + StarCount int 651 } 652 653 + func (p *Pages) StarBtnFragment(w io.Writer, params StarBtnFragmentParams) error { 654 + return p.executePlain("fragments/starBtn-oob", w, params) 655 } 656 657 type RepoIndexParams struct { ··· 659 RepoInfo repoinfo.RepoInfo 660 Active string 661 TagMap map[string][]string 662 + CommitsTrunc []types.Commit 663 TagsTrunc []*types.TagReference 664 BranchesTrunc []types.Branch 665 // ForkInfo *types.ForkInfo ··· 850 } 851 852 type Collaborator struct { 853 + Did string 854 + Role string 855 } 856 857 type RepoSettingsParams struct { ··· 926 RepoInfo repoinfo.RepoInfo 927 Active string 928 Issues []models.Issue 929 + IssueCount int 930 LabelDefs map[string]*models.LabelDefinition 931 Page pagination.Page 932 FilteringByOpen bool ··· 944 Active string 945 Issue *models.Issue 946 CommentList []models.CommentListItem 947 + Backlinks []models.RichReferenceLink 948 LabelDefs map[string]*models.LabelDefinition 949 950 OrderedReactionKinds []models.ReactionKind ··· 1098 Pull *models.Pull 1099 Stack models.Stack 1100 AbandonedPulls []*models.Pull 1101 + Backlinks []models.RichReferenceLink 1102 BranchDeleteStatus *models.BranchDeleteStatus 1103 MergeCheck types.MergeCheckResponse 1104 ResubmitCheck ResubmitResult ··· 1270 return p.executePlain("repo/fragments/compareAllowPull", w, params) 1271 } 1272 1273 + type RepoCompareDiffFragmentParams struct { 1274 + Diff types.NiceDiff 1275 + DiffOpts types.DiffOpts 1276 } 1277 1278 + func (p *Pages) RepoCompareDiffFragment(w io.Writer, params RepoCompareDiffFragmentParams) error { 1279 + return p.executePlain("repo/fragments/diff", w, []any{&params.Diff, &params.DiffOpts}) 1280 } 1281 1282 type LabelPanelParams struct { ··· 1396 ShowRendered bool 1397 RenderToggle bool 1398 RenderedContents template.HTML 1399 + String *models.String 1400 Stats models.StringStats 1401 + IsStarred bool 1402 + StarCount int 1403 Owner identity.Identity 1404 } 1405
+25 -22
appview/pages/repoinfo/repoinfo.go
··· 1 package repoinfo 2 3 import ( 4 "path" 5 "slices" 6 7 "github.com/bluesky-social/indigo/atproto/syntax" 8 "tangled.org/core/appview/models" 9 "tangled.org/core/appview/state/userutil" 10 ) 11 12 - func (r RepoInfo) Owner() string { 13 if r.OwnerHandle != "" { 14 return r.OwnerHandle 15 } else { ··· 18 } 19 20 func (r RepoInfo) FullName() string { 21 - return path.Join(r.Owner(), r.Name) 22 } 23 24 - func (r RepoInfo) OwnerWithoutAt() string { 25 if r.OwnerHandle != "" { 26 return r.OwnerHandle 27 } else { ··· 30 } 31 32 func (r RepoInfo) FullNameWithoutAt() string { 33 - return path.Join(r.OwnerWithoutAt(), r.Name) 34 } 35 36 func (r RepoInfo) GetTabs() [][]string { ··· 48 return tabs 49 } 50 51 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 70 } 71 72 // each tab on a repo could have some metadata:
··· 1 package repoinfo 2 3 import ( 4 + "fmt" 5 "path" 6 "slices" 7 8 "github.com/bluesky-social/indigo/atproto/syntax" 9 + "tangled.org/core/api/tangled" 10 "tangled.org/core/appview/models" 11 "tangled.org/core/appview/state/userutil" 12 ) 13 14 + func (r RepoInfo) owner() string { 15 if r.OwnerHandle != "" { 16 return r.OwnerHandle 17 } else { ··· 20 } 21 22 func (r RepoInfo) FullName() string { 23 + return path.Join(r.owner(), r.Name) 24 } 25 26 + func (r RepoInfo) ownerWithoutAt() string { 27 if r.OwnerHandle != "" { 28 return r.OwnerHandle 29 } else { ··· 32 } 33 34 func (r RepoInfo) FullNameWithoutAt() string { 35 + return path.Join(r.ownerWithoutAt(), r.Name) 36 } 37 38 func (r RepoInfo) GetTabs() [][]string { ··· 50 return tabs 51 } 52 53 + func (r RepoInfo) RepoAt() syntax.ATURI { 54 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", r.OwnerDid, tangled.RepoNSID, r.Rkey)) 55 + } 56 + 57 type RepoInfo struct { 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 73 } 74 75 // each tab on a repo could have some metadata:
+1 -1
appview/pages/templates/banner.html
··· 30 <div class="mx-6"> 31 These services may not be fully accessible until upgraded. 32 <a class="underline text-red-800 dark:text-red-200" 33 - href="https://tangled.org/@tangled.org/core/tree/master/docs/migrations.md"> 34 Click to read the upgrade guide</a>. 35 </div> 36 </details>
··· 30 <div class="mx-6"> 31 These services may not be fully accessible until upgraded. 32 <a class="underline text-red-800 dark:text-red-200" 33 + href="https://docs.tangled.org/migrating-knots-spindles.html#migrating-knots-spindles"> 34 Click to read the upgrade guide</a>. 35 </div> 36 </details>
+9 -29
appview/pages/templates/brand/brand.html
··· 4 <div class="grid grid-cols-10"> 5 <header class="col-span-full md:col-span-10 px-6 py-2 mb-4"> 6 <h1 class="text-2xl font-bold dark:text-white mb-1">Brand</h1> 7 - <p class="text-gray-600 dark:text-gray-400 mb-1"> 8 Assets and guidelines for using Tangled's logo and brand elements. 9 </p> 10 </header> ··· 14 15 <!-- Introduction Section --> 16 <section> 17 - <p class="text-gray-600 dark:text-gray-400 mb-2"> 18 Tangled's logo and mascot is <strong>Dolly</strong>, the first ever <em>cloned</em> mammal. Please 19 follow the below guidelines when using Dolly and the logotype. 20 </p> 21 - <p class="text-gray-600 dark:text-gray-400 mb-2"> 22 All assets are served as SVGs, and can be downloaded by right-clicking and clicking "Save image as". 23 </p> 24 </section> ··· 34 </div> 35 <div class="order-1 lg:order-2"> 36 <h2 class="text-xl font-semibold dark:text-white mb-3">Black logotype</h2> 37 - <p class="text-gray-600 dark:text-gray-400 mb-4">For use on light-colored backgrounds.</p> 38 <p class="text-gray-700 dark:text-gray-300"> 39 This is the preferred version of the logotype, featuring dark text and elements, ideal for light 40 backgrounds and designs. ··· 53 </div> 54 <div class="order-1 lg:order-2"> 55 <h2 class="text-xl font-semibold dark:text-white mb-3">White logotype</h2> 56 - <p class="text-gray-600 dark:text-gray-400 mb-4">For use on dark-colored backgrounds.</p> 57 <p class="text-gray-700 dark:text-gray-300"> 58 This version features white text and elements, ideal for dark backgrounds 59 and inverted designs. ··· 81 </div> 82 <div class="order-1 lg:order-2"> 83 <h2 class="text-xl font-semibold dark:text-white mb-3">Mark only</h2> 84 - <p class="text-gray-600 dark:text-gray-400 mb-4"> 85 When a smaller 1:1 logo or icon is needed, Dolly's face may be used on its own. 86 </p> 87 <p class="text-gray-700 dark:text-gray-300 mb-4"> ··· 123 </div> 124 <div class="order-1 lg:order-2"> 125 <h2 class="text-xl font-semibold dark:text-white mb-3">Colored backgrounds</h2> 126 - <p class="text-gray-600 dark:text-gray-400 mb-4"> 127 White logo mark on colored backgrounds. 128 </p> 129 <p class="text-gray-700 dark:text-gray-300 mb-4"> ··· 165 </div> 166 <div class="order-1 lg:order-2"> 167 <h2 class="text-xl font-semibold dark:text-white mb-3">Lighter backgrounds</h2> 168 - <p class="text-gray-600 dark:text-gray-400 mb-4"> 169 Dark logo mark on lighter, pastel backgrounds. 170 </p> 171 <p class="text-gray-700 dark:text-gray-300 mb-4"> ··· 186 </div> 187 <div class="order-1 lg:order-2"> 188 <h2 class="text-xl font-semibold dark:text-white mb-3">Recoloring</h2> 189 - <p class="text-gray-600 dark:text-gray-400 mb-4"> 190 Custom coloring of the logotype is permitted. 191 </p> 192 <p class="text-gray-700 dark:text-gray-300 mb-4"> ··· 194 </p> 195 <p class="text-gray-700 dark:text-gray-300 text-sm"> 196 <strong>Example:</strong> Gray/sand colored logotype on a light yellow/tan background. 197 - </p> 198 - </div> 199 - </section> 200 - 201 - <!-- Silhouette Section --> 202 - <section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center"> 203 - <div class="order-2 lg:order-1"> 204 - <div class="border border-gray-200 dark:border-gray-700 p-8 sm:p-16 bg-gray-50 dark:bg-gray-100 rounded"> 205 - <img src="https://assets.tangled.network/tangled_dolly_silhouette.svg" 206 - alt="Dolly silhouette" 207 - class="w-full max-w-32 mx-auto" /> 208 - </div> 209 - </div> 210 - <div class="order-1 lg:order-2"> 211 - <h2 class="text-xl font-semibold dark:text-white mb-3">Dolly silhouette</h2> 212 - <p class="text-gray-600 dark:text-gray-400 mb-4">A minimalist version of Dolly.</p> 213 - <p class="text-gray-700 dark:text-gray-300"> 214 - The silhouette can be used where a subtle brand presence is needed, 215 - or as a background element. Works on any background color with proper contrast. 216 - For example, we use this as the site's favicon. 217 </p> 218 </div> 219 </section>
··· 4 <div class="grid grid-cols-10"> 5 <header class="col-span-full md:col-span-10 px-6 py-2 mb-4"> 6 <h1 class="text-2xl font-bold dark:text-white mb-1">Brand</h1> 7 + <p class="text-gray-500 dark:text-gray-300 mb-1"> 8 Assets and guidelines for using Tangled's logo and brand elements. 9 </p> 10 </header> ··· 14 15 <!-- Introduction Section --> 16 <section> 17 + <p class="text-gray-500 dark:text-gray-300 mb-2"> 18 Tangled's logo and mascot is <strong>Dolly</strong>, the first ever <em>cloned</em> mammal. Please 19 follow the below guidelines when using Dolly and the logotype. 20 </p> 21 + <p class="text-gray-500 dark:text-gray-300 mb-2"> 22 All assets are served as SVGs, and can be downloaded by right-clicking and clicking "Save image as". 23 </p> 24 </section> ··· 34 </div> 35 <div class="order-1 lg:order-2"> 36 <h2 class="text-xl font-semibold dark:text-white mb-3">Black logotype</h2> 37 + <p class="text-gray-500 dark:text-gray-300 mb-4">For use on light-colored backgrounds.</p> 38 <p class="text-gray-700 dark:text-gray-300"> 39 This is the preferred version of the logotype, featuring dark text and elements, ideal for light 40 backgrounds and designs. ··· 53 </div> 54 <div class="order-1 lg:order-2"> 55 <h2 class="text-xl font-semibold dark:text-white mb-3">White logotype</h2> 56 + <p class="text-gray-500 dark:text-gray-300 mb-4">For use on dark-colored backgrounds.</p> 57 <p class="text-gray-700 dark:text-gray-300"> 58 This version features white text and elements, ideal for dark backgrounds 59 and inverted designs. ··· 81 </div> 82 <div class="order-1 lg:order-2"> 83 <h2 class="text-xl font-semibold dark:text-white mb-3">Mark only</h2> 84 + <p class="text-gray-500 dark:text-gray-300 mb-4"> 85 When a smaller 1:1 logo or icon is needed, Dolly's face may be used on its own. 86 </p> 87 <p class="text-gray-700 dark:text-gray-300 mb-4"> ··· 123 </div> 124 <div class="order-1 lg:order-2"> 125 <h2 class="text-xl font-semibold dark:text-white mb-3">Colored backgrounds</h2> 126 + <p class="text-gray-500 dark:text-gray-300 mb-4"> 127 White logo mark on colored backgrounds. 128 </p> 129 <p class="text-gray-700 dark:text-gray-300 mb-4"> ··· 165 </div> 166 <div class="order-1 lg:order-2"> 167 <h2 class="text-xl font-semibold dark:text-white mb-3">Lighter backgrounds</h2> 168 + <p class="text-gray-500 dark:text-gray-300 mb-4"> 169 Dark logo mark on lighter, pastel backgrounds. 170 </p> 171 <p class="text-gray-700 dark:text-gray-300 mb-4"> ··· 186 </div> 187 <div class="order-1 lg:order-2"> 188 <h2 class="text-xl font-semibold dark:text-white mb-3">Recoloring</h2> 189 + <p class="text-gray-500 dark:text-gray-300 mb-4"> 190 Custom coloring of the logotype is permitted. 191 </p> 192 <p class="text-gray-700 dark:text-gray-300 mb-4"> ··· 194 </p> 195 <p class="text-gray-700 dark:text-gray-300 text-sm"> 196 <strong>Example:</strong> Gray/sand colored logotype on a light yellow/tan background. 197 </p> 198 </div> 199 </section>
+14 -2
appview/pages/templates/fragments/dolly/logo.html
··· 2 <svg 3 version="1.1" 4 id="svg1" 5 - class="{{ . }}" 6 width="25" 7 height="25" 8 viewBox="0 0 25 25" ··· 17 xmlns:svg="http://www.w3.org/2000/svg" 18 xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" 19 xmlns:cc="http://creativecommons.org/ns#"> 20 <sodipodi:namedview 21 id="namedview1" 22 pagecolor="#ffffff" ··· 51 id="g1" 52 transform="translate(-0.42924038,-0.87777209)"> 53 <path 54 - fill="currentColor" 55 style="stroke-width:0.111183;" 56 d="m 16.775491,24.987061 c -0.78517,-0.0064 -1.384202,-0.234614 -2.033994,-0.631295 -0.931792,-0.490188 -1.643475,-1.31368 -2.152014,-2.221647 C 11.781409,23.136647 10.701392,23.744942 9.4922931,24.0886 8.9774725,24.238111 8.0757679,24.389777 6.5811304,23.84827 4.4270703,23.124679 2.8580086,20.883331 3.0363279,18.599583 3.0037061,17.652919 3.3488675,16.723769 3.8381157,15.925061 2.5329485,15.224503 1.4686756,14.048584 1.0611184,12.606459 0.81344502,11.816973 0.82385989,10.966486 0.91519098,10.154906 1.2422711,8.2387903 2.6795811,6.5725716 4.5299585,5.9732484 5.2685364,4.290122 6.8802592,3.0349975 8.706276,2.7794663 c 1.2124148,-0.1688264 2.46744,0.084987 3.52811,0.7011837 1.545426,-1.7139736 4.237779,-2.2205077 6.293579,-1.1676231 1.568222,0.7488935 2.689625,2.3113526 2.961888,4.0151464 1.492195,0.5977882 2.749007,1.8168898 3.242225,3.3644951 0.329805,0.9581836 0.340709,2.0135956 0.127128,2.9974286 -0.381606,1.535184 -1.465322,2.842146 -2.868035,3.556463 0.0034,0.273204 0.901506,2.243045 0.751284,3.729647 -0.03281,1.858525 -1.211631,3.619894 -2.846433,4.475452 -0.953967,0.556812 -2.084452,0.546309 -3.120531,0.535398 z m -4.470079,-5.349839 c 1.322246,-0.147248 2.189053,-1.300106 2.862307,-2.338363 0.318287,-0.472954 0.561404,-1.002348 0.803,-1.505815 0.313265,0.287151 0.578698,0.828085 1.074141,0.956909 0.521892,0.162542 1.133743,0.03052 1.45325,-0.443554 0.611414,-1.140449 0.31004,-2.516537 -0.04602,-3.698347 C 18.232844,11.92927 17.945151,11.232927 17.397785,10.751793 17.514522,9.9283111 17.026575,9.0919791 16.332883,8.6609491 15.741721,9.1323278 14.842258,9.1294949 14.271975,8.6252369 13.178927,9.7400102 12.177239,9.7029996 11.209704,8.8195135 10.992255,8.6209543 10.577326,10.031484 9.1211947,9.2324497 8.2846288,9.9333947 7.6359672,10.607693 7.0611981,11.578553 6.5026891,12.62523 5.9177873,13.554793 5.867393,14.69141 c -0.024234,0.66432 0.4948601,1.360337 1.1982269,1.306329 0.702996,0.06277 1.1815208,-0.629091 1.7138087,-0.916491 0.079382,0.927141 0.1688108,1.923227 0.4821259,2.828358 0.3596254,1.171275 1.6262605,1.915695 2.8251855,1.745211 0.08481,-0.0066 0.218672,-0.01769 0.218672,-0.0176 z m 0.686342,-3.497495 c -0.643126,-0.394168 -0.33365,-1.249599 -0.359402,-1.870938 0.064,-0.749774 0.115321,-1.538054 0.452402,-2.221125 0.356724,-0.487008 1.226721,-0.299139 1.265134,0.325689 -0.02558,0.628509 -0.314101,1.25416 -0.279646,1.9057 -0.07482,0.544043 0.05418,1.155133 -0.186476,1.652391 -0.197455,0.275121 -0.599638,0.355105 -0.892012,0.208283 z m -2.808766,-0.358124 c -0.605767,-0.328664 -0.4133176,-1.155655 -0.5083256,-1.73063 0.078762,-0.66567 0.013203,-1.510085 0.5705316,-1.976886 0.545037,-0.380109 1.286917,0.270803 1.029164,0.868384 -0.274913,0.755214 -0.09475,1.580345 -0.08893,2.34609 -0.104009,0.451702 -0.587146,0.691508 -1.002445,0.493042 z" 57 id="path4"
··· 2 <svg 3 version="1.1" 4 id="svg1" 5 + class="{{ .Classes }}" 6 width="25" 7 height="25" 8 viewBox="0 0 25 25" ··· 17 xmlns:svg="http://www.w3.org/2000/svg" 18 xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" 19 xmlns:cc="http://creativecommons.org/ns#"> 20 + <style> 21 + .dolly { 22 + color: #000000; 23 + } 24 + 25 + @media (prefers-color-scheme: dark) { 26 + .dolly { 27 + color: #ffffff; 28 + } 29 + } 30 + </style> 31 <sodipodi:namedview 32 id="namedview1" 33 pagecolor="#ffffff" ··· 62 id="g1" 63 transform="translate(-0.42924038,-0.87777209)"> 64 <path 65 + class="dolly" 66 + fill="{{ or .FillColor "currentColor" }}" 67 style="stroke-width:0.111183;" 68 d="m 16.775491,24.987061 c -0.78517,-0.0064 -1.384202,-0.234614 -2.033994,-0.631295 -0.931792,-0.490188 -1.643475,-1.31368 -2.152014,-2.221647 C 11.781409,23.136647 10.701392,23.744942 9.4922931,24.0886 8.9774725,24.238111 8.0757679,24.389777 6.5811304,23.84827 4.4270703,23.124679 2.8580086,20.883331 3.0363279,18.599583 3.0037061,17.652919 3.3488675,16.723769 3.8381157,15.925061 2.5329485,15.224503 1.4686756,14.048584 1.0611184,12.606459 0.81344502,11.816973 0.82385989,10.966486 0.91519098,10.154906 1.2422711,8.2387903 2.6795811,6.5725716 4.5299585,5.9732484 5.2685364,4.290122 6.8802592,3.0349975 8.706276,2.7794663 c 1.2124148,-0.1688264 2.46744,0.084987 3.52811,0.7011837 1.545426,-1.7139736 4.237779,-2.2205077 6.293579,-1.1676231 1.568222,0.7488935 2.689625,2.3113526 2.961888,4.0151464 1.492195,0.5977882 2.749007,1.8168898 3.242225,3.3644951 0.329805,0.9581836 0.340709,2.0135956 0.127128,2.9974286 -0.381606,1.535184 -1.465322,2.842146 -2.868035,3.556463 0.0034,0.273204 0.901506,2.243045 0.751284,3.729647 -0.03281,1.858525 -1.211631,3.619894 -2.846433,4.475452 -0.953967,0.556812 -2.084452,0.546309 -3.120531,0.535398 z m -4.470079,-5.349839 c 1.322246,-0.147248 2.189053,-1.300106 2.862307,-2.338363 0.318287,-0.472954 0.561404,-1.002348 0.803,-1.505815 0.313265,0.287151 0.578698,0.828085 1.074141,0.956909 0.521892,0.162542 1.133743,0.03052 1.45325,-0.443554 0.611414,-1.140449 0.31004,-2.516537 -0.04602,-3.698347 C 18.232844,11.92927 17.945151,11.232927 17.397785,10.751793 17.514522,9.9283111 17.026575,9.0919791 16.332883,8.6609491 15.741721,9.1323278 14.842258,9.1294949 14.271975,8.6252369 13.178927,9.7400102 12.177239,9.7029996 11.209704,8.8195135 10.992255,8.6209543 10.577326,10.031484 9.1211947,9.2324497 8.2846288,9.9333947 7.6359672,10.607693 7.0611981,11.578553 6.5026891,12.62523 5.9177873,13.554793 5.867393,14.69141 c -0.024234,0.66432 0.4948601,1.360337 1.1982269,1.306329 0.702996,0.06277 1.1815208,-0.629091 1.7138087,-0.916491 0.079382,0.927141 0.1688108,1.923227 0.4821259,2.828358 0.3596254,1.171275 1.6262605,1.915695 2.8251855,1.745211 0.08481,-0.0066 0.218672,-0.01769 0.218672,-0.0176 z m 0.686342,-3.497495 c -0.643126,-0.394168 -0.33365,-1.249599 -0.359402,-1.870938 0.064,-0.749774 0.115321,-1.538054 0.452402,-2.221125 0.356724,-0.487008 1.226721,-0.299139 1.265134,0.325689 -0.02558,0.628509 -0.314101,1.25416 -0.279646,1.9057 -0.07482,0.544043 0.05418,1.155133 -0.186476,1.652391 -0.197455,0.275121 -0.599638,0.355105 -0.892012,0.208283 z m -2.808766,-0.358124 c -0.605767,-0.328664 -0.4133176,-1.155655 -0.5083256,-1.73063 0.078762,-0.66567 0.013203,-1.510085 0.5705316,-1.976886 0.545037,-0.380109 1.286917,0.270803 1.029164,0.868384 -0.274913,0.755214 -0.09475,1.580345 -0.08893,2.34609 -0.104009,0.451702 -0.587146,0.691508 -1.002445,0.493042 z" 69 id="path4"
-95
appview/pages/templates/fragments/dolly/silhouette.html
··· 1 - {{ define "fragments/dolly/silhouette" }} 2 - <svg 3 - version="1.1" 4 - id="svg1" 5 - width="25" 6 - height="25" 7 - viewBox="0 0 25 25" 8 - sodipodi:docname="tangled_dolly_face_only_black_on_trans.svg" 9 - inkscape:export-filename="tangled_dolly_silhouette_black_on_trans.svg" 10 - inkscape:export-xdpi="96" 11 - inkscape:export-ydpi="96" 12 - inkscape:version="1.4 (e7c3feb100, 2024-10-09)" 13 - xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" 14 - xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" 15 - xmlns="http://www.w3.org/2000/svg" 16 - xmlns:svg="http://www.w3.org/2000/svg" 17 - xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" 18 - xmlns:cc="http://creativecommons.org/ns#"> 19 - <style> 20 - .dolly { 21 - color: #000000; 22 - } 23 - 24 - @media (prefers-color-scheme: dark) { 25 - .dolly { 26 - color: #ffffff; 27 - } 28 - } 29 - </style> 30 - <sodipodi:namedview 31 - id="namedview1" 32 - pagecolor="#ffffff" 33 - bordercolor="#000000" 34 - borderopacity="0.25" 35 - inkscape:showpageshadow="2" 36 - inkscape:pageopacity="0.0" 37 - inkscape:pagecheckerboard="true" 38 - inkscape:deskcolor="#d5d5d5" 39 - inkscape:zoom="64" 40 - inkscape:cx="4.96875" 41 - inkscape:cy="13.429688" 42 - inkscape:window-width="3840" 43 - inkscape:window-height="2160" 44 - inkscape:window-x="0" 45 - inkscape:window-y="0" 46 - inkscape:window-maximized="0" 47 - inkscape:current-layer="g1" 48 - borderlayer="true"> 49 - <inkscape:page 50 - x="0" 51 - y="0" 52 - width="25" 53 - height="25" 54 - id="page2" 55 - margin="0" 56 - bleed="0" /> 57 - </sodipodi:namedview> 58 - <g 59 - inkscape:groupmode="layer" 60 - inkscape:label="Image" 61 - id="g1" 62 - transform="translate(-0.42924038,-0.87777209)"> 63 - <path 64 - class="dolly" 65 - fill="currentColor" 66 - style="stroke-width:0.111183" 67 - d="m 16.775491,24.987061 c -0.78517,-0.0064 -1.384202,-0.234614 -2.033994,-0.631295 -0.931792,-0.490188 -1.643475,-1.31368 -2.152014,-2.221647 C 11.781409,23.136647 10.701392,23.744942 9.4922931,24.0886 8.9774725,24.238111 8.0757679,24.389777 6.5811304,23.84827 4.4270703,23.124679 2.8580086,20.883331 3.0363279,18.599583 3.0037061,17.652919 3.3488675,16.723769 3.8381157,15.925061 2.5329485,15.224503 1.4686756,14.048584 1.0611184,12.606459 0.81344502,11.816973 0.82385989,10.966486 0.91519098,10.154906 1.2422711,8.2387903 2.6795811,6.5725716 4.5299585,5.9732484 5.2685364,4.290122 6.8802592,3.0349975 8.706276,2.7794663 c 1.2124148,-0.1688264 2.46744,0.084987 3.52811,0.7011837 1.545426,-1.7139736 4.237779,-2.2205077 6.293579,-1.1676231 1.568222,0.7488935 2.689625,2.3113526 2.961888,4.0151464 1.492195,0.5977882 2.749007,1.8168898 3.242225,3.3644951 0.329805,0.9581836 0.340709,2.0135956 0.127128,2.9974286 -0.381606,1.535184 -1.465322,2.842146 -2.868035,3.556463 0.0034,0.273204 0.901506,2.243045 0.751284,3.729647 -0.03281,1.858525 -1.211631,3.619894 -2.846433,4.475452 -0.953967,0.556812 -2.084452,0.546309 -3.120531,0.535398 z m -4.470079,-5.349839 c 1.322246,-0.147248 2.189053,-1.300106 2.862307,-2.338363 0.318287,-0.472954 0.561404,-1.002348 0.803,-1.505815 0.313265,0.287151 0.578698,0.828085 1.074141,0.956909 0.521892,0.162542 1.133743,0.03052 1.45325,-0.443554 0.611414,-1.140449 0.31004,-2.516537 -0.04602,-3.698347 C 18.232844,11.92927 17.945151,11.232927 17.397785,10.751793 17.514522,9.9283111 17.026575,9.0919791 16.332883,8.6609491 15.741721,9.1323278 14.842258,9.1294949 14.271975,8.6252369 13.178927,9.7400102 12.177239,9.7029996 11.209704,8.8195135 10.992255,8.6209543 10.577326,10.031484 9.1211947,9.2324497 8.2846288,9.9333947 7.6359672,10.607693 7.0611981,11.578553 6.5026891,12.62523 5.9177873,13.554793 5.867393,14.69141 c -0.024234,0.66432 0.4948601,1.360337 1.1982269,1.306329 0.702996,0.06277 1.1815208,-0.629091 1.7138087,-0.916491 0.079382,0.927141 0.1688108,1.923227 0.4821259,2.828358 0.3596254,1.171275 1.6262605,1.915695 2.8251855,1.745211 0.08481,-0.0066 0.218672,-0.01769 0.218672,-0.0176 z" 68 - id="path7" 69 - sodipodi:nodetypes="sccccccccccccccccccsscccccccccscccccccsc" /> 70 - </g> 71 - <metadata 72 - id="metadata1"> 73 - <rdf:RDF> 74 - <cc:Work 75 - rdf:about=""> 76 - <cc:license 77 - rdf:resource="http://creativecommons.org/licenses/by/4.0/" /> 78 - </cc:Work> 79 - <cc:License 80 - rdf:about="http://creativecommons.org/licenses/by/4.0/"> 81 - <cc:permits 82 - rdf:resource="http://creativecommons.org/ns#Reproduction" /> 83 - <cc:permits 84 - rdf:resource="http://creativecommons.org/ns#Distribution" /> 85 - <cc:requires 86 - rdf:resource="http://creativecommons.org/ns#Notice" /> 87 - <cc:requires 88 - rdf:resource="http://creativecommons.org/ns#Attribution" /> 89 - <cc:permits 90 - rdf:resource="http://creativecommons.org/ns#DerivativeWorks" /> 91 - </cc:License> 92 - </rdf:RDF> 93 - </metadata> 94 - </svg> 95 - {{ end }}
···
+1 -1
appview/pages/templates/fragments/logotype.html
··· 1 {{ define "fragments/logotype" }} 2 <span class="flex items-center gap-2"> 3 - {{ template "fragments/dolly/logo" "size-16 text-black dark:text-white" }} 4 <span class="font-bold text-4xl not-italic">tangled</span> 5 <span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1"> 6 alpha
··· 1 {{ define "fragments/logotype" }} 2 <span class="flex items-center gap-2"> 3 + {{ template "fragments/dolly/logo" (dict "Classes" "size-16 text-black dark:text-white") }} 4 <span class="font-bold text-4xl not-italic">tangled</span> 5 <span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1"> 6 alpha
+1 -1
appview/pages/templates/fragments/logotypeSmall.html
··· 1 {{ define "fragments/logotypeSmall" }} 2 <span class="flex items-center gap-2"> 3 - {{ template "fragments/dolly/logo" "size-8 text-black dark:text-white" }} 4 <span class="font-bold text-xl not-italic">tangled</span> 5 <span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1"> 6 alpha
··· 1 {{ define "fragments/logotypeSmall" }} 2 <span class="flex items-center gap-2"> 3 + {{ template "fragments/dolly/logo" (dict "Classes" "size-8 text-black dark:text-white")}} 4 <span class="font-bold text-xl not-italic">tangled</span> 5 <span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1"> 6 alpha
+5
appview/pages/templates/fragments/starBtn-oob.html
···
··· 1 + {{ define "fragments/starBtn-oob" }} 2 + <div hx-swap-oob='outerHTML:#starBtn[data-star-subject-at="{{ .SubjectAt }}"]'> 3 + {{ template "fragments/starBtn" . }} 4 + </div> 5 + {{ end }}
+26
appview/pages/templates/fragments/starBtn.html
···
··· 1 + {{ define "fragments/starBtn" }} 2 + {{/* NOTE: this fragment is always replaced with hx-swap-oob */}} 3 + <button 4 + id="starBtn" 5 + class="btn disabled:opacity-50 disabled:cursor-not-allowed flex gap-2 items-center group" 6 + data-star-subject-at="{{ .SubjectAt }}" 7 + {{ if .IsStarred }} 8 + hx-delete="/star?subject={{ .SubjectAt }}&countHint={{ .StarCount }}" 9 + {{ else }} 10 + hx-post="/star?subject={{ .SubjectAt }}&countHint={{ .StarCount }}" 11 + {{ end }} 12 + 13 + hx-trigger="click" 14 + hx-disabled-elt="#starBtn" 15 + > 16 + {{ if .IsStarred }} 17 + {{ i "star" "w-4 h-4 fill-current" }} 18 + {{ else }} 19 + {{ i "star" "w-4 h-4" }} 20 + {{ end }} 21 + <span class="text-sm"> 22 + {{ .StarCount }} 23 + </span> 24 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 25 + </button> 26 + {{ end }}
+8
appview/pages/templates/fragments/tabSelector.html
··· 2 {{ $name := .Name }} 3 {{ $all := .Values }} 4 {{ $active := .Active }} 5 <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"> 6 {{ $activeTab := "bg-white dark:bg-gray-700 shadow-sm" }} 7 {{ $inactiveTab := "bg-gray-100 dark:bg-gray-800 shadow-inner" }} 8 {{ range $index, $value := $all }} 9 {{ $isActive := eq $value.Key $active }} 10 <a href="?{{ $name }}={{ $value.Key }}" 11 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 }}"> 12 {{ if $value.Icon }} 13 {{ i $value.Icon "size-4" }}
··· 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
appview/pages/templates/fragments/tinyAvatarList.html
···
··· 1 + {{ define "fragments/tinyAvatarList" }} 2 + {{ $all := .all }} 3 + {{ $classes := .classes }} 4 + {{ $ps := take $all 5 }} 5 + <div class="inline-flex items-center -space-x-3"> 6 + {{ $c := "z-50 z-40 z-30 z-20 z-10" }} 7 + {{ range $i, $p := $ps }} 8 + <img 9 + src="{{ tinyAvatar . }}" 10 + alt="" 11 + class="rounded-full size-8 mr-1 border-2 border-gray-100 dark:border-gray-900 z-{{sub 5 $i}}0 {{ $classes }}" 12 + /> 13 + {{ end }} 14 + 15 + {{ if gt (len $all) 5 }} 16 + <span class="pl-4 text-gray-500 dark:text-gray-400 text-sm"> 17 + +{{ sub (len $all) 5 }} 18 + </span> 19 + {{ end }} 20 + </div> 21 + {{ end }} 22 +
+23 -7
appview/pages/templates/knots/dashboard.html
··· 1 - {{ define "title" }}{{ .Registration.Domain }} &middot; knots{{ end }} 2 3 {{ define "content" }} 4 - <div class="px-6 py-4"> 5 <div class="flex justify-between items-center"> 6 - <h1 class="text-xl font-bold dark:text-white">{{ .Registration.Domain }}</h1> 7 <div id="right-side" class="flex gap-2"> 8 {{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2" }} 9 {{ $isOwner := and .LoggedInUser (eq .LoggedInUser.Did .Registration.ByDid) }} ··· 35 </div> 36 37 {{ if .Members }} 38 - <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 39 <div class="flex flex-col gap-2"> 40 {{ block "member" . }} {{ end }} 41 </div> ··· 79 <button 80 class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 81 title="Delete knot" 82 - hx-delete="/knots/{{ .Domain }}" 83 hx-swap="outerHTML" 84 hx-confirm="Are you sure you want to delete the knot '{{ .Domain }}'?" 85 hx-headers='{"shouldRedirect": "true"}' ··· 95 <button 96 class="btn gap-2 group" 97 title="Retry knot verification" 98 - hx-post="/knots/{{ .Domain }}/retry" 99 hx-swap="none" 100 hx-headers='{"shouldRefresh": "true"}' 101 > ··· 113 <button 114 class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 115 title="Remove member" 116 - hx-post="/knots/{{ $root.Registration.Domain }}/remove" 117 hx-swap="none" 118 hx-vals='{"member": "{{$member}}" }' 119 hx-confirm="Are you sure you want to remove {{ $memberHandle }} from this knot?"
··· 1 + {{ define "title" }}{{ .Registration.Domain }} &middot; {{ .Tab }} settings{{ end }} 2 3 {{ define "content" }} 4 + <div class="p-6"> 5 + <p class="text-xl font-bold dark:text-white">Settings</p> 6 + </div> 7 + <div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 8 + <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6"> 9 + <div class="col-span-1"> 10 + {{ template "user/settings/fragments/sidebar" . }} 11 + </div> 12 + <div class="col-span-1 md:col-span-3 flex flex-col gap-6"> 13 + {{ template "knotDash" . }} 14 + </div> 15 + </section> 16 + </div> 17 + {{ end }} 18 + 19 + {{ define "knotDash" }} 20 + <div> 21 <div class="flex justify-between items-center"> 22 + <h2 class="text-sm pb-2 uppercase font-bold">{{ .Tab }} &middot; {{ .Registration.Domain }}</h2> 23 <div id="right-side" class="flex gap-2"> 24 {{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2" }} 25 {{ $isOwner := and .LoggedInUser (eq .LoggedInUser.Did .Registration.ByDid) }} ··· 51 </div> 52 53 {{ if .Members }} 54 + <section class="bg-white dark:bg-gray-800 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 55 <div class="flex flex-col gap-2"> 56 {{ block "member" . }} {{ end }} 57 </div> ··· 95 <button 96 class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 97 title="Delete knot" 98 + hx-delete="/settings/knots/{{ .Domain }}" 99 hx-swap="outerHTML" 100 hx-confirm="Are you sure you want to delete the knot '{{ .Domain }}'?" 101 hx-headers='{"shouldRedirect": "true"}' ··· 111 <button 112 class="btn gap-2 group" 113 title="Retry knot verification" 114 + hx-post="/settings/knots/{{ .Domain }}/retry" 115 hx-swap="none" 116 hx-headers='{"shouldRefresh": "true"}' 117 > ··· 129 <button 130 class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 131 title="Remove member" 132 + hx-post="/settings/knots/{{ $root.Registration.Domain }}/remove" 133 hx-swap="none" 134 hx-vals='{"member": "{{$member}}" }' 135 hx-confirm="Are you sure you want to remove {{ $memberHandle }} from this knot?"
+1 -1
appview/pages/templates/knots/fragments/addMemberModal.html
··· 22 23 {{ define "addKnotMemberPopover" }} 24 <form 25 - hx-post="/knots/{{ .Domain }}/add" 26 hx-indicator="#spinner" 27 hx-swap="none" 28 class="flex flex-col gap-2"
··· 22 23 {{ define "addKnotMemberPopover" }} 24 <form 25 + hx-post="/settings/knots/{{ .Domain }}/add" 26 hx-indicator="#spinner" 27 hx-swap="none" 28 class="flex flex-col gap-2"
+3 -3
appview/pages/templates/knots/fragments/knotListing.html
··· 7 8 {{ define "knotLeftSide" }} 9 {{ if .Registered }} 10 - <a href="/knots/{{ .Domain }}" class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]"> 11 {{ i "hard-drive" "w-4 h-4" }} 12 <span class="hover:underline"> 13 {{ .Domain }} ··· 56 <button 57 class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 58 title="Delete knot" 59 - hx-delete="/knots/{{ .Domain }}" 60 hx-swap="outerHTML" 61 hx-target="#knot-{{.Id}}" 62 hx-confirm="Are you sure you want to delete the knot '{{ .Domain }}'?" ··· 72 <button 73 class="btn gap-2 group" 74 title="Retry knot verification" 75 - hx-post="/knots/{{ .Domain }}/retry" 76 hx-swap="none" 77 hx-target="#knot-{{.Id}}" 78 >
··· 7 8 {{ define "knotLeftSide" }} 9 {{ if .Registered }} 10 + <a href="/settings/knots/{{ .Domain }}" class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]"> 11 {{ i "hard-drive" "w-4 h-4" }} 12 <span class="hover:underline"> 13 {{ .Domain }} ··· 56 <button 57 class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 58 title="Delete knot" 59 + hx-delete="/settings/knots/{{ .Domain }}" 60 hx-swap="outerHTML" 61 hx-target="#knot-{{.Id}}" 62 hx-confirm="Are you sure you want to delete the knot '{{ .Domain }}'?" ··· 72 <button 73 class="btn gap-2 group" 74 title="Retry knot verification" 75 + hx-post="/settings/knots/{{ .Domain }}/retry" 76 hx-swap="none" 77 hx-target="#knot-{{.Id}}" 78 >
+42 -11
appview/pages/templates/knots/index.html
··· 1 - {{ define "title" }}knots{{ end }} 2 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> 11 12 - <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 13 <div class="flex flex-col gap-6"> 14 - {{ block "about" . }} {{ end }} 15 {{ block "list" . }} {{ end }} 16 {{ block "register" . }} {{ end }} 17 </div> ··· 50 <h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">register a knot</h2> 51 <p class="mb-2 dark:text-gray-300">Enter the hostname of your knot to get started.</p> 52 <form 53 - hx-post="/knots/register" 54 class="max-w-2xl mb-2 space-y-4" 55 hx-indicator="#register-button" 56 hx-swap="none" ··· 84 85 </section> 86 {{ end }}
··· 1 + {{ define "title" }}{{ .Tab }} settings{{ end }} 2 3 {{ define "content" }} 4 + <div class="p-6"> 5 + <p class="text-xl font-bold dark:text-white">Settings</p> 6 + </div> 7 + <div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 8 + <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6"> 9 + <div class="col-span-1"> 10 + {{ template "user/settings/fragments/sidebar" . }} 11 + </div> 12 + <div class="col-span-1 md:col-span-3 flex flex-col gap-6"> 13 + {{ template "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> 29 30 + <section> 31 <div class="flex flex-col gap-6"> 32 {{ block "list" . }} {{ end }} 33 {{ block "register" . }} {{ end }} 34 </div> ··· 67 <h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">register a knot</h2> 68 <p class="mb-2 dark:text-gray-300">Enter the hostname of your knot to get started.</p> 69 <form 70 + hx-post="/settings/knots/register" 71 class="max-w-2xl mb-2 space-y-4" 72 hx-indicator="#register-button" 73 hx-swap="none" ··· 101 102 </section> 103 {{ end }} 104 + 105 + {{ define "docsButton" }} 106 + <a 107 + class="btn flex items-center gap-2" 108 + href="https://docs.tangled.org/knot-self-hosting-guide.html#knot-self-hosting-guide"> 109 + {{ i "book" "size-4" }} 110 + docs 111 + </a> 112 + <div 113 + id="add-email-modal" 114 + popover 115 + class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 116 + </div> 117 + {{ end }}
+4
appview/pages/templates/layouts/base.html
··· 11 <script defer src="/static/htmx-ext-ws.min.js"></script> 12 <script defer src="/static/actor-typeahead.js" type="module"></script> 13 14 <!-- preconnect to image cdn --> 15 <link rel="preconnect" href="https://avatar.tangled.sh" /> 16 <link rel="preconnect" href="https://camo.tangled.sh" />
··· 11 <script defer src="/static/htmx-ext-ws.min.js"></script> 12 <script defer src="/static/actor-typeahead.js" type="module"></script> 13 14 + <link rel="icon" href="/static/logos/dolly.ico" sizes="48x48"/> 15 + <link rel="icon" href="/static/logos/dolly.svg" sizes="any" type="image/svg+xml"/> 16 + <link rel="apple-touch-icon" href="/static/logos/dolly.png"/> 17 + 18 <!-- preconnect to image cdn --> 19 <link rel="preconnect" href="https://avatar.tangled.sh" /> 20 <link rel="preconnect" href="https://camo.tangled.sh" />
+2 -2
appview/pages/templates/layouts/fragments/footer.html
··· 26 <div class="flex flex-col gap-1"> 27 <div class="{{ $headerStyle }}">resources</div> 28 <a href="https://blog.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a> 29 - <a href="https://tangled.org/@tangled.org/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 30 <a href="https://tangled.org/@tangled.org/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a> 31 <a href="https://tangled.org/brand" class="{{ $linkStyle }}">{{ i "paintbrush" $iconStyle }} brand</a> 32 </div> ··· 73 <div class="flex flex-col gap-1"> 74 <div class="{{ $headerStyle }}">resources</div> 75 <a href="https://blog.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a> 76 - <a href="https://tangled.org/@tangled.org/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 77 <a href="https://tangled.org/@tangled.org/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a> 78 <a href="https://tangled.org/brand" class="{{ $linkStyle }}">{{ i "paintbrush" $iconStyle }} brand</a> 79 </div>
··· 26 <div class="flex flex-col gap-1"> 27 <div class="{{ $headerStyle }}">resources</div> 28 <a href="https://blog.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a> 29 + <a href="https://docs.tangled.org" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 30 <a href="https://tangled.org/@tangled.org/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a> 31 <a href="https://tangled.org/brand" class="{{ $linkStyle }}">{{ i "paintbrush" $iconStyle }} brand</a> 32 </div> ··· 73 <div class="flex flex-col gap-1"> 74 <div class="{{ $headerStyle }}">resources</div> 75 <a href="https://blog.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a> 76 + <a href="https://docs.tangled.org" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 77 <a href="https://tangled.org/@tangled.org/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a> 78 <a href="https://tangled.org/brand" class="{{ $linkStyle }}">{{ i "paintbrush" $iconStyle }} brand</a> 79 </div>
+1 -7
appview/pages/templates/layouts/fragments/topbar.html
··· 3 <div class="flex justify-between p-0 items-center"> 4 <div id="left-items"> 5 <a href="/" hx-boost="true" class="text-2xl no-underline hover:no-underline flex items-center gap-2"> 6 - {{ template "fragments/dolly/logo" "size-8 text-black dark:text-white" }} 7 - <span class="font-bold text-xl not-italic hidden md:inline">tangled</span> 8 - <span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1 hidden md:inline"> 9 - alpha 10 - </span> 11 </a> 12 </div> 13 ··· 61 <a href="/{{ $user }}">profile</a> 62 <a href="/{{ $user }}?tab=repos">repositories</a> 63 <a href="/{{ $user }}?tab=strings">strings</a> 64 - <a href="/knots">knots</a> 65 - <a href="/spindles">spindles</a> 66 <a href="/settings">settings</a> 67 <a href="#" 68 hx-post="/logout"
··· 3 <div class="flex justify-between p-0 items-center"> 4 <div id="left-items"> 5 <a href="/" hx-boost="true" class="text-2xl no-underline hover:no-underline flex items-center gap-2"> 6 + {{ template "fragments/logotypeSmall" }} 7 </a> 8 </div> 9 ··· 57 <a href="/{{ $user }}">profile</a> 58 <a href="/{{ $user }}?tab=repos">repositories</a> 59 <a href="/{{ $user }}?tab=strings">strings</a> 60 <a href="/settings">settings</a> 61 <a href="#" 62 hx-post="/logout"
+8 -7
appview/pages/templates/layouts/profilebase.html
··· 1 - {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }}{{ end }} 2 3 {{ define "extrameta" }} 4 - {{ $avatarUrl := fullAvatar .Card.UserHandle }} 5 - <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" /> 6 <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 }}" /> 9 <meta property="og:image" content="{{ $avatarUrl }}" /> 10 <meta property="og:image:width" content="512" /> 11 <meta property="og:image:height" content="512" /> 12 13 <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 }}" /> 16 <meta name="twitter:image" content="{{ $avatarUrl }}" /> 17 {{ end }} 18
··· 1 + {{ define "title" }}{{ resolve .Card.UserDid }}{{ end }} 2 3 {{ define "extrameta" }} 4 + {{ $handle := resolve .Card.UserDid }} 5 + {{ $avatarUrl := fullAvatar $handle }} 6 + <meta property="og:title" content="{{ $handle }}" /> 7 <meta property="og:type" content="profile" /> 8 + <meta property="og:url" content="https://tangled.org/{{ $handle }}?tab={{ .Active }}" /> 9 + <meta property="og:description" content="{{ or .Card.Profile.Description $handle }}" /> 10 <meta property="og:image" content="{{ $avatarUrl }}" /> 11 <meta property="og:image:width" content="512" /> 12 <meta property="og:image:height" content="512" /> 13 14 <meta name="twitter:card" content="summary" /> 15 + <meta name="twitter:title" content="{{ $handle }}" /> 16 + <meta name="twitter:description" content="{{ or .Card.Profile.Description $handle }}" /> 17 <meta name="twitter:image" content="{{ $avatarUrl }}" /> 18 {{ end }} 19
+4 -1
appview/pages/templates/layouts/repobase.html
··· 49 </div> 50 51 <div class="w-full sm:w-fit grid grid-cols-3 gap-2 z-auto"> 52 - {{ template "repo/fragments/repoStar" .RepoInfo }} 53 <a 54 class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group" 55 hx-boost="true"
··· 49 </div> 50 51 <div class="w-full sm:w-fit grid grid-cols-3 gap-2 z-auto"> 52 + {{ template "fragments/starBtn" 53 + (dict "SubjectAt" .RepoInfo.RepoAt 54 + "IsStarred" .RepoInfo.IsStarred 55 + "StarCount" .RepoInfo.Stats.StarCount) }} 56 <a 57 class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group" 58 hx-boost="true"
+35 -10
appview/pages/templates/repo/commit.html
··· 25 </div> 26 27 <div class="flex flex-wrap items-center space-x-2"> 28 - <p class="flex flex-wrap items-center gap-2 text-sm text-gray-500 dark:text-gray-300"> 29 - {{ $did := index $.EmailToDid $commit.Author.Email }} 30 - 31 - {{ if $did }} 32 - {{ template "user/fragments/picHandleLink" $did }} 33 - {{ else }} 34 - <a href="mailto:{{ $commit.Author.Email }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ $commit.Author.Name }}</a> 35 - {{ end }} 36 37 <span class="px-1 select-none before:content-['\00B7']"></span> 38 - {{ template "repo/fragments/time" $commit.Author.When }} 39 <span class="px-1 select-none before:content-['\00B7']"></span> 40 41 <a href="/{{ $repo }}/commit/{{ $commit.This }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ slice $commit.This 0 8 }}</a> ··· 79 </section> 80 {{end}} 81 82 {{ define "topbarLayout" }} 83 <header class="col-span-full" style="z-index: 20;"> 84 {{ template "layouts/fragments/topbar" . }} ··· 111 {{ end }} 112 113 {{ define "contentAfter" }} 114 - {{ template "repo/fragments/diff" (list .RepoInfo.FullName .Diff .DiffOpts) }} 115 {{end}} 116 117 {{ define "contentAfterLeft" }}
··· 25 </div> 26 27 <div class="flex flex-wrap items-center space-x-2"> 28 + <p class="flex flex-wrap items-center gap-1 text-sm text-gray-500 dark:text-gray-300"> 29 + {{ template "attribution" . }} 30 31 <span class="px-1 select-none before:content-['\00B7']"></span> 32 + {{ template "repo/fragments/time" $commit.Committer.When }} 33 <span class="px-1 select-none before:content-['\00B7']"></span> 34 35 <a href="/{{ $repo }}/commit/{{ $commit.This }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ slice $commit.This 0 8 }}</a> ··· 73 </section> 74 {{end}} 75 76 + {{ define "attribution" }} 77 + {{ $commit := .Diff.Commit }} 78 + {{ $showCommitter := true }} 79 + {{ if eq $commit.Author.Email $commit.Committer.Email }} 80 + {{ $showCommitter = false }} 81 + {{ end }} 82 + 83 + {{ if $showCommitter }} 84 + authored by {{ template "attributedUser" (list $commit.Author.Email $commit.Author.Name $.EmailToDid) }} 85 + {{ range $commit.CoAuthors }} 86 + {{ template "attributedUser" (list .Email .Name $.EmailToDid) }} 87 + {{ end }} 88 + and committed by {{ template "attributedUser" (list $commit.Committer.Email $commit.Committer.Name $.EmailToDid) }} 89 + {{ else }} 90 + {{ template "attributedUser" (list $commit.Author.Email $commit.Author.Name $.EmailToDid )}} 91 + {{ end }} 92 + {{ end }} 93 + 94 + {{ define "attributedUser" }} 95 + {{ $email := index . 0 }} 96 + {{ $name := index . 1 }} 97 + {{ $map := index . 2 }} 98 + {{ $did := index $map $email }} 99 + 100 + {{ if $did }} 101 + {{ template "user/fragments/picHandleLink" $did }} 102 + {{ else }} 103 + <a href="mailto:{{ $email }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ $name }}</a> 104 + {{ end }} 105 + {{ end }} 106 + 107 {{ define "topbarLayout" }} 108 <header class="col-span-full" style="z-index: 20;"> 109 {{ template "layouts/fragments/topbar" . }} ··· 136 {{ end }} 137 138 {{ define "contentAfter" }} 139 + {{ template "repo/fragments/diff" (list .Diff .DiffOpts) }} 140 {{end}} 141 142 {{ define "contentAfterLeft" }}
+1 -1
appview/pages/templates/repo/compare/compare.html
··· 42 {{ end }} 43 44 {{ define "contentAfter" }} 45 - {{ template "repo/fragments/diff" (list .RepoInfo.FullName .Diff .DiffOpts) }} 46 {{end}} 47 48 {{ define "contentAfterLeft" }}
··· 42 {{ end }} 43 44 {{ define "contentAfter" }} 45 + {{ template "repo/fragments/diff" (list .Diff .DiffOpts) }} 46 {{end}} 47 48 {{ define "contentAfterLeft" }}
+2 -2
appview/pages/templates/repo/empty.html
··· 26 {{ else if (and .LoggedInUser (eq .LoggedInUser.Did .RepoInfo.OwnerDid)) }} 27 {{ $knot := .RepoInfo.Knot }} 28 {{ if eq $knot "knot1.tangled.sh" }} 29 - {{ $knot = "tangled.sh" }} 30 {{ end }} 31 <div class="w-full flex place-content-center"> 32 <div class="py-6 w-fit flex flex-col gap-4"> ··· 35 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 <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> 39 <p><span class="{{$bullet}}">4</span>Push!</p> 40 </div> 41 </div>
··· 26 {{ else if (and .LoggedInUser (eq .LoggedInUser.Did .RepoInfo.OwnerDid)) }} 27 {{ $knot := .RepoInfo.Knot }} 28 {{ if eq $knot "knot1.tangled.sh" }} 29 + {{ $knot = "tangled.org" }} 30 {{ end }} 31 <div class="w-full flex place-content-center"> 32 <div class="py-6 w-fit flex flex-col gap-4"> ··· 35 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 <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 }}:{{ resolve .RepoInfo.OwnerDid }}/{{ .RepoInfo.Name }}</code></p> 39 <p><span class="{{$bullet}}">4</span>Push!</p> 40 </div> 41 </div>
+2 -1
appview/pages/templates/repo/fork.html
··· 25 value="{{ . }}" 26 class="mr-2" 27 id="domain-{{ . }}" 28 /> 29 <label for="domain-{{ . }}" class="dark:text-white">{{ . }}</label> 30 </div> ··· 33 {{ end }} 34 </div> 35 </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 </fieldset> 38 39 <div class="space-y-2">
··· 25 value="{{ . }}" 26 class="mr-2" 27 id="domain-{{ . }}" 28 + {{if eq (len $.Knots) 1}}checked{{end}} 29 /> 30 <label for="domain-{{ . }}" class="dark:text-white">{{ . }}</label> 31 </div> ··· 34 {{ end }} 35 </div> 36 </div> 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> 38 </fieldset> 39 40 <div class="space-y-2">
+49
appview/pages/templates/repo/fragments/backlinks.html
···
··· 1 + {{ define "repo/fragments/backlinks" }} 2 + {{ if .Backlinks }} 3 + <div id="at-uri-panel" class="px-2 md:px-0"> 4 + <div> 5 + <span class="text-sm py-1 font-bold text-gray-500 dark:text-gray-400">Referenced by</span> 6 + </div> 7 + <ul> 8 + {{ range .Backlinks }} 9 + <li> 10 + {{ $repoOwner := resolve .Handle }} 11 + {{ $repoName := .Repo }} 12 + {{ $repoUrl := printf "%s/%s" $repoOwner $repoName }} 13 + <div class="flex flex-col"> 14 + <div class="flex gap-2 items-center"> 15 + {{ if .State.IsClosed }} 16 + <span class="text-gray-500 dark:text-gray-400"> 17 + {{ i "ban" "size-3" }} 18 + </span> 19 + {{ else if eq .Kind.String "issues" }} 20 + <span class="text-green-600 dark:text-green-500"> 21 + {{ i "circle-dot" "size-3" }} 22 + </span> 23 + {{ else if .State.IsOpen }} 24 + <span class="text-green-600 dark:text-green-500"> 25 + {{ i "git-pull-request" "size-3" }} 26 + </span> 27 + {{ else if .State.IsMerged }} 28 + <span class="text-purple-600 dark:text-purple-500"> 29 + {{ i "git-merge" "size-3" }} 30 + </span> 31 + {{ else }} 32 + <span class="text-gray-600 dark:text-gray-300"> 33 + {{ i "git-pull-request-closed" "size-3" }} 34 + </span> 35 + {{ end }} 36 + <a href="{{ . }}" class="line-clamp-1 text-sm"><span class="text-gray-500 dark:text-gray-400">#{{ .SubjectId }}</span> {{ .Title }}</a> 37 + </div> 38 + {{ if not (eq $.RepoInfo.FullName $repoUrl) }} 39 + <div> 40 + <span>on <a href="/{{ $repoUrl }}">{{ $repoUrl }}</a></span> 41 + </div> 42 + {{ end }} 43 + </div> 44 + </li> 45 + {{ end }} 46 + </ul> 47 + </div> 48 + {{ end }} 49 + {{ end }}
+3 -2
appview/pages/templates/repo/fragments/cloneDropdown.html
··· 43 44 <!-- SSH Clone --> 45 <div class="mb-3"> 46 <label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">SSH</label> 47 <div class="flex items-center border border-gray-300 dark:border-gray-600 rounded"> 48 <code 49 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 onclick="window.getSelection().selectAllChildren(this)" 51 - data-url="git@{{ $knot | stripPort }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}" 52 - >git@{{ $knot | stripPort }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code> 53 <button 54 onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))" 55 class="px-3 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 border-l border-gray-300 dark:border-gray-600"
··· 43 44 <!-- SSH Clone --> 45 <div class="mb-3"> 46 + {{ $repoOwnerHandle := resolve .RepoInfo.OwnerDid }} 47 <label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">SSH</label> 48 <div class="flex items-center border border-gray-300 dark:border-gray-600 rounded"> 49 <code 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" 51 onclick="window.getSelection().selectAllChildren(this)" 52 + data-url="git@{{ $knot | stripPort }}:{{ $repoOwnerHandle }}/{{ .RepoInfo.Name }}" 53 + >git@{{ $knot | stripPort }}:{{ $repoOwnerHandle }}/{{ .RepoInfo.Name }}</code> 54 <button 55 onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))" 56 class="px-3 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 border-l border-gray-300 dark:border-gray-600"
+3 -4
appview/pages/templates/repo/fragments/diff.html
··· 1 {{ define "repo/fragments/diff" }} 2 - {{ $repo := index . 0 }} 3 - {{ $diff := index . 1 }} 4 - {{ $opts := index . 2 }} 5 6 {{ $commit := $diff.Commit }} 7 {{ $diff := $diff.Diff }} ··· 18 {{ else }} 19 {{ range $idx, $hunk := $diff }} 20 {{ with $hunk }} 21 - <details open id="file-{{ .Name.New }}" class="group border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm" tabindex="{{ add $idx 1 }}"> 22 <summary class="list-none cursor-pointer sticky top-0"> 23 <div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between"> 24 <div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto">
··· 1 {{ define "repo/fragments/diff" }} 2 + {{ $diff := index . 0 }} 3 + {{ $opts := index . 1 }} 4 5 {{ $commit := $diff.Commit }} 6 {{ $diff := $diff.Diff }} ··· 17 {{ else }} 18 {{ range $idx, $hunk := $diff }} 19 {{ with $hunk }} 20 + <details open id="file-{{ .Id }}" class="group border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm" tabindex="{{ add $idx 1 }}"> 21 <summary class="list-none cursor-pointer sticky top-0"> 22 <div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between"> 23 <div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto">
+15 -1
appview/pages/templates/repo/fragments/editLabelPanel.html
··· 170 {{ $fieldName := $def.AtUri }} 171 {{ $valueType := $def.ValueType }} 172 {{ $value := .value }} 173 {{ if $valueType.IsDidFormat }} 174 {{ $value = trimPrefix (resolve .value) "@" }} 175 {{ end }} 176 - <input class="p-1 w-full" type="text" name="{{$fieldName}}" value="{{$value}}"> 177 {{ end }} 178 179 {{ define "nullTypeInput" }}
··· 170 {{ $fieldName := $def.AtUri }} 171 {{ $valueType := $def.ValueType }} 172 {{ $value := .value }} 173 + 174 {{ if $valueType.IsDidFormat }} 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}}"> 190 {{ end }} 191 {{ end }} 192 193 {{ define "nullTypeInput" }}
+1 -16
appview/pages/templates/repo/fragments/participants.html
··· 6 <span class="font-bold text-gray-500 dark:text-gray-400 capitalize">Participants</span> 7 <span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 ml-1">{{ len $all }}</span> 8 </div> 9 - <div class="flex items-center -space-x-3 mt-2"> 10 - {{ $c := "z-50 z-40 z-30 z-20 z-10" }} 11 - {{ range $i, $p := $ps }} 12 - <img 13 - src="{{ tinyAvatar . }}" 14 - alt="" 15 - class="rounded-full h-8 w-8 mr-1 border-2 border-gray-100 dark:border-gray-900 z-{{sub 5 $i}}0" 16 - /> 17 - {{ end }} 18 - 19 - {{ if gt (len $all) 5 }} 20 - <span class="pl-4 text-gray-500 dark:text-gray-400 text-sm"> 21 - +{{ sub (len $all) 5 }} 22 - </span> 23 - {{ end }} 24 - </div> 25 </div> 26 {{ end }}
··· 6 <span class="font-bold text-gray-500 dark:text-gray-400 capitalize">Participants</span> 7 <span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 ml-1">{{ len $all }}</span> 8 </div> 9 + {{ template "fragments/tinyAvatarList" (dict "all" $all "classes" "w-8 h-8") }} 10 </div> 11 {{ end }}
-26
appview/pages/templates/repo/fragments/repoStar.html
··· 1 - {{ define "repo/fragments/repoStar" }} 2 - <button 3 - id="starBtn" 4 - class="btn disabled:opacity-50 disabled:cursor-not-allowed flex gap-2 items-center group" 5 - {{ if .IsStarred }} 6 - hx-delete="/star?subject={{ .RepoAt }}&countHint={{ .Stats.StarCount }}" 7 - {{ else }} 8 - hx-post="/star?subject={{ .RepoAt }}&countHint={{ .Stats.StarCount }}" 9 - {{ end }} 10 - 11 - hx-trigger="click" 12 - hx-target="this" 13 - hx-swap="outerHTML" 14 - hx-disabled-elt="#starBtn" 15 - > 16 - {{ if .IsStarred }} 17 - {{ i "star" "w-4 h-4 fill-current" }} 18 - {{ else }} 19 - {{ i "star" "w-4 h-4" }} 20 - {{ end }} 21 - <span class="text-sm"> 22 - {{ .Stats.StarCount }} 23 - </span> 24 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 25 - </button> 26 - {{ end }}
···
+35 -35
appview/pages/templates/repo/fragments/splitDiff.html
··· 3 {{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800" -}} 4 {{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}} 5 {{- $lineNrSepStyle := "pr-2 border-r border-gray-200 dark:border-gray-700" -}} 6 - {{- $containerStyle := "flex min-w-full items-center target:border target:rounded-sm target:border-yellow-200 target:dark:border-yellow-700 scroll-mt-20" -}} 7 {{- $emptyStyle := "bg-gray-200/30 dark:bg-gray-700/30" -}} 8 {{- $addStyle := "bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400" -}} 9 {{- $delStyle := "bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 " -}} 10 {{- $ctxStyle := "bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400" -}} 11 {{- $opStyle := "w-5 flex-shrink-0 select-none text-center" -}} 12 <div class="grid grid-cols-2 divide-x divide-gray-200 dark:divide-gray-700"> 13 - <pre class="overflow-x-auto col-span-1"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}<div class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">&middot;&middot;&middot;</div> 14 {{- range .LeftLines -}} 15 {{- if .IsEmpty -}} 16 - <div class="{{ $emptyStyle }} {{ $containerStyle }}"> 17 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><span aria-hidden="true" class="invisible">{{.LineNumber}}</span></div> 18 - <div class="{{ $opStyle }}"><span aria-hidden="true" class="invisible">{{ .Op.String }}</span></div> 19 - <div class="px-2 invisible" aria-hidden="true">{{ .Content }}</div> 20 - </div> 21 {{- else if eq .Op.String "-" -}} 22 - <div class="{{ $delStyle }} {{ $containerStyle }}" id="{{$name}}-O{{.LineNumber}}"> 23 - <div class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{.LineNumber}}">{{ .LineNumber }}</a></div> 24 - <div class="{{ $opStyle }}">{{ .Op.String }}</div> 25 - <div class="px-2">{{ .Content }}</div> 26 - </div> 27 {{- else if eq .Op.String " " -}} 28 - <div class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-O{{.LineNumber}}"> 29 - <div class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{.LineNumber}}">{{ .LineNumber }}</a></div> 30 - <div class="{{ $opStyle }}">{{ .Op.String }}</div> 31 - <div class="px-2">{{ .Content }}</div> 32 - </div> 33 {{- end -}} 34 {{- end -}} 35 - {{- end -}}</div></div></pre> 36 37 - <pre class="overflow-x-auto col-span-1"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}<div class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">&middot;&middot;&middot;</div> 38 {{- range .RightLines -}} 39 {{- if .IsEmpty -}} 40 - <div class="{{ $emptyStyle }} {{ $containerStyle }}"> 41 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><span aria-hidden="true" class="invisible">{{.LineNumber}}</span></div> 42 - <div class="{{ $opStyle }}"><span aria-hidden="true" class="invisible">{{ .Op.String }}</span></div> 43 - <div class="px-2 invisible" aria-hidden="true">{{ .Content }}</div> 44 - </div> 45 {{- else if eq .Op.String "+" -}} 46 - <div class="{{ $addStyle }} {{ $containerStyle }}" id="{{$name}}-N{{.LineNumber}}"> 47 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{.LineNumber}}">{{ .LineNumber }}</a></div> 48 - <div class="{{ $opStyle }}">{{ .Op.String }}</div> 49 - <div class="px-2" >{{ .Content }}</div> 50 - </div> 51 {{- else if eq .Op.String " " -}} 52 - <div class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-N{{.LineNumber}}"> 53 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{.LineNumber}}">{{ .LineNumber }}</a></div> 54 - <div class="{{ $opStyle }}">{{ .Op.String }}</div> 55 - <div class="px-2">{{ .Content }}</div> 56 - </div> 57 {{- end -}} 58 {{- end -}} 59 - {{- end -}}</div></div></pre> 60 </div> 61 {{ end }}
··· 3 {{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800" -}} 4 {{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}} 5 {{- $lineNrSepStyle := "pr-2 border-r border-gray-200 dark:border-gray-700" -}} 6 + {{- $containerStyle := "inline-flex w-full items-center target:border target:rounded-sm target:border-yellow-200 target:dark:border-yellow-700 scroll-mt-20" -}} 7 {{- $emptyStyle := "bg-gray-200/30 dark:bg-gray-700/30" -}} 8 {{- $addStyle := "bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400" -}} 9 {{- $delStyle := "bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 " -}} 10 {{- $ctxStyle := "bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400" -}} 11 {{- $opStyle := "w-5 flex-shrink-0 select-none text-center" -}} 12 <div class="grid grid-cols-2 divide-x divide-gray-200 dark:divide-gray-700"> 13 + <div class="overflow-x-auto col-span-1 font-mono leading-normal"><div class="overflow-x-auto"><div class="inline-flex flex-col min-w-full">{{- range .TextFragments -}}<span class="block bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">&middot;&middot;&middot;</span> 14 {{- range .LeftLines -}} 15 {{- if .IsEmpty -}} 16 + <span class="{{ $emptyStyle }} {{ $containerStyle }}"> 17 + <span class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><span aria-hidden="true" class="invisible">{{.LineNumber}}</span></span> 18 + <span class="{{ $opStyle }}"><span aria-hidden="true" class="invisible">{{ .Op.String }}</span></span> 19 + <span class="px-2 invisible" aria-hidden="true">{{ .Content }}</span> 20 + </span> 21 {{- else if eq .Op.String "-" -}} 22 + <span class="{{ $delStyle }} {{ $containerStyle }}" id="{{$name}}-O{{.LineNumber}}"> 23 + <span class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{.LineNumber}}">{{ .LineNumber }}</a></span> 24 + <span class="{{ $opStyle }}">{{ .Op.String }}</span> 25 + <span class="px-2 whitespace-pre">{{ .Content }}</span> 26 + </span> 27 {{- else if eq .Op.String " " -}} 28 + <span class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-O{{.LineNumber}}"> 29 + <span class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{.LineNumber}}">{{ .LineNumber }}</a></span> 30 + <span class="{{ $opStyle }}">{{ .Op.String }}</span> 31 + <span class="px-2 whitespace-pre">{{ .Content }}</span> 32 + </span> 33 {{- end -}} 34 {{- end -}} 35 + {{- end -}}</div></div></div> 36 37 + <div class="overflow-x-auto col-span-1 font-mono leading-normal"><div class="overflow-x-auto"><div class="inline-flex flex-col min-w-full">{{- range .TextFragments -}}<span class="block bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">&middot;&middot;&middot;</span> 38 {{- range .RightLines -}} 39 {{- if .IsEmpty -}} 40 + <span class="{{ $emptyStyle }} {{ $containerStyle }}"> 41 + <span class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><span aria-hidden="true" class="invisible">{{.LineNumber}}</span></span> 42 + <span class="{{ $opStyle }}"><span aria-hidden="true" class="invisible">{{ .Op.String }}</span></span> 43 + <span class="px-2 invisible" aria-hidden="true">{{ .Content }}</span> 44 + </span> 45 {{- else if eq .Op.String "+" -}} 46 + <span class="{{ $addStyle }} {{ $containerStyle }}" id="{{$name}}-N{{.LineNumber}}"> 47 + <span class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{.LineNumber}}">{{ .LineNumber }}</a></span> 48 + <span class="{{ $opStyle }}">{{ .Op.String }}</span> 49 + <span class="px-2 whitespace-pre">{{ .Content }}</span> 50 + </span> 51 {{- else if eq .Op.String " " -}} 52 + <span class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-N{{.LineNumber}}"> 53 + <span class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{.LineNumber}}">{{ .LineNumber }}</a> </span> 54 + <span class="{{ $opStyle }}">{{ .Op.String }}</span> 55 + <span class="px-2 whitespace-pre">{{ .Content }}</span> 56 + </span> 57 {{- end -}} 58 {{- end -}} 59 + {{- end -}}</div></div></div> 60 </div> 61 {{ end }}
+21 -22
appview/pages/templates/repo/fragments/unifiedDiff.html
··· 1 {{ define "repo/fragments/unifiedDiff" }} 2 {{ $name := .Id }} 3 - <pre class="overflow-x-auto"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}<div class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">&middot;&middot;&middot;</div> 4 {{- $oldStart := .OldPosition -}} 5 {{- $newStart := .NewPosition -}} 6 {{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800 target:bg-yellow-200 target:dark:bg-yellow-600" -}} 7 {{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}} 8 {{- $lineNrSepStyle1 := "" -}} 9 {{- $lineNrSepStyle2 := "pr-2 border-r border-gray-200 dark:border-gray-700" -}} 10 - {{- $containerStyle := "flex min-w-full items-center target:border target:rounded-sm target:border-yellow-200 target:dark:border-yellow-700 scroll-mt-20" -}} 11 {{- $addStyle := "bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400 " -}} 12 {{- $delStyle := "bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 " -}} 13 {{- $ctxStyle := "bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400" -}} 14 {{- $opStyle := "w-5 flex-shrink-0 select-none text-center" -}} 15 {{- range .Lines -}} 16 {{- if eq .Op.String "+" -}} 17 - <div class="{{ $addStyle }} {{ $containerStyle }}" id="{{$name}}-N{{$newStart}}"> 18 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><span aria-hidden="true" class="invisible">{{$newStart}}</span></div> 19 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></div> 20 - <div class="{{ $opStyle }}">{{ .Op.String }}</div> 21 - <div class="px-2">{{ .Line }}</div> 22 - </div> 23 {{- $newStart = add64 $newStart 1 -}} 24 {{- end -}} 25 {{- if eq .Op.String "-" -}} 26 - <div class="{{ $delStyle }} {{ $containerStyle }}" id="{{$name}}-O{{$oldStart}}"> 27 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></div> 28 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><span aria-hidden="true" class="invisible">{{$oldStart}}</span></div> 29 - <div class="{{ $opStyle }}">{{ .Op.String }}</div> 30 - <div class="px-2">{{ .Line }}</div> 31 - </div> 32 {{- $oldStart = add64 $oldStart 1 -}} 33 {{- end -}} 34 {{- if eq .Op.String " " -}} 35 - <div class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-O{{$oldStart}}-N{{$newStart}}"> 36 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}-N{{$newStart}}">{{ $oldStart }}</a></div> 37 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}-N{{$newStart}}">{{ $newStart }}</a></div> 38 - <div class="{{ $opStyle }}">{{ .Op.String }}</div> 39 - <div class="px-2">{{ .Line }}</div> 40 - </div> 41 {{- $newStart = add64 $newStart 1 -}} 42 {{- $oldStart = add64 $oldStart 1 -}} 43 {{- end -}} 44 {{- end -}} 45 - {{- end -}}</div></div></pre> 46 {{ end }} 47 -
··· 1 {{ define "repo/fragments/unifiedDiff" }} 2 {{ $name := .Id }} 3 + <div class="overflow-x-auto font-mono leading-normal"><div class="overflow-x-auto"><div class="inline-flex flex-col min-w-full">{{- range .TextFragments -}}<span class="block bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">&middot;&middot;&middot;</span> 4 {{- $oldStart := .OldPosition -}} 5 {{- $newStart := .NewPosition -}} 6 {{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800 target:bg-yellow-200 target:dark:bg-yellow-600" -}} 7 {{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}} 8 {{- $lineNrSepStyle1 := "" -}} 9 {{- $lineNrSepStyle2 := "pr-2 border-r border-gray-200 dark:border-gray-700" -}} 10 + {{- $containerStyle := "inline-flex w-full items-center target:border target:rounded-sm target:border-yellow-200 target:dark:border-yellow-700 scroll-mt-20" -}} 11 {{- $addStyle := "bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400 " -}} 12 {{- $delStyle := "bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 " -}} 13 {{- $ctxStyle := "bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400" -}} 14 {{- $opStyle := "w-5 flex-shrink-0 select-none text-center" -}} 15 {{- range .Lines -}} 16 {{- if eq .Op.String "+" -}} 17 + <span class="{{ $addStyle }} {{ $containerStyle }}" id="{{$name}}-N{{$newStart}}"> 18 + <span class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><span aria-hidden="true" class="invisible">{{$newStart}}</span></span> 19 + <span class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></span> 20 + <span class="{{ $opStyle }}">{{ .Op.String }}</span> 21 + <span class="px-2 whitespace-pre">{{ .Line }}</span> 22 + </span> 23 {{- $newStart = add64 $newStart 1 -}} 24 {{- end -}} 25 {{- if eq .Op.String "-" -}} 26 + <span class="{{ $delStyle }} {{ $containerStyle }}" id="{{$name}}-O{{$oldStart}}"> 27 + <span class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></span> 28 + <span class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><span aria-hidden="true" class="invisible">{{$oldStart}}</span></span> 29 + <span class="{{ $opStyle }}">{{ .Op.String }}</span> 30 + <span class="px-2 whitespace-pre">{{ .Line }}</span> 31 + </span> 32 {{- $oldStart = add64 $oldStart 1 -}} 33 {{- end -}} 34 {{- if eq .Op.String " " -}} 35 + <span class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-O{{$oldStart}}-N{{$newStart}}"> 36 + <span class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}-N{{$newStart}}">{{ $oldStart }}</a></span> 37 + <span class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}-N{{$newStart}}">{{ $newStart }}</a></span> 38 + <span class="{{ $opStyle }}">{{ .Op.String }}</span> 39 + <span class="px-2 whitespace-pre">{{ .Line }}</span> 40 + </span> 41 {{- $newStart = add64 $newStart 1 -}} 42 {{- $oldStart = add64 $oldStart 1 -}} 43 {{- end -}} 44 {{- end -}} 45 + {{- end -}}</div></div></div> 46 {{ end }}
+31 -9
appview/pages/templates/repo/index.html
··· 14 {{ end }} 15 <div class="flex items-center justify-between pb-5"> 16 {{ block "branchSelector" . }}{{ end }} 17 - <div class="flex md:hidden items-center gap-2"> 18 <a href="/{{ .RepoInfo.FullName }}/commits/{{ .Ref | urlquery }}" class="inline-flex items-center text-sm gap-1 font-bold"> 19 {{ i "git-commit-horizontal" "w-4" "h-4" }} {{ .TotalCommits }} 20 </a> ··· 47 <div class="px-4 py-2 border-b border-gray-200 dark:border-gray-600 flex items-center gap-4 flex-wrap"> 48 {{ range $value := .Languages }} 49 <div 50 - class="flex flex-grow items-center gap-2 text-xs align-items-center justify-center" 51 > 52 {{ template "repo/fragments/colorBall" (dict "color" (langColor $value.Name)) }} 53 <div>{{ or $value.Name "Other" }} ··· 66 67 {{ define "branchSelector" }} 68 <div class="flex gap-2 items-center justify-between w-full"> 69 - <div class="flex gap-2 items-center"> 70 <select 71 onchange="window.location.href = '/{{ .RepoInfo.FullName }}/tree/' + encodeURIComponent(this.value)" 72 class="p-1 border max-w-32 border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700" ··· 228 <span 229 class="mx-1 before:content-['ยท'] before:select-none" 230 ></span> 231 - <span> 232 - {{ $did := index $.EmailToDid .Author.Email }} 233 - <a href="{{ if $did }}/{{ resolve $did }}{{ else }}mailto:{{ .Author.Email }}{{ end }}" 234 - class="text-gray-500 dark:text-gray-400 no-underline hover:underline" 235 - >{{ if $did }}{{ template "user/fragments/picHandleLink" $did }}{{ else }}{{ .Author.Name }}{{ end }}</a> 236 - </span> 237 <div class="inline-block px-1 select-none after:content-['ยท']"></div> 238 {{ template "repo/fragments/time" .Committer.When }} 239 ··· 259 {{ end }} 260 </div> 261 </div> 262 {{ end }} 263 264 {{ define "branchList" }}
··· 14 {{ end }} 15 <div class="flex items-center justify-between pb-5"> 16 {{ block "branchSelector" . }}{{ end }} 17 + <div class="flex md:hidden items-center gap-3"> 18 <a href="/{{ .RepoInfo.FullName }}/commits/{{ .Ref | urlquery }}" class="inline-flex items-center text-sm gap-1 font-bold"> 19 {{ i "git-commit-horizontal" "w-4" "h-4" }} {{ .TotalCommits }} 20 </a> ··· 47 <div class="px-4 py-2 border-b border-gray-200 dark:border-gray-600 flex items-center gap-4 flex-wrap"> 48 {{ range $value := .Languages }} 49 <div 50 + class="flex items-center gap-2 text-xs align-items-center justify-center" 51 > 52 {{ template "repo/fragments/colorBall" (dict "color" (langColor $value.Name)) }} 53 <div>{{ or $value.Name "Other" }} ··· 66 67 {{ define "branchSelector" }} 68 <div class="flex gap-2 items-center justify-between w-full"> 69 + <div class="flex gap-2 items-stretch"> 70 <select 71 onchange="window.location.href = '/{{ .RepoInfo.FullName }}/tree/' + encodeURIComponent(this.value)" 72 class="p-1 border max-w-32 border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700" ··· 228 <span 229 class="mx-1 before:content-['ยท'] before:select-none" 230 ></span> 231 + {{ template "attribution" (list . $.EmailToDid) }} 232 <div class="inline-block px-1 select-none after:content-['ยท']"></div> 233 {{ template "repo/fragments/time" .Committer.When }} 234 ··· 254 {{ end }} 255 </div> 256 </div> 257 + {{ end }} 258 + 259 + {{ define "attribution" }} 260 + {{ $commit := index . 0 }} 261 + {{ $map := index . 1 }} 262 + <span class="flex items-center"> 263 + {{ $author := index $map $commit.Author.Email }} 264 + {{ $coauthors := $commit.CoAuthors }} 265 + {{ $all := list }} 266 + 267 + {{ if $author }} 268 + {{ $all = append $all $author }} 269 + {{ end }} 270 + {{ range $coauthors }} 271 + {{ $co := index $map .Email }} 272 + {{ if $co }} 273 + {{ $all = append $all $co }} 274 + {{ end }} 275 + {{ end }} 276 + 277 + {{ template "fragments/tinyAvatarList" (dict "all" $all "classes" "size-6") }} 278 + <a href="{{ if $author }}/{{ $author }}{{ else }}mailto:{{ $commit.Author.Email }}{{ end }}" 279 + class="no-underline hover:underline"> 280 + {{ if $author }}{{ resolve $author }}{{ else }}{{ $commit.Author.Name }}{{ end }} 281 + {{ if $coauthors }} +{{ length $coauthors }}{{ end }} 282 + </a> 283 + </span> 284 {{ end }} 285 286 {{ define "branchList" }}
+2 -2
appview/pages/templates/repo/issues/fragments/issueCommentHeader.html
··· 19 {{ end }} 20 21 {{ define "timestamp" }} 22 - <a href="#{{ .Comment.Id }}" 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 }}"> 25 {{ if .Comment.Deleted }} 26 {{ template "repo/fragments/shortTimeAgo" .Comment.Deleted }} 27 {{ else if .Comment.Edited }}
··· 19 {{ end }} 20 21 {{ define "timestamp" }} 22 + <a href="#comment-{{ .Comment.Id }}" 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-{{ .Comment.Id }}"> 25 {{ if .Comment.Deleted }} 26 {{ template "repo/fragments/shortTimeAgo" .Comment.Deleted }} 27 {{ else if .Comment.Edited }}
+3
appview/pages/templates/repo/issues/issue.html
··· 20 "Subject" $.Issue.AtUri 21 "State" $.Issue.Labels) }} 22 {{ template "repo/fragments/participants" $.Issue.Participants }} 23 {{ template "repo/fragments/externalLinkPanel" $.Issue.AtUri }} 24 </div> 25 </div>
··· 20 "Subject" $.Issue.AtUri 21 "State" $.Issue.Labels) }} 22 {{ template "repo/fragments/participants" $.Issue.Participants }} 23 + {{ template "repo/fragments/backlinks" 24 + (dict "RepoInfo" $.RepoInfo 25 + "Backlinks" $.Backlinks) }} 26 {{ template "repo/fragments/externalLinkPanel" $.Issue.AtUri }} 27 </div> 28 </div>
+97 -27
appview/pages/templates/repo/issues/issues.html
··· 32 <input type="hidden" name="state" value="{{ if .FilteringByOpen }}open{{ else }}closed{{ end }}"> 33 <div class="flex-1 flex relative"> 34 <input 35 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" 36 type="text" 37 name="q" ··· 53 </button> 54 </form> 55 <div class="sm:row-start-1"> 56 - {{ template "fragments/tabSelector" (dict "Name" "state" "Values" $values "Active" $active) }} 57 </div> 58 <a 59 href="/{{ .RepoInfo.FullName }}/issues/new" ··· 70 <div class="mt-2"> 71 {{ template "repo/issues/fragments/issueListing" (dict "Issues" .Issues "RepoPrefix" .RepoInfo.FullName "LabelDefs" .LabelDefs) }} 72 </div> 73 - {{ block "pagination" . }} {{ end }} 74 {{ end }} 75 76 {{ define "pagination" }} 77 - <div class="flex justify-end mt-4 gap-2"> 78 - {{ $currentState := "closed" }} 79 - {{ if .FilteringByOpen }} 80 - {{ $currentState = "open" }} 81 - {{ end }} 82 83 {{ if gt .Page.Offset 0 }} 84 - {{ $prev := .Page.Previous }} 85 - <a 86 - class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 87 - hx-boost="true" 88 - href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&q={{ .FilterQuery }}&offset={{ $prev.Offset }}&limit={{ $prev.Limit }}" 89 - > 90 - {{ i "chevron-left" "w-4 h-4" }} 91 - previous 92 - </a> 93 - {{ else }} 94 - <div></div> 95 {{ end }} 96 97 {{ if eq (len .Issues) .Page.Limit }} 98 - {{ $next := .Page.Next }} 99 - <a 100 - class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 101 - hx-boost="true" 102 - href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&q={{ .FilterQuery }}&offset={{ $next.Offset }}&limit={{ $next.Limit }}" 103 - > 104 - next 105 - {{ i "chevron-right" "w-4 h-4" }} 106 - </a> 107 {{ end }} 108 </div> 109 {{ end }}
··· 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" ··· 54 </button> 55 </form> 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 60 href="/{{ .RepoInfo.FullName }}/issues/new" ··· 71 <div class="mt-2"> 72 {{ template "repo/issues/fragments/issueListing" (dict "Issues" .Issues "RepoPrefix" .RepoInfo.FullName "LabelDefs" .LabelDefs) }} 73 </div> 74 + {{if gt .IssueCount .Page.Limit }} 75 + {{ block "pagination" . }} {{ end }} 76 + {{ end }} 77 {{ end }} 78 79 {{ define "pagination" }} 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) }} 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 + " 98 {{ if gt .Page.Offset 0 }} 99 + hx-boost="true" 100 + href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&q={{ .FilterQuery }}&offset={{ $prev }}&limit={{ .Page.Limit }}" 101 {{ end }} 102 + > 103 + {{ i "chevron-left" "w-4 h-4" }} 104 + previous 105 + </a> 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 + " 170 {{ if eq (len .Issues) .Page.Limit }} 171 + hx-boost="true" 172 + href="/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&q={{ .FilterQuery }}&offset={{ $next }}&limit={{ .Page.Limit }}" 173 {{ end }} 174 + > 175 + next 176 + {{ i "chevron-right" "w-4 h-4" }} 177 + </a> 178 </div> 179 {{ end }}
+40 -23
appview/pages/templates/repo/log.html
··· 17 <div class="hidden md:flex md:flex-col divide-y divide-gray-200 dark:divide-gray-700"> 18 {{ $grid := "grid grid-cols-14 gap-4" }} 19 <div class="{{ $grid }}"> 20 - <div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-2">Author</div> 21 <div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-3">Commit</div> 22 <div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-6">Message</div> 23 - <div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-1"></div> 24 <div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-2 justify-self-end">Date</div> 25 </div> 26 {{ range $index, $commit := .Commits }} 27 {{ $messageParts := splitN $commit.Message "\n\n" 2 }} 28 <div class="{{ $grid }} py-3"> 29 - <div class="align-top truncate col-span-2"> 30 - {{ $did := index $.EmailToDid $commit.Author.Email }} 31 - {{ if $did }} 32 - {{ template "user/fragments/picHandleLink" $did }} 33 - {{ else }} 34 - <a href="mailto:{{ $commit.Author.Email }}" class="text-gray-700 dark:text-gray-300 no-underline hover:underline">{{ $commit.Author.Name }}</a> 35 - {{ end }} 36 </div> 37 <div class="align-top font-mono flex items-start col-span-3"> 38 {{ $verified := $.VerifiedCommits.IsVerified $commit.Hash.String }} ··· 61 <div class="align-top col-span-6"> 62 <div> 63 <a href="/{{ $.RepoInfo.FullName }}/commit/{{ $commit.Hash.String }}" class="dark:text-white no-underline hover:underline">{{ index $messageParts 0 }}</a> 64 {{ if gt (len $messageParts) 1 }} 65 <button class="py-1/2 px-1 bg-gray-200 hover:bg-gray-400 dark:bg-gray-700 dark:hover:bg-gray-600 rounded" hx-on:click="this.parentElement.nextElementSibling.classList.toggle('hidden')">{{ i "ellipsis" "w-3 h-3" }}</button> 66 {{ end }} ··· 72 </span> 73 {{ end }} 74 {{ end }} 75 </div> 76 77 {{ if gt (len $messageParts) 1 }} 78 <p class="hidden mt-1 text-sm text-gray-600 dark:text-gray-400">{{ nl2br (index $messageParts 1) }}</p> 79 {{ end }} 80 - </div> 81 - <div class="align-top col-span-1"> 82 - <!-- ci status --> 83 - {{ $pipeline := index $.Pipelines .Hash.String }} 84 - {{ if and $pipeline (gt (len $pipeline.Statuses) 0) }} 85 - {{ template "repo/pipelines/fragments/pipelineSymbolLong" (dict "Pipeline" $pipeline "RepoInfo" $.RepoInfo) }} 86 - {{ end }} 87 </div> 88 <div class="align-top justify-self-end text-gray-500 dark:text-gray-400 col-span-2">{{ template "repo/fragments/shortTimeAgo" $commit.Committer.When }}</div> 89 </div> ··· 152 </a> 153 </span> 154 <span class="mx-2 before:content-['ยท'] before:select-none"></span> 155 - <span> 156 - {{ $did := index $.EmailToDid $commit.Author.Email }} 157 - <a href="{{ if $did }}/{{ $did }}{{ else }}mailto:{{ $commit.Author.Email }}{{ end }}" 158 - class="text-gray-500 dark:text-gray-400 no-underline hover:underline"> 159 - {{ if $did }}{{ template "user/fragments/picHandleLink" $did }}{{ else }}{{ $commit.Author.Name }}{{ end }} 160 - </a> 161 - </span> 162 <div class="inline-block px-1 select-none after:content-['ยท']"></div> 163 <span>{{ template "repo/fragments/shortTime" $commit.Committer.When }}</span> 164 ··· 176 </div> 177 </section> 178 179 {{ end }} 180 181 {{ define "repoAfter" }}
··· 17 <div class="hidden md:flex md:flex-col divide-y divide-gray-200 dark:divide-gray-700"> 18 {{ $grid := "grid grid-cols-14 gap-4" }} 19 <div class="{{ $grid }}"> 20 + <div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-3">Author</div> 21 <div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-3">Commit</div> 22 <div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-6">Message</div> 23 <div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-2 justify-self-end">Date</div> 24 </div> 25 {{ range $index, $commit := .Commits }} 26 {{ $messageParts := splitN $commit.Message "\n\n" 2 }} 27 <div class="{{ $grid }} py-3"> 28 + <div class="align-top col-span-3"> 29 + {{ template "attribution" (list $commit $.EmailToDid) }} 30 </div> 31 <div class="align-top font-mono flex items-start col-span-3"> 32 {{ $verified := $.VerifiedCommits.IsVerified $commit.Hash.String }} ··· 55 <div class="align-top col-span-6"> 56 <div> 57 <a href="/{{ $.RepoInfo.FullName }}/commit/{{ $commit.Hash.String }}" class="dark:text-white no-underline hover:underline">{{ index $messageParts 0 }}</a> 58 + 59 {{ if gt (len $messageParts) 1 }} 60 <button class="py-1/2 px-1 bg-gray-200 hover:bg-gray-400 dark:bg-gray-700 dark:hover:bg-gray-600 rounded" hx-on:click="this.parentElement.nextElementSibling.classList.toggle('hidden')">{{ i "ellipsis" "w-3 h-3" }}</button> 61 {{ end }} ··· 67 </span> 68 {{ end }} 69 {{ end }} 70 + 71 + <!-- ci status --> 72 + <span class="text-xs"> 73 + {{ $pipeline := index $.Pipelines .Hash.String }} 74 + {{ if and $pipeline (gt (len $pipeline.Statuses) 0) }} 75 + {{ template "repo/pipelines/fragments/pipelineSymbolLong" (dict "Pipeline" $pipeline "RepoInfo" $.RepoInfo) }} 76 + {{ end }} 77 + </span> 78 </div> 79 80 {{ if gt (len $messageParts) 1 }} 81 <p class="hidden mt-1 text-sm text-gray-600 dark:text-gray-400">{{ nl2br (index $messageParts 1) }}</p> 82 {{ end }} 83 </div> 84 <div class="align-top justify-self-end text-gray-500 dark:text-gray-400 col-span-2">{{ template "repo/fragments/shortTimeAgo" $commit.Committer.When }}</div> 85 </div> ··· 148 </a> 149 </span> 150 <span class="mx-2 before:content-['ยท'] before:select-none"></span> 151 + {{ template "attribution" (list $commit $.EmailToDid) }} 152 <div class="inline-block px-1 select-none after:content-['ยท']"></div> 153 <span>{{ template "repo/fragments/shortTime" $commit.Committer.When }}</span> 154 ··· 166 </div> 167 </section> 168 169 + {{ end }} 170 + 171 + {{ define "attribution" }} 172 + {{ $commit := index . 0 }} 173 + {{ $map := index . 1 }} 174 + <span class="flex items-center gap-1"> 175 + {{ $author := index $map $commit.Author.Email }} 176 + {{ $coauthors := $commit.CoAuthors }} 177 + {{ $all := list }} 178 + 179 + {{ if $author }} 180 + {{ $all = append $all $author }} 181 + {{ end }} 182 + {{ range $coauthors }} 183 + {{ $co := index $map .Email }} 184 + {{ if $co }} 185 + {{ $all = append $all $co }} 186 + {{ end }} 187 + {{ end }} 188 + 189 + {{ template "fragments/tinyAvatarList" (dict "all" $all "classes" "size-6") }} 190 + <a href="{{ if $author }}/{{ $author }}{{ else }}mailto:{{ $commit.Author.Email }}{{ end }}" 191 + class="no-underline hover:underline"> 192 + {{ if $author }}{{ resolve $author }}{{ else }}{{ $commit.Author.Name }}{{ end }} 193 + {{ if $coauthors }} +{{ length $coauthors }}{{ end }} 194 + </a> 195 + </span> 196 {{ end }} 197 198 {{ define "repoAfter" }}
+2 -1
appview/pages/templates/repo/new.html
··· 155 class="mr-2" 156 id="domain-{{ . }}" 157 required 158 /> 159 <label for="domain-{{ . }}" class="dark:text-white lowercase">{{ . }}</label> 160 </div> ··· 164 </div> 165 <p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> 166 A knot hosts repository data and handles Git operations. 167 - You can also <a href="/knots" class="underline">register your own knot</a>. 168 </p> 169 </div> 170 {{ end }}
··· 155 class="mr-2" 156 id="domain-{{ . }}" 157 required 158 + {{if eq (len $.Knots) 1}}checked{{end}} 159 /> 160 <label for="domain-{{ . }}" class="dark:text-white lowercase">{{ . }}</label> 161 </div> ··· 165 </div> 166 <p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> 167 A knot hosts repository data and handles Git operations. 168 + You can also <a href="/settings/knots" class="underline">register your own knot</a>. 169 </p> 170 </div> 171 {{ end }}
+1 -1
appview/pages/templates/repo/pipelines/pipelines.html
··· 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>
··· 23 </p> 24 <p> 25 <span class="{{ $bullet }}">2</span>Configure your CI/CD 26 + <a href="https://docs.tangled.org/spindles.html#pipelines" class="underline">pipeline</a>. 27 </p> 28 <p><span class="{{ $bullet }}">3</span>Trigger a workflow with a push or a pull-request!</p> 29 </div>
+1 -1
appview/pages/templates/repo/pulls/patch.html
··· 54 {{ end }} 55 56 {{ define "contentAfter" }} 57 - {{ template "repo/fragments/diff" (list .RepoInfo.FullName .Diff .DiffOpts) }} 58 {{end}} 59 60 {{ define "contentAfterLeft" }}
··· 54 {{ end }} 55 56 {{ define "contentAfter" }} 57 + {{ template "repo/fragments/diff" (list .Diff .DiffOpts) }} 58 {{end}} 59 60 {{ define "contentAfterLeft" }}
+3
appview/pages/templates/repo/pulls/pull.html
··· 21 "Subject" $.Pull.AtUri 22 "State" $.Pull.Labels) }} 23 {{ template "repo/fragments/participants" $.Pull.Participants }} 24 {{ template "repo/fragments/externalLinkPanel" $.Pull.AtUri }} 25 </div> 26 </div>
··· 21 "Subject" $.Pull.AtUri 22 "State" $.Pull.Labels) }} 23 {{ template "repo/fragments/participants" $.Pull.Participants }} 24 + {{ template "repo/fragments/backlinks" 25 + (dict "RepoInfo" $.RepoInfo 26 + "Backlinks" $.Backlinks) }} 27 {{ template "repo/fragments/externalLinkPanel" $.Pull.AtUri }} 28 </div> 29 </div>
+2 -1
appview/pages/templates/repo/pulls/pulls.html
··· 38 <input type="hidden" name="state" value="{{ .FilteringBy.String }}"> 39 <div class="flex-1 flex relative"> 40 <input 41 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" 42 type="text" 43 name="q" ··· 59 </button> 60 </form> 61 <div class="sm:row-start-1"> 62 - {{ template "fragments/tabSelector" (dict "Name" "state" "Values" $values "Active" $active) }} 63 </div> 64 <a 65 href="/{{ .RepoInfo.FullName }}/pulls/new"
··· 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" ··· 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") }} 64 </div> 65 <a 66 href="/{{ .RepoInfo.FullName }}/pulls/new"
+5 -4
appview/pages/templates/repo/settings/access.html
··· 29 {{ template "addCollaboratorButton" . }} 30 {{ end }} 31 {{ range .Collaborators }} 32 <div class="border border-gray-200 dark:border-gray-700 rounded p-4"> 33 <div class="flex items-center gap-3"> 34 <img 35 - src="{{ fullAvatar .Handle }}" 36 - alt="{{ .Handle }}" 37 class="rounded-full h-10 w-10 border border-gray-300 dark:border-gray-600 flex-shrink-0"/> 38 39 <div class="flex-1 min-w-0"> 40 - <a href="/{{ .Handle }}" class="block truncate"> 41 - {{ didOrHandle .Did .Handle }} 42 </a> 43 <p class="text-sm text-gray-500 dark:text-gray-400">{{ .Role }}</p> 44 </div>
··· 29 {{ template "addCollaboratorButton" . }} 30 {{ end }} 31 {{ range .Collaborators }} 32 + {{ $handle := resolve .Did }} 33 <div class="border border-gray-200 dark:border-gray-700 rounded p-4"> 34 <div class="flex items-center gap-3"> 35 <img 36 + src="{{ fullAvatar $handle }}" 37 + alt="{{ $handle }}" 38 class="rounded-full h-10 w-10 border border-gray-300 dark:border-gray-600 flex-shrink-0"/> 39 40 <div class="flex-1 min-w-0"> 41 + <a href="/{{ $handle }}" class="block truncate"> 42 + {{ $handle }} 43 </a> 44 <p class="text-sm text-gray-500 dark:text-gray-400">{{ .Role }}</p> 45 </div>
+1 -1
appview/pages/templates/repo/settings/pipelines.html
··· 22 <p class="text-gray-500 dark:text-gray-400"> 23 Choose a spindle to execute your workflows on. Only repository owners 24 can configure spindles. Spindles can be selfhosted, 25 - <a class="text-gray-500 dark:text-gray-400 underline" href="https://tangled.org/@tangled.org/core/blob/master/docs/spindle/hosting.md"> 26 click to learn more. 27 </a> 28 </p>
··· 22 <p class="text-gray-500 dark:text-gray-400"> 23 Choose a spindle to execute your workflows on. Only repository owners 24 can configure spindles. Spindles can be selfhosted, 25 + <a class="text-gray-500 dark:text-gray-400 underline" href="https://docs.tangled.org/spindles.html#self-hosting-guide"> 26 click to learn more. 27 </a> 28 </p>
+22 -6
appview/pages/templates/spindles/dashboard.html
··· 1 - {{ define "title" }}{{.Spindle.Instance}} &middot; spindles{{ end }} 2 3 {{ define "content" }} 4 - <div class="px-6 py-4"> 5 <div class="flex justify-between items-center"> 6 - <h1 class="text-xl font-bold dark:text-white">{{ .Spindle.Instance }}</h1> 7 <div id="right-side" class="flex gap-2"> 8 {{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2" }} 9 {{ $isOwner := and .LoggedInUser (eq .LoggedInUser.Did .Spindle.Owner) }} ··· 71 <button 72 class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 73 title="Delete spindle" 74 - hx-delete="/spindles/{{ .Instance }}" 75 hx-swap="outerHTML" 76 hx-confirm="Are you sure you want to delete the spindle '{{ .Instance }}'?" 77 hx-headers='{"shouldRedirect": "true"}' ··· 87 <button 88 class="btn gap-2 group" 89 title="Retry spindle verification" 90 - hx-post="/spindles/{{ .Instance }}/retry" 91 hx-swap="none" 92 hx-headers='{"shouldRefresh": "true"}' 93 > ··· 104 <button 105 class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 106 title="Remove member" 107 - hx-post="/spindles/{{ $root.Spindle.Instance }}/remove" 108 hx-swap="none" 109 hx-vals='{"member": "{{$member}}" }' 110 hx-confirm="Are you sure you want to remove {{ resolve $member }} from this instance?"
··· 1 + {{ define "title" }}{{.Spindle.Instance}} &middot; {{ .Tab }} settings{{ end }} 2 3 {{ define "content" }} 4 + <div class="p-6"> 5 + <p class="text-xl font-bold dark:text-white">Settings</p> 6 + </div> 7 + <div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 8 + <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6"> 9 + <div class="col-span-1"> 10 + {{ template "user/settings/fragments/sidebar" . }} 11 + </div> 12 + <div class="col-span-1 md:col-span-3 flex flex-col gap-6"> 13 + {{ template "spindleDash" . }} 14 + </div> 15 + </section> 16 + </div> 17 + {{ end }} 18 + 19 + {{ define "spindleDash" }} 20 + <div> 21 <div class="flex justify-between items-center"> 22 + <h2 class="text-sm pb-2 uppercase font-bold">{{ .Tab }} &middot; {{ .Spindle.Instance }}</h2> 23 <div id="right-side" class="flex gap-2"> 24 {{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2" }} 25 {{ $isOwner := and .LoggedInUser (eq .LoggedInUser.Did .Spindle.Owner) }} ··· 87 <button 88 class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 89 title="Delete spindle" 90 + hx-delete="/settings/spindles/{{ .Instance }}" 91 hx-swap="outerHTML" 92 hx-confirm="Are you sure you want to delete the spindle '{{ .Instance }}'?" 93 hx-headers='{"shouldRedirect": "true"}' ··· 103 <button 104 class="btn gap-2 group" 105 title="Retry spindle verification" 106 + hx-post="/settings/spindles/{{ .Instance }}/retry" 107 hx-swap="none" 108 hx-headers='{"shouldRefresh": "true"}' 109 > ··· 120 <button 121 class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 122 title="Remove member" 123 + hx-post="/settings/spindles/{{ $root.Spindle.Instance }}/remove" 124 hx-swap="none" 125 hx-vals='{"member": "{{$member}}" }' 126 hx-confirm="Are you sure you want to remove {{ resolve $member }} from this instance?"
+1 -1
appview/pages/templates/spindles/fragments/addMemberModal.html
··· 22 23 {{ define "addSpindleMemberPopover" }} 24 <form 25 - hx-post="/spindles/{{ .Instance }}/add" 26 hx-indicator="#spinner" 27 hx-swap="none" 28 class="flex flex-col gap-2"
··· 22 23 {{ define "addSpindleMemberPopover" }} 24 <form 25 + hx-post="/settings/spindles/{{ .Instance }}/add" 26 hx-indicator="#spinner" 27 hx-swap="none" 28 class="flex flex-col gap-2"
+3 -3
appview/pages/templates/spindles/fragments/spindleListing.html
··· 7 8 {{ define "spindleLeftSide" }} 9 {{ if .Verified }} 10 - <a href="/spindles/{{ .Instance }}" class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]"> 11 {{ i "hard-drive" "w-4 h-4" }} 12 <span class="hover:underline"> 13 {{ .Instance }} ··· 50 <button 51 class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 52 title="Delete spindle" 53 - hx-delete="/spindles/{{ .Instance }}" 54 hx-swap="outerHTML" 55 hx-target="#spindle-{{.Id}}" 56 hx-confirm="Are you sure you want to delete the spindle '{{ .Instance }}'?" ··· 66 <button 67 class="btn gap-2 group" 68 title="Retry spindle verification" 69 - hx-post="/spindles/{{ .Instance }}/retry" 70 hx-swap="none" 71 hx-target="#spindle-{{.Id}}" 72 >
··· 7 8 {{ define "spindleLeftSide" }} 9 {{ if .Verified }} 10 + <a href="/settings/spindles/{{ .Instance }}" class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]"> 11 {{ i "hard-drive" "w-4 h-4" }} 12 <span class="hover:underline"> 13 {{ .Instance }} ··· 50 <button 51 class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 52 title="Delete spindle" 53 + hx-delete="/settings/spindles/{{ .Instance }}" 54 hx-swap="outerHTML" 55 hx-target="#spindle-{{.Id}}" 56 hx-confirm="Are you sure you want to delete the spindle '{{ .Instance }}'?" ··· 66 <button 67 class="btn gap-2 group" 68 title="Retry spindle verification" 69 + hx-post="/settings/spindles/{{ .Instance }}/retry" 70 hx-swap="none" 71 hx-target="#spindle-{{.Id}}" 72 >
+90 -59
appview/pages/templates/spindles/index.html
··· 1 - {{ define "title" }}spindles{{ end }} 2 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> 10 </div> 11 12 - <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 13 <div class="flex flex-col gap-6"> 14 - {{ block "about" . }} {{ end }} 15 {{ block "list" . }} {{ end }} 16 {{ block "register" . }} {{ end }} 17 </div> ··· 20 21 {{ define "about" }} 22 <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> 26 </section> 27 {{ end }} 28 29 {{ 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 }} 40 </div> 41 - <div id="operation-error" class="text-red-500 dark:text-red-400"></div> 42 - </section> 43 {{ end }} 44 45 {{ 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> 78 79 - <div id="register-error" class="dark:text-red-400"></div> 80 - </form> 81 82 - </section> 83 {{ end }}
··· 1 + {{ define "title" }}{{ .Tab }} settings{{ end }} 2 3 {{ define "content" }} 4 + <div class="p-6"> 5 + <p class="text-xl font-bold dark:text-white">Settings</p> 6 + </div> 7 + <div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 8 + <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6"> 9 + <div class="col-span-1"> 10 + {{ template "user/settings/fragments/sidebar" . }} 11 + </div> 12 + <div class="col-span-1 md:col-span-3 flex flex-col gap-6"> 13 + {{ template "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> 28 </div> 29 30 + <section> 31 <div class="flex flex-col gap-6"> 32 {{ block "list" . }} {{ end }} 33 {{ block "register" . }} {{ end }} 34 </div> ··· 37 38 {{ define "about" }} 39 <section class="rounded flex items-center gap-2"> 40 + <p class="text-gray-500 dark:text-gray-400"> 41 + Spindles are small CI runners. 42 + </p> 43 </section> 44 {{ end }} 45 46 {{ define "list" }} 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 55 </div> 56 + {{ end }} 57 + </div> 58 + <div id="operation-error" class="text-red-500 dark:text-red-400"></div> 59 + </section> 60 {{ end }} 61 62 {{ define "register" }} 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> 95 96 + <div id="register-error" class="dark:text-red-400"></div> 97 + </form> 98 + 99 + </section> 100 + {{ end }} 101 102 + {{ define "docsButton" }} 103 + <a 104 + class="btn flex items-center gap-2" 105 + href="https://docs.tangled.org/spindles.html#self-hosting-guide"> 106 + {{ i "book" "size-4" }} 107 + docs 108 + </a> 109 + <div 110 + id="add-email-modal" 111 + popover 112 + class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 113 + </div> 114 {{ end }}
+6 -5
appview/pages/templates/strings/dashboard.html
··· 1 - {{ define "title" }}strings by {{ or .Card.UserHandle .Card.UserDid }}{{ end }} 2 3 {{ define "extrameta" }} 4 - <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" /> 5 <meta property="og:type" content="profile" /> 6 - <meta property="og:url" content="https://tangled.org/{{ or .Card.UserHandle .Card.UserDid }}" /> 7 - <meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 8 {{ end }} 9 10 ··· 35 {{ $s := index . 1 }} 36 <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800"> 37 <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 </div> 40 {{ with $s.Description }} 41 <div class="text-gray-600 dark:text-gray-300 text-sm">
··· 1 + {{ define "title" }}strings by {{ resolve .Card.UserDid }}{{ end }} 2 3 {{ define "extrameta" }} 4 + {{ $handle := resolve .Card.UserDid }} 5 + <meta property="og:title" content="{{ $handle }}" /> 6 <meta property="og:type" content="profile" /> 7 + <meta property="og:url" content="https://tangled.org/{{ $handle }}" /> 8 + <meta property="og:description" content="{{ or .Card.Profile.Description $handle }}" /> 9 {{ end }} 10 11 ··· 36 {{ $s := index . 1 }} 37 <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800"> 38 <div class="font-medium dark:text-white flex gap-2 items-center"> 39 + <a href="/strings/{{ resolve $root.Card.UserDid }}/{{ $s.Rkey }}">{{ $s.Filename }}</a> 40 </div> 41 {{ with $s.Description }} 42 <div class="text-gray-600 dark:text-gray-300 text-sm">
+11 -7
appview/pages/templates/strings/string.html
··· 1 - {{ define "title" }}{{ .String.Filename }} ยท by {{ didOrHandle .Owner.DID.String .Owner.Handle.String }}{{ end }} 2 3 {{ define "extrameta" }} 4 - {{ $ownerId := didOrHandle .Owner.DID.String .Owner.Handle.String }} 5 <meta property="og:title" content="{{ .String.Filename }} ยท by {{ $ownerId }}" /> 6 <meta property="og:type" content="object" /> 7 <meta property="og:url" content="https://tangled.org/strings/{{ $ownerId }}/{{ .String.Rkey }}" /> ··· 9 {{ end }} 10 11 {{ define "content" }} 12 - {{ $ownerId := didOrHandle .Owner.DID.String .Owner.Handle.String }} 13 <section id="string-header" class="mb-4 py-2 px-6 dark:text-white"> 14 <div class="text-lg flex items-center justify-between"> 15 <div> ··· 17 <span class="select-none">/</span> 18 <a href="/strings/{{ $ownerId }}/{{ .String.Rkey }}" class="font-bold">{{ .String.Filename }}</a> 19 </div> 20 - {{ if and .LoggedInUser (eq .LoggedInUser.Did .String.Did) }} 21 - <div class="flex gap-2 text-base"> 22 <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group" 23 hx-boost="true" 24 href="/strings/{{ .String.Did }}/{{ .String.Rkey }}/edit"> ··· 37 <span class="hidden md:inline">delete</span> 38 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 39 </button> 40 - </div> 41 - {{ end }} 42 </div> 43 <span> 44 {{ with .String.Description }}
··· 1 + {{ define "title" }}{{ .String.Filename }} ยท by {{ resolve .Owner.DID.String }}{{ end }} 2 3 {{ define "extrameta" }} 4 + {{ $ownerId := resolve .Owner.DID.String }} 5 <meta property="og:title" content="{{ .String.Filename }} ยท by {{ $ownerId }}" /> 6 <meta property="og:type" content="object" /> 7 <meta property="og:url" content="https://tangled.org/strings/{{ $ownerId }}/{{ .String.Rkey }}" /> ··· 9 {{ end }} 10 11 {{ define "content" }} 12 + {{ $ownerId := resolve .Owner.DID.String }} 13 <section id="string-header" class="mb-4 py-2 px-6 dark:text-white"> 14 <div class="text-lg flex items-center justify-between"> 15 <div> ··· 17 <span class="select-none">/</span> 18 <a href="/strings/{{ $ownerId }}/{{ .String.Rkey }}" class="font-bold">{{ .String.Filename }}</a> 19 </div> 20 + <div class="flex gap-2 items-stretch text-base"> 21 + {{ if and .LoggedInUser (eq .LoggedInUser.Did .String.Did) }} 22 <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group" 23 hx-boost="true" 24 href="/strings/{{ .String.Did }}/{{ .String.Rkey }}/edit"> ··· 37 <span class="hidden md:inline">delete</span> 38 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 39 </button> 40 + {{ end }} 41 + {{ template "fragments/starBtn" 42 + (dict "SubjectAt" .String.AtUri 43 + "IsStarred" .IsStarred 44 + "StarCount" .StarCount) }} 45 + </div> 46 </div> 47 <span> 48 {{ with .String.Description }}
+1 -2
appview/pages/templates/timeline/fragments/goodfirstissues.html
··· 3 <a href="/goodfirstissues" class="no-underline hover:no-underline"> 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 <div class="flex-1 flex flex-col gap-2"> 6 - <div class="text-purple-500 dark:text-purple-400">Oct 2025</div> 7 <p> 8 - Make your first contribution to an open-source project this October. 9 <em>good-first-issue</em> helps new contributors find easy ways to 10 start contributing to open-source projects. 11 </p>
··· 3 <a href="/goodfirstissues" class="no-underline hover:no-underline"> 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 <div class="flex-1 flex flex-col gap-2"> 6 <p> 7 + Make your first contribution to an open-source project. 8 <em>good-first-issue</em> helps new contributors find easy ways to 9 start contributing to open-source projects. 10 </p>
+5 -5
appview/pages/templates/timeline/fragments/timeline.html
··· 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 {{ if .Repo }} 16 {{ template "timeline/fragments/repoEvent" (list $ .) }} 17 - {{ else if .Star }} 18 {{ template "timeline/fragments/starEvent" (list $ .) }} 19 {{ else if .Follow }} 20 {{ template "timeline/fragments/followEvent" (list $ .) }} ··· 52 <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $repo.Created }}</span> 53 </div> 54 {{ with $repo }} 55 - {{ template "user/fragments/repoCard" (list $root . true true (dict "IsStarred" $event.IsStarred "RepoAt" .RepoAt "Stats" (dict "StarCount" $event.StarCount))) }} 56 {{ end }} 57 {{ end }} 58 59 {{ define "timeline/fragments/starEvent" }} 60 {{ $root := index . 0 }} 61 {{ $event := index . 1 }} 62 - {{ $star := $event.Star }} 63 {{ with $star }} 64 - {{ $starrerHandle := resolve .StarredByDid }} 65 {{ $repoOwnerHandle := resolve .Repo.Did }} 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 {{ template "user/fragments/picHandleLink" $starrerHandle }} ··· 72 <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" .Created }}</span> 73 </div> 74 {{ with .Repo }} 75 - {{ template "user/fragments/repoCard" (list $root . true true (dict "IsStarred" $event.IsStarred "RepoAt" .RepoAt "Stats" (dict "StarCount" $event.StarCount))) }} 76 {{ end }} 77 {{ end }} 78 {{ end }}
··· 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 {{ if .Repo }} 16 {{ template "timeline/fragments/repoEvent" (list $ .) }} 17 + {{ else if .RepoStar }} 18 {{ template "timeline/fragments/starEvent" (list $ .) }} 19 {{ else if .Follow }} 20 {{ template "timeline/fragments/followEvent" (list $ .) }} ··· 52 <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $repo.Created }}</span> 53 </div> 54 {{ with $repo }} 55 + {{ template "user/fragments/repoCard" (list $root . true true (dict "IsStarred" $event.IsStarred "SubjectAt" .RepoAt "StarCount" $event.StarCount)) }} 56 {{ end }} 57 {{ end }} 58 59 {{ define "timeline/fragments/starEvent" }} 60 {{ $root := index . 0 }} 61 {{ $event := index . 1 }} 62 + {{ $star := $event.RepoStar }} 63 {{ with $star }} 64 + {{ $starrerHandle := resolve .Did }} 65 {{ $repoOwnerHandle := resolve .Repo.Did }} 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 {{ template "user/fragments/picHandleLink" $starrerHandle }} ··· 72 <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" .Created }}</span> 73 </div> 74 {{ with .Repo }} 75 + {{ template "user/fragments/repoCard" (list $root . true true (dict "IsStarred" $event.IsStarred "SubjectAt" .RepoAt "StarCount" $event.StarCount)) }} 76 {{ end }} 77 {{ end }} 78 {{ end }}
+4 -2
appview/pages/templates/user/followers.html
··· 1 - {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท followers {{ end }} 2 3 {{ define "profileContent" }} 4 <div id="all-followers" class="md:col-span-8 order-2 md:order-2"> ··· 19 "FollowersCount" .FollowersCount 20 "FollowingCount" .FollowingCount) }} 21 {{ else }} 22 - <p class="px-6 dark:text-white">This user does not have any followers yet.</p> 23 {{ end }} 24 </div> 25 {{ end }}
··· 1 + {{ define "title" }}{{ resolve .Card.UserDid }} ยท followers {{ end }} 2 3 {{ define "profileContent" }} 4 <div id="all-followers" class="md:col-span-8 order-2 md:order-2"> ··· 19 "FollowersCount" .FollowersCount 20 "FollowingCount" .FollowingCount) }} 21 {{ else }} 22 + <div class="text-base text-gray-500 flex items-center justify-center italic p-12 border border-gray-200 dark:border-gray-700 rounded"> 23 + <span>This user does not have any followers yet.</span> 24 + </div> 25 {{ end }} 26 </div> 27 {{ end }}
+4 -2
appview/pages/templates/user/following.html
··· 1 - {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท following {{ end }} 2 3 {{ define "profileContent" }} 4 <div id="all-following" class="md:col-span-8 order-2 md:order-2"> ··· 19 "FollowersCount" .FollowersCount 20 "FollowingCount" .FollowingCount) }} 21 {{ else }} 22 - <p class="px-6 dark:text-white">This user does not follow anyone yet.</p> 23 {{ end }} 24 </div> 25 {{ end }}
··· 1 + {{ define "title" }}{{ resolve .Card.UserDid }} ยท following {{ end }} 2 3 {{ define "profileContent" }} 4 <div id="all-following" class="md:col-span-8 order-2 md:order-2"> ··· 19 "FollowersCount" .FollowersCount 20 "FollowingCount" .FollowingCount) }} 21 {{ else }} 22 + <div class="text-base text-gray-500 flex items-center justify-center italic p-12 border border-gray-200 dark:border-gray-700 rounded"> 23 + <span>This user does not follow anyone yet.</span> 24 + </div> 25 {{ end }} 26 </div> 27 {{ end }}
+2 -2
appview/pages/templates/user/fragments/followCard.html
··· 6 <img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" alt="{{ $userIdent }}" /> 7 </div> 8 9 - <div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2 w-full"> 10 <div class="flex-1 min-h-0 justify-around flex flex-col"> 11 <a href="/{{ $userIdent }}"> 12 <span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $userIdent | truncateAt30 }}</span> 13 </a> 14 {{ with .Profile }} 15 - <p class="text-sm pb-2 md:pb-2">{{.Description}}</p> 16 {{ end }} 17 <div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 18 <span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
··· 6 <img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" alt="{{ $userIdent }}" /> 7 </div> 8 9 + <div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2 w-full min-w-0"> 10 <div class="flex-1 min-h-0 justify-around flex flex-col"> 11 <a href="/{{ $userIdent }}"> 12 <span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $userIdent | truncateAt30 }}</span> 13 </a> 14 {{ with .Profile }} 15 + <p class="text-sm pb-2 md:pb-2 break-words">{{.Description}}</p> 16 {{ end }} 17 <div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 18 <span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
+1 -1
appview/pages/templates/user/fragments/profileCard.html
··· 1 {{ define "user/fragments/profileCard" }} 2 - {{ $userIdent := didOrHandle .UserDid .UserHandle }} 3 <div class="grid grid-cols-3 md:grid-cols-1 gap-1 items-center"> 4 <div id="avatar" class="col-span-1 flex justify-center items-center"> 5 <div class="w-3/4 aspect-square relative">
··· 1 {{ define "user/fragments/profileCard" }} 2 + {{ $userIdent := resolve .UserDid }} 3 <div class="grid grid-cols-3 md:grid-cols-1 gap-1 items-center"> 4 <div id="avatar" class="col-span-1 flex justify-center items-center"> 5 <div class="w-3/4 aspect-square relative">
+2 -1
appview/pages/templates/user/fragments/repoCard.html
··· 1 {{ define "user/fragments/repoCard" }} 2 {{ $root := index . 0 }} 3 {{ $repo := index . 1 }} 4 {{ $fullName := index . 2 }} ··· 29 </div> 30 {{ if and $starButton $root.LoggedInUser }} 31 <div class="shrink-0"> 32 - {{ template "repo/fragments/repoStar" $starData }} 33 </div> 34 {{ end }} 35 </div>
··· 1 {{ define "user/fragments/repoCard" }} 2 + {{/* root, repo, fullName [,starButton [,starData]] */}} 3 {{ $root := index . 0 }} 4 {{ $repo := index . 1 }} 5 {{ $fullName := index . 2 }} ··· 30 </div> 31 {{ if and $starButton $root.LoggedInUser }} 32 <div class="shrink-0"> 33 + {{ template "fragments/starBtn" $starData }} 34 </div> 35 {{ end }} 36 </div>
+22 -4
appview/pages/templates/user/overview.html
··· 1 - {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }}{{ end }} 2 3 {{ define "profileContent" }} 4 <div id="all-repos" class="md:col-span-4 order-2 md:order-2"> ··· 16 <p class="text-sm font-bold px-2 pb-4 dark:text-white">ACTIVITY</p> 17 <div class="flex flex-col gap-4 relative"> 18 {{ if .ProfileTimeline.IsEmpty }} 19 - <p class="dark:text-white">This user does not have any activity yet.</p> 20 {{ end }} 21 22 {{ with .ProfileTimeline }} ··· 33 </p> 34 35 <div class="flex flex-col gap-1"> 36 {{ block "repoEvents" .RepoEvents }} {{ end }} 37 {{ block "issueEvents" .IssueEvents }} {{ end }} 38 {{ block "pullEvents" .PullEvents }} {{ end }} ··· 43 {{ end }} 44 {{ end }} 45 </div> 46 {{ end }} 47 48 {{ define "repoEvents" }} ··· 224 {{ define "ownRepos" }} 225 <div> 226 <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" 228 class="flex text-black dark:text-white items-center gap-2 no-underline hover:no-underline group"> 229 <span>PINNED REPOS</span> 230 </a> ··· 244 {{ template "user/fragments/repoCard" (list $ . false) }} 245 </div> 246 {{ else }} 247 - <p class="dark:text-white">This user does not have any pinned repos.</p> 248 {{ end }} 249 </div> 250 </div>
··· 1 + {{ define "title" }}{{ resolve .Card.UserDid }}{{ end }} 2 3 {{ define "profileContent" }} 4 <div id="all-repos" class="md:col-span-4 order-2 md:order-2"> ··· 16 <p class="text-sm font-bold px-2 pb-4 dark:text-white">ACTIVITY</p> 17 <div class="flex flex-col gap-4 relative"> 18 {{ if .ProfileTimeline.IsEmpty }} 19 + <div class="text-base text-gray-500 flex items-center justify-center italic p-12 border border-gray-200 dark:border-gray-700 rounded"> 20 + <span class="flex items-center gap-2"> 21 + This user does not have any activity yet. 22 + </span> 23 + </div> 24 {{ end }} 25 26 {{ with .ProfileTimeline }} ··· 37 </p> 38 39 <div class="flex flex-col gap-1"> 40 + {{ block "commits" .Commits }} {{ end }} 41 {{ block "repoEvents" .RepoEvents }} {{ end }} 42 {{ block "issueEvents" .IssueEvents }} {{ end }} 43 {{ block "pullEvents" .PullEvents }} {{ end }} ··· 48 {{ end }} 49 {{ end }} 50 </div> 51 + {{ end }} 52 + 53 + {{ define "commits" }} 54 + {{ if . }} 55 + <div class="flex flex-wrap items-center gap-1"> 56 + {{ i "git-commit-horizontal" "size-5" }} 57 + created {{ . }} commits 58 + </div> 59 + {{ end }} 60 {{ end }} 61 62 {{ define "repoEvents" }} ··· 238 {{ define "ownRepos" }} 239 <div> 240 <div class="text-sm font-bold px-2 pb-4 dark:text-white flex items-center gap-2"> 241 + <a href="/{{ resolve $.Card.UserDid }}?tab=repos" 242 class="flex text-black dark:text-white items-center gap-2 no-underline hover:no-underline group"> 243 <span>PINNED REPOS</span> 244 </a> ··· 258 {{ template "user/fragments/repoCard" (list $ . false) }} 259 </div> 260 {{ else }} 261 + <div class="text-base text-gray-500 flex items-center justify-center italic p-12 border border-gray-200 dark:border-gray-700 rounded"> 262 + <span class="flex items-center gap-2"> 263 + This user does not have any pinned repos. 264 + </span> 265 + </div> 266 {{ end }} 267 </div> 268 </div>
+4 -2
appview/pages/templates/user/repos.html
··· 1 - {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท repos {{ end }} 2 3 {{ define "profileContent" }} 4 <div id="all-repos" class="md:col-span-8 order-2 md:order-2"> ··· 13 {{ template "user/fragments/repoCard" (list $ . false) }} 14 </div> 15 {{ else }} 16 - <p class="px-6 dark:text-white">This user does not have any repos yet.</p> 17 {{ end }} 18 </div> 19 {{ end }}
··· 1 + {{ define "title" }}{{ resolve .Card.UserDid }} ยท repos {{ end }} 2 3 {{ define "profileContent" }} 4 <div id="all-repos" class="md:col-span-8 order-2 md:order-2"> ··· 13 {{ template "user/fragments/repoCard" (list $ . false) }} 14 </div> 15 {{ else }} 16 + <div class="text-base text-gray-500 flex items-center justify-center italic p-12 border border-gray-200 dark:border-gray-700 rounded"> 17 + <span>This user does not have any repos yet.</span> 18 + </div> 19 {{ end }} 20 </div> 21 {{ end }}
+1 -1
appview/pages/templates/user/settings/notifications.html
··· 151 </div> 152 </div> 153 <label class="flex items-center gap-2"> 154 - <input type="checkbox" name="mentioned" {{if .Preferences.UserMentioned}}checked{{end}}> 155 </label> 156 </div> 157
··· 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
+9 -6
appview/pages/templates/user/signup.html
··· 43 page to complete your registration. 44 </span> 45 <div class="w-full mt-4 text-center"> 46 - <div class="cf-turnstile" data-sitekey="{{ .CloudflareSiteKey }}"></div> 47 </div> 48 <button class="btn text-base w-full my-2 mt-6" type="submit" id="signup-button" tabindex="7" > 49 <span>join now</span> 50 </button> 51 </form> 52 - <p class="text-sm text-gray-500"> 53 - Already have an AT Protocol account? <a href="/login" class="underline">Login to Tangled</a>. 54 - </p> 55 - 56 - <p id="signup-msg" class="error w-full"></p> 57 </main> 58 </body> 59 </html>
··· 43 page to complete your registration. 44 </span> 45 <div class="w-full mt-4 text-center"> 46 + <div class="cf-turnstile" data-sitekey="{{ .CloudflareSiteKey }}" data-size="flexible"></div> 47 </div> 48 <button class="btn text-base w-full my-2 mt-6" type="submit" id="signup-button" tabindex="7" > 49 <span>join now</span> 50 </button> 51 + <p class="text-sm text-gray-500"> 52 + Already have an AT Protocol account? <a href="/login" class="underline">Login to Tangled</a>. 53 + </p> 54 + 55 + <p id="signup-msg" class="error w-full"></p> 56 + <p class="text-sm text-gray-500 pt-4"> 57 + By signing up, you agree to our <a href="/terms" class="underline">Terms of Service</a> and <a href="/privacy" class="underline">Privacy Policy</a>. 58 + </p> 59 </form> 60 </main> 61 </body> 62 </html>
+4 -2
appview/pages/templates/user/starred.html
··· 1 - {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท repos {{ end }} 2 3 {{ define "profileContent" }} 4 <div id="all-repos" class="md:col-span-8 order-2 md:order-2"> ··· 13 {{ template "user/fragments/repoCard" (list $ . true) }} 14 </div> 15 {{ else }} 16 - <p class="px-6 dark:text-white">This user does not have any starred repos yet.</p> 17 {{ end }} 18 </div> 19 {{ end }}
··· 1 + {{ define "title" }}{{ resolve .Card.UserDid }} ยท repos {{ end }} 2 3 {{ define "profileContent" }} 4 <div id="all-repos" class="md:col-span-8 order-2 md:order-2"> ··· 13 {{ template "user/fragments/repoCard" (list $ . true) }} 14 </div> 15 {{ else }} 16 + <div class="text-base text-gray-500 flex items-center justify-center italic p-12 border border-gray-200 dark:border-gray-700 rounded"> 17 + <span>This user does not have any starred repos yet.</span> 18 + </div> 19 {{ end }} 20 </div> 21 {{ end }}
+5 -3
appview/pages/templates/user/strings.html
··· 1 - {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท strings {{ end }} 2 3 {{ define "profileContent" }} 4 <div id="all-strings" class="md:col-span-8 order-2 md:order-2"> ··· 13 {{ template "singleString" (list $ .) }} 14 </div> 15 {{ else }} 16 - <p class="px-6 dark:text-white">This user does not have any strings yet.</p> 17 {{ end }} 18 </div> 19 {{ end }} ··· 23 {{ $s := index . 1 }} 24 <div class="py-4 px-6 rounded bg-white dark:bg-gray-800"> 25 <div class="font-medium dark:text-white flex gap-2 items-center"> 26 - <a href="/strings/{{ or $root.Card.UserHandle $root.Card.UserDid }}/{{ $s.Rkey }}">{{ $s.Filename }}</a> 27 </div> 28 {{ with $s.Description }} 29 <div class="text-gray-600 dark:text-gray-300 text-sm">
··· 1 + {{ define "title" }}{{ resolve .Card.UserDid }} ยท strings {{ end }} 2 3 {{ define "profileContent" }} 4 <div id="all-strings" class="md:col-span-8 order-2 md:order-2"> ··· 13 {{ template "singleString" (list $ .) }} 14 </div> 15 {{ else }} 16 + <div class="text-base text-gray-500 flex items-center justify-center italic p-12 border border-gray-200 dark:border-gray-700 rounded"> 17 + <span>This user does not have any strings yet.</span> 18 + </div> 19 {{ end }} 20 </div> 21 {{ end }} ··· 25 {{ $s := index . 1 }} 26 <div class="py-4 px-6 rounded bg-white dark:bg-gray-800"> 27 <div class="font-medium dark:text-white flex gap-2 items-center"> 28 + <a href="/strings/{{ resolve $root.Card.UserDid }}/{{ $s.Rkey }}">{{ $s.Filename }}</a> 29 </div> 30 {{ with $s.Description }} 31 <div class="text-gray-600 dark:text-gray-300 text-sm">
+16 -22
appview/pipelines/pipelines.go
··· 16 "tangled.org/core/appview/reporesolver" 17 "tangled.org/core/eventconsumer" 18 "tangled.org/core/idresolver" 19 "tangled.org/core/rbac" 20 spindlemodel "tangled.org/core/spindle/models" 21 ··· 78 return 79 } 80 81 - repoInfo := f.RepoInfo(user) 82 - 83 ps, err := db.GetPipelineStatuses( 84 p.db, 85 30, 86 - db.FilterEq("repo_owner", repoInfo.OwnerDid), 87 - db.FilterEq("repo_name", repoInfo.Name), 88 - db.FilterEq("knot", repoInfo.Knot), 89 ) 90 if err != nil { 91 l.Error("failed to query db", "err", err) ··· 94 95 p.pages.Pipelines(w, pages.PipelinesParams{ 96 LoggedInUser: user, 97 - RepoInfo: repoInfo, 98 Pipelines: ps, 99 }) 100 } ··· 108 l.Error("failed to get repo and knot", "err", err) 109 return 110 } 111 - 112 - repoInfo := f.RepoInfo(user) 113 114 pipelineId := chi.URLParam(r, "pipeline") 115 if pipelineId == "" { ··· 126 ps, err := db.GetPipelineStatuses( 127 p.db, 128 1, 129 - db.FilterEq("repo_owner", repoInfo.OwnerDid), 130 - db.FilterEq("repo_name", repoInfo.Name), 131 - db.FilterEq("knot", repoInfo.Knot), 132 - db.FilterEq("id", pipelineId), 133 ) 134 if err != nil { 135 l.Error("failed to query db", "err", err) ··· 145 146 p.pages.Workflow(w, pages.WorkflowParams{ 147 LoggedInUser: user, 148 - RepoInfo: repoInfo, 149 Pipeline: singlePipeline, 150 Workflow: workflow, 151 }) ··· 176 ctx, cancel := context.WithCancel(r.Context()) 177 defer cancel() 178 179 - user := p.oauth.GetUser(r) 180 f, err := p.repoResolver.Resolve(r) 181 if err != nil { 182 l.Error("failed to get repo and knot", "err", err) ··· 184 return 185 } 186 187 - repoInfo := f.RepoInfo(user) 188 - 189 pipelineId := chi.URLParam(r, "pipeline") 190 workflow := chi.URLParam(r, "workflow") 191 if pipelineId == "" || workflow == "" { ··· 196 ps, err := db.GetPipelineStatuses( 197 p.db, 198 1, 199 - db.FilterEq("repo_owner", repoInfo.OwnerDid), 200 - db.FilterEq("repo_name", repoInfo.Name), 201 - db.FilterEq("knot", repoInfo.Knot), 202 - db.FilterEq("id", pipelineId), 203 ) 204 if err != nil || len(ps) != 1 { 205 l.Error("pipeline query failed", "err", err, "count", len(ps)) ··· 208 } 209 210 singlePipeline := ps[0] 211 - spindle := repoInfo.Spindle 212 - knot := repoInfo.Knot 213 rkey := singlePipeline.Rkey 214 215 if spindle == "" || knot == "" || rkey == "" {
··· 16 "tangled.org/core/appview/reporesolver" 17 "tangled.org/core/eventconsumer" 18 "tangled.org/core/idresolver" 19 + "tangled.org/core/orm" 20 "tangled.org/core/rbac" 21 spindlemodel "tangled.org/core/spindle/models" 22 ··· 79 return 80 } 81 82 ps, err := db.GetPipelineStatuses( 83 p.db, 84 30, 85 + orm.FilterEq("repo_owner", f.Did), 86 + orm.FilterEq("repo_name", f.Name), 87 + orm.FilterEq("knot", f.Knot), 88 ) 89 if err != nil { 90 l.Error("failed to query db", "err", err) ··· 93 94 p.pages.Pipelines(w, pages.PipelinesParams{ 95 LoggedInUser: user, 96 + RepoInfo: p.repoResolver.GetRepoInfo(r, user), 97 Pipelines: ps, 98 }) 99 } ··· 107 l.Error("failed to get repo and knot", "err", err) 108 return 109 } 110 111 pipelineId := chi.URLParam(r, "pipeline") 112 if pipelineId == "" { ··· 123 ps, err := db.GetPipelineStatuses( 124 p.db, 125 1, 126 + orm.FilterEq("repo_owner", f.Did), 127 + orm.FilterEq("repo_name", f.Name), 128 + orm.FilterEq("knot", f.Knot), 129 + orm.FilterEq("id", pipelineId), 130 ) 131 if err != nil { 132 l.Error("failed to query db", "err", err) ··· 142 143 p.pages.Workflow(w, pages.WorkflowParams{ 144 LoggedInUser: user, 145 + RepoInfo: p.repoResolver.GetRepoInfo(r, user), 146 Pipeline: singlePipeline, 147 Workflow: workflow, 148 }) ··· 173 ctx, cancel := context.WithCancel(r.Context()) 174 defer cancel() 175 176 f, err := p.repoResolver.Resolve(r) 177 if err != nil { 178 l.Error("failed to get repo and knot", "err", err) ··· 180 return 181 } 182 183 pipelineId := chi.URLParam(r, "pipeline") 184 workflow := chi.URLParam(r, "workflow") 185 if pipelineId == "" || workflow == "" { ··· 190 ps, err := db.GetPipelineStatuses( 191 p.db, 192 1, 193 + orm.FilterEq("repo_owner", f.Did), 194 + orm.FilterEq("repo_name", f.Name), 195 + orm.FilterEq("knot", f.Knot), 196 + orm.FilterEq("id", pipelineId), 197 ) 198 if err != nil || len(ps) != 1 { 199 l.Error("pipeline query failed", "err", err, "count", len(ps)) ··· 202 } 203 204 singlePipeline := ps[0] 205 + spindle := f.Spindle 206 + knot := f.Knot 207 rkey := singlePipeline.Rkey 208 209 if spindle == "" || knot == "" || rkey == "" {
+4 -3
appview/pulls/opengraph.go
··· 13 "tangled.org/core/appview/db" 14 "tangled.org/core/appview/models" 15 "tangled.org/core/appview/ogcard" 16 "tangled.org/core/patchutil" 17 "tangled.org/core/types" 18 ) ··· 241 dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2) 242 dollyY := statsY + iconBaselineOffset - dollySize/2 + 25 243 dollyColor := color.RGBA{180, 180, 180, 255} // light gray 244 - err = dollyArea.DrawDollySilhouette(dollyX, dollyY, dollySize, dollyColor) 245 if err != nil { 246 log.Printf("dolly silhouette not available (this is ok): %v", err) 247 } ··· 276 } 277 278 // Get comment count from database 279 - comments, err := db.GetPullComments(s.db, db.FilterEq("pull_id", pull.ID)) 280 if err != nil { 281 log.Printf("failed to get pull comments: %v", err) 282 } ··· 293 filesChanged = niceDiff.Stat.FilesChanged 294 } 295 296 - card, err := s.drawPullSummaryCard(pull, &f.Repo, commentCount, diffStats, filesChanged) 297 if err != nil { 298 log.Println("failed to draw pull summary card", err) 299 http.Error(w, "failed to draw pull summary card", http.StatusInternalServerError)
··· 13 "tangled.org/core/appview/db" 14 "tangled.org/core/appview/models" 15 "tangled.org/core/appview/ogcard" 16 + "tangled.org/core/orm" 17 "tangled.org/core/patchutil" 18 "tangled.org/core/types" 19 ) ··· 242 dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2) 243 dollyY := statsY + iconBaselineOffset - dollySize/2 + 25 244 dollyColor := color.RGBA{180, 180, 180, 255} // light gray 245 + err = dollyArea.DrawDolly(dollyX, dollyY, dollySize, dollyColor) 246 if err != nil { 247 log.Printf("dolly silhouette not available (this is ok): %v", err) 248 } ··· 277 } 278 279 // Get comment count from database 280 + comments, err := db.GetPullComments(s.db, orm.FilterEq("pull_id", pull.ID)) 281 if err != nil { 282 log.Printf("failed to get pull comments: %v", err) 283 } ··· 294 filesChanged = niceDiff.Stat.FilesChanged 295 } 296 297 + card, err := s.drawPullSummaryCard(pull, f, commentCount, diffStats, filesChanged) 298 if err != nil { 299 log.Println("failed to draw pull summary card", err) 300 http.Error(w, "failed to draw pull summary card", http.StatusInternalServerError)
+193 -177
appview/pulls/pulls.go
··· 1 package pulls 2 3 import ( 4 "database/sql" 5 "encoding/json" 6 "errors" ··· 18 "tangled.org/core/appview/config" 19 "tangled.org/core/appview/db" 20 pulls_indexer "tangled.org/core/appview/indexer/pulls" 21 "tangled.org/core/appview/models" 22 "tangled.org/core/appview/notify" 23 "tangled.org/core/appview/oauth" 24 "tangled.org/core/appview/pages" 25 "tangled.org/core/appview/pages/markup" 26 "tangled.org/core/appview/reporesolver" 27 "tangled.org/core/appview/validator" 28 "tangled.org/core/appview/xrpcclient" 29 "tangled.org/core/idresolver" 30 "tangled.org/core/patchutil" 31 "tangled.org/core/rbac" 32 "tangled.org/core/tid" ··· 41 ) 42 43 type Pulls struct { 44 - oauth *oauth.OAuth 45 - repoResolver *reporesolver.RepoResolver 46 - pages *pages.Pages 47 - idResolver *idresolver.Resolver 48 - db *db.DB 49 - config *config.Config 50 - notifier notify.Notifier 51 - enforcer *rbac.Enforcer 52 - logger *slog.Logger 53 - validator *validator.Validator 54 - indexer *pulls_indexer.Indexer 55 } 56 57 func New( ··· 59 repoResolver *reporesolver.RepoResolver, 60 pages *pages.Pages, 61 resolver *idresolver.Resolver, 62 db *db.DB, 63 config *config.Config, 64 notifier notify.Notifier, ··· 68 logger *slog.Logger, 69 ) *Pulls { 70 return &Pulls{ 71 - oauth: oauth, 72 - repoResolver: repoResolver, 73 - pages: pages, 74 - idResolver: resolver, 75 - db: db, 76 - config: config, 77 - notifier: notifier, 78 - enforcer: enforcer, 79 - logger: logger, 80 - validator: validator, 81 - indexer: indexer, 82 } 83 } 84 ··· 123 124 s.pages.PullActionsFragment(w, pages.PullActionsParams{ 125 LoggedInUser: user, 126 - RepoInfo: f.RepoInfo(user), 127 Pull: pull, 128 RoundNumber: roundNumber, 129 MergeCheck: mergeCheckResponse, ··· 150 return 151 } 152 153 // can be nil if this pull is not stacked 154 stack, _ := r.Context().Value("stack").(models.Stack) 155 abandonedPulls, _ := r.Context().Value("abandonedPulls").([]*models.Pull) ··· 160 if user != nil && user.Did == pull.OwnerDid { 161 resubmitResult = s.resubmitCheck(r, f, pull, stack) 162 } 163 - 164 - repoInfo := f.RepoInfo(user) 165 166 m := make(map[string]models.Pipeline) 167 ··· 179 ps, err := db.GetPipelineStatuses( 180 s.db, 181 len(shas), 182 - db.FilterEq("repo_owner", repoInfo.OwnerDid), 183 - db.FilterEq("repo_name", repoInfo.Name), 184 - db.FilterEq("knot", repoInfo.Knot), 185 - db.FilterIn("sha", shas), 186 ) 187 if err != nil { 188 log.Printf("failed to fetch pipeline statuses: %s", err) ··· 206 207 labelDefs, err := db.GetLabelDefinitions( 208 s.db, 209 - db.FilterIn("at_uri", f.Repo.Labels), 210 - db.FilterContains("scope", tangled.RepoPullNSID), 211 ) 212 if err != nil { 213 log.Println("failed to fetch labels", err) ··· 222 223 s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 224 LoggedInUser: user, 225 - RepoInfo: repoInfo, 226 Pull: pull, 227 Stack: stack, 228 AbandonedPulls: abandonedPulls, 229 BranchDeleteStatus: branchDeleteStatus, 230 MergeCheck: mergeCheckResponse, 231 ResubmitCheck: resubmitResult, ··· 239 }) 240 } 241 242 - func (s *Pulls) mergeCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *models.Pull, stack models.Stack) types.MergeCheckResponse { 243 if pull.State == models.PullMerged { 244 return types.MergeCheckResponse{} 245 } ··· 268 r.Context(), 269 &xrpcc, 270 &tangled.RepoMergeCheck_Input{ 271 - Did: f.OwnerDid(), 272 Name: f.Name, 273 Branch: pull.TargetBranch, 274 Patch: patch, ··· 306 return result 307 } 308 309 - func (s *Pulls) branchDeleteStatus(r *http.Request, f *reporesolver.ResolvedRepo, pull *models.Pull) *models.BranchDeleteStatus { 310 if pull.State != models.PullMerged { 311 return nil 312 } ··· 317 } 318 319 var branch string 320 - var repo *models.Repo 321 // check if the branch exists 322 // NOTE: appview could cache branches/tags etc. for every repo by listening for gitRefUpdates 323 if pull.IsBranchBased() { 324 branch = pull.PullSource.Branch 325 - repo = &f.Repo 326 } else if pull.IsForkBased() { 327 branch = pull.PullSource.Branch 328 repo = pull.PullSource.Repo ··· 361 } 362 } 363 364 - func (s *Pulls) resubmitCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *models.Pull, stack models.Stack) pages.ResubmitResult { 365 if pull.State == models.PullMerged || pull.State == models.PullDeleted || pull.PullSource == nil { 366 return pages.Unknown 367 } ··· 381 repoName = sourceRepo.Name 382 } else { 383 // pulls within the same repo 384 - knot = f.Knot 385 - ownerDid = f.OwnerDid() 386 - repoName = f.Name 387 } 388 389 scheme := "http" ··· 395 Host: host, 396 } 397 398 - repo := fmt.Sprintf("%s/%s", ownerDid, repoName) 399 - branchResp, err := tangled.RepoBranch(r.Context(), xrpcc, pull.PullSource.Branch, repo) 400 if err != nil { 401 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 402 log.Println("failed to call XRPC repo.branches", xrpcerr) ··· 424 425 func (s *Pulls) RepoPullPatch(w http.ResponseWriter, r *http.Request) { 426 user := s.oauth.GetUser(r) 427 - f, err := s.repoResolver.Resolve(r) 428 - if err != nil { 429 - log.Println("failed to get repo and knot", err) 430 - return 431 - } 432 433 var diffOpts types.DiffOpts 434 if d := r.URL.Query().Get("diff"); d == "split" { ··· 457 458 s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{ 459 LoggedInUser: user, 460 - RepoInfo: f.RepoInfo(user), 461 Pull: pull, 462 Stack: stack, 463 Round: roundIdInt, ··· 471 func (s *Pulls) RepoPullInterdiff(w http.ResponseWriter, r *http.Request) { 472 user := s.oauth.GetUser(r) 473 474 - f, err := s.repoResolver.Resolve(r) 475 - if err != nil { 476 - log.Println("failed to get repo and knot", err) 477 - return 478 - } 479 - 480 var diffOpts types.DiffOpts 481 if d := r.URL.Query().Get("diff"); d == "split" { 482 diffOpts.Split = true ··· 521 522 s.pages.RepoPullInterdiffPage(w, pages.RepoPullInterdiffParams{ 523 LoggedInUser: s.oauth.GetUser(r), 524 - RepoInfo: f.RepoInfo(user), 525 Pull: pull, 526 Round: roundIdInt, 527 Interdiff: interdiff, ··· 598 599 pulls, err := db.GetPulls( 600 s.db, 601 - db.FilterIn("id", ids), 602 ) 603 if err != nil { 604 log.Println("failed to get pulls", err) ··· 646 } 647 pulls = pulls[:n] 648 649 - repoInfo := f.RepoInfo(user) 650 ps, err := db.GetPipelineStatuses( 651 s.db, 652 len(shas), 653 - db.FilterEq("repo_owner", repoInfo.OwnerDid), 654 - db.FilterEq("repo_name", repoInfo.Name), 655 - db.FilterEq("knot", repoInfo.Knot), 656 - db.FilterIn("sha", shas), 657 ) 658 if err != nil { 659 log.Printf("failed to fetch pipeline statuses: %s", err) ··· 666 667 labelDefs, err := db.GetLabelDefinitions( 668 s.db, 669 - db.FilterIn("at_uri", f.Repo.Labels), 670 - db.FilterContains("scope", tangled.RepoPullNSID), 671 ) 672 if err != nil { 673 log.Println("failed to fetch labels", err) ··· 682 683 s.pages.RepoPulls(w, pages.RepoPullsParams{ 684 LoggedInUser: s.oauth.GetUser(r), 685 - RepoInfo: f.RepoInfo(user), 686 Pulls: pulls, 687 LabelDefs: defs, 688 FilteringBy: state, ··· 693 } 694 695 func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) { 696 - l := s.logger.With("handler", "PullComment") 697 user := s.oauth.GetUser(r) 698 f, err := s.repoResolver.Resolve(r) 699 if err != nil { ··· 720 case http.MethodGet: 721 s.pages.PullNewCommentFragment(w, pages.PullNewCommentParams{ 722 LoggedInUser: user, 723 - RepoInfo: f.RepoInfo(user), 724 Pull: pull, 725 RoundNumber: roundNumber, 726 }) ··· 731 s.pages.Notice(w, "pull", "Comment body is required") 732 return 733 } 734 735 // Start a transaction 736 tx, err := s.db.BeginTx(r.Context(), nil) ··· 774 Body: body, 775 CommentAt: atResp.Uri, 776 SubmissionId: pull.Submissions[roundNumber].ID, 777 } 778 779 // Create the pull comment in the database with the commentAt field ··· 791 return 792 } 793 794 - rawMentions := markup.FindUserMentions(comment.Body) 795 - idents := s.idResolver.ResolveIdents(r.Context(), rawMentions) 796 - l.Debug("parsed mentions", "raw", rawMentions, "idents", idents) 797 - var mentions []syntax.DID 798 - for _, ident := range idents { 799 - if ident != nil && !ident.Handle.IsInvalidHandle() { 800 - mentions = append(mentions, ident.DID) 801 - } 802 - } 803 s.notifier.NewPullComment(r.Context(), comment, mentions) 804 805 - s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId)) 806 return 807 } 808 } ··· 826 Host: host, 827 } 828 829 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 830 xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 831 if err != nil { 832 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { ··· 853 854 s.pages.RepoNewPull(w, pages.RepoNewPullParams{ 855 LoggedInUser: user, 856 - RepoInfo: f.RepoInfo(user), 857 Branches: result.Branches, 858 Strategy: strategy, 859 SourceBranch: sourceBranch, ··· 876 } 877 878 // Determine PR type based on input parameters 879 - isPushAllowed := f.RepoInfo(user).Roles.IsPushAllowed() 880 isBranchBased := isPushAllowed && sourceBranch != "" && fromFork == "" 881 isForkBased := fromFork != "" && sourceBranch != "" 882 isPatchBased := patch != "" && !isBranchBased && !isForkBased ··· 974 func (s *Pulls) handleBranchBasedPull( 975 w http.ResponseWriter, 976 r *http.Request, 977 - f *reporesolver.ResolvedRepo, 978 user *oauth.User, 979 title, 980 body, ··· 986 if !s.config.Core.Dev { 987 scheme = "https" 988 } 989 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 990 xrpcc := &indigoxrpc.Client{ 991 Host: host, 992 } 993 994 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 995 - xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, targetBranch, sourceBranch) 996 if err != nil { 997 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 998 log.Println("failed to call XRPC repo.compare", xrpcerr) ··· 1029 Sha: comparison.Rev2, 1030 } 1031 1032 - s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, combined, sourceRev, pullSource, recordPullSource, isStacked) 1033 } 1034 1035 - func (s *Pulls) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, f *reporesolver.ResolvedRepo, user *oauth.User, title, body, targetBranch, patch string, isStacked bool) { 1036 if err := s.validator.ValidatePatch(&patch); err != nil { 1037 s.logger.Error("patch validation failed", "err", err) 1038 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 1039 return 1040 } 1041 1042 - s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, "", "", nil, nil, isStacked) 1043 } 1044 1045 - 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) { 1046 repoString := strings.SplitN(forkRepo, "/", 2) 1047 forkOwnerDid := repoString[0] 1048 repoName := repoString[1] ··· 1144 Sha: sourceRev, 1145 } 1146 1147 - s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, combined, sourceRev, pullSource, recordPullSource, isStacked) 1148 } 1149 1150 func (s *Pulls) createPullRequest( 1151 w http.ResponseWriter, 1152 r *http.Request, 1153 - f *reporesolver.ResolvedRepo, 1154 user *oauth.User, 1155 title, body, targetBranch string, 1156 patch string, ··· 1165 s.createStackedPullRequest( 1166 w, 1167 r, 1168 - f, 1169 user, 1170 targetBranch, 1171 patch, ··· 1211 } 1212 } 1213 1214 rkey := tid.TID() 1215 initialSubmission := models.PullSubmission{ 1216 Patch: patch, ··· 1222 Body: body, 1223 TargetBranch: targetBranch, 1224 OwnerDid: user.Did, 1225 - RepoAt: f.RepoAt(), 1226 Rkey: rkey, 1227 Submissions: []*models.PullSubmission{ 1228 &initialSubmission, 1229 }, ··· 1235 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1236 return 1237 } 1238 - pullId, err := db.NextPullId(tx, f.RepoAt()) 1239 if err != nil { 1240 log.Println("failed to get pull id", err) 1241 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1242 return 1243 } 1244 1245 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1246 Collection: tangled.RepoPullNSID, 1247 Repo: user.Did, ··· 1250 Val: &tangled.RepoPull{ 1251 Title: title, 1252 Target: &tangled.RepoPull_Target{ 1253 - Repo: string(f.RepoAt()), 1254 Branch: targetBranch, 1255 }, 1256 - Patch: patch, 1257 Source: recordPullSource, 1258 CreatedAt: time.Now().Format(time.RFC3339), 1259 }, ··· 1273 1274 s.notifier.NewPull(r.Context(), pull) 1275 1276 - s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId)) 1277 } 1278 1279 func (s *Pulls) createStackedPullRequest( 1280 w http.ResponseWriter, 1281 r *http.Request, 1282 - f *reporesolver.ResolvedRepo, 1283 user *oauth.User, 1284 targetBranch string, 1285 patch string, ··· 1311 1312 // build a stack out of this patch 1313 stackId := uuid.New() 1314 - stack, err := newStack(f, user, targetBranch, patch, pullSource, stackId.String()) 1315 if err != nil { 1316 log.Println("failed to create stack", err) 1317 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to create stack: %v", err)) ··· 1328 // apply all record creations at once 1329 var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem 1330 for _, p := range stack { 1331 record := p.AsRecord() 1332 - write := comatproto.RepoApplyWrites_Input_Writes_Elem{ 1333 RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{ 1334 Collection: tangled.RepoPullNSID, 1335 Rkey: &p.Rkey, ··· 1337 Val: &record, 1338 }, 1339 }, 1340 - } 1341 - writes = append(writes, &write) 1342 } 1343 _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 1344 Repo: user.Did, ··· 1366 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1367 return 1368 } 1369 } 1370 1371 if err = tx.Commit(); err != nil { ··· 1374 return 1375 } 1376 1377 - s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls", f.OwnerSlashRepo())) 1378 } 1379 1380 func (s *Pulls) ValidatePatch(w http.ResponseWriter, r *http.Request) { ··· 1405 1406 func (s *Pulls) PatchUploadFragment(w http.ResponseWriter, r *http.Request) { 1407 user := s.oauth.GetUser(r) 1408 - f, err := s.repoResolver.Resolve(r) 1409 - if err != nil { 1410 - log.Println("failed to get repo and knot", err) 1411 - return 1412 - } 1413 1414 s.pages.PullPatchUploadFragment(w, pages.PullPatchUploadParams{ 1415 - RepoInfo: f.RepoInfo(user), 1416 }) 1417 } 1418 ··· 1433 Host: host, 1434 } 1435 1436 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1437 xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 1438 if err != nil { 1439 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { ··· 1466 } 1467 1468 s.pages.PullCompareBranchesFragment(w, pages.PullCompareBranchesParams{ 1469 - RepoInfo: f.RepoInfo(user), 1470 Branches: withoutDefault, 1471 }) 1472 } 1473 1474 func (s *Pulls) CompareForksFragment(w http.ResponseWriter, r *http.Request) { 1475 user := s.oauth.GetUser(r) 1476 - f, err := s.repoResolver.Resolve(r) 1477 - if err != nil { 1478 - log.Println("failed to get repo and knot", err) 1479 - return 1480 - } 1481 1482 forks, err := db.GetForksByDid(s.db, user.Did) 1483 if err != nil { ··· 1486 } 1487 1488 s.pages.PullCompareForkFragment(w, pages.PullCompareForkParams{ 1489 - RepoInfo: f.RepoInfo(user), 1490 Forks: forks, 1491 Selected: r.URL.Query().Get("fork"), 1492 }) ··· 1508 // fork repo 1509 repo, err := db.GetRepo( 1510 s.db, 1511 - db.FilterEq("did", forkOwnerDid), 1512 - db.FilterEq("name", forkName), 1513 ) 1514 if err != nil { 1515 log.Println("failed to get repo", "did", forkOwnerDid, "name", forkName, "err", err) ··· 1554 Host: targetHost, 1555 } 1556 1557 - targetRepo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1558 targetXrpcBytes, err := tangled.RepoBranches(r.Context(), targetXrpcc, "", 0, targetRepo) 1559 if err != nil { 1560 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { ··· 1579 }) 1580 1581 s.pages.PullCompareForkBranchesFragment(w, pages.PullCompareForkBranchesParams{ 1582 - RepoInfo: f.RepoInfo(user), 1583 SourceBranches: sourceBranches.Branches, 1584 TargetBranches: targetBranches.Branches, 1585 }) ··· 1587 1588 func (s *Pulls) ResubmitPull(w http.ResponseWriter, r *http.Request) { 1589 user := s.oauth.GetUser(r) 1590 - f, err := s.repoResolver.Resolve(r) 1591 - if err != nil { 1592 - log.Println("failed to get repo and knot", err) 1593 - return 1594 - } 1595 1596 pull, ok := r.Context().Value("pull").(*models.Pull) 1597 if !ok { ··· 1603 switch r.Method { 1604 case http.MethodGet: 1605 s.pages.PullResubmitFragment(w, pages.PullResubmitParams{ 1606 - RepoInfo: f.RepoInfo(user), 1607 Pull: pull, 1608 }) 1609 return ··· 1670 return 1671 } 1672 1673 - if !f.RepoInfo(user).Roles.IsPushAllowed() { 1674 log.Println("unauthorized user") 1675 w.WriteHeader(http.StatusUnauthorized) 1676 return ··· 1685 Host: host, 1686 } 1687 1688 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1689 xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, pull.TargetBranch, pull.PullSource.Branch) 1690 if err != nil { 1691 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { ··· 1812 func (s *Pulls) resubmitPullHelper( 1813 w http.ResponseWriter, 1814 r *http.Request, 1815 - f *reporesolver.ResolvedRepo, 1816 user *oauth.User, 1817 pull *models.Pull, 1818 patch string, ··· 1821 ) { 1822 if pull.IsStacked() { 1823 log.Println("resubmitting stacked PR") 1824 - s.resubmitStackedPullHelper(w, r, f, user, pull, patch, pull.StackId) 1825 return 1826 } 1827 ··· 1876 return 1877 } 1878 1879 - var recordPullSource *tangled.RepoPull_Source 1880 - if pull.IsBranchBased() { 1881 - recordPullSource = &tangled.RepoPull_Source{ 1882 - Branch: pull.PullSource.Branch, 1883 - Sha: sourceRev, 1884 - } 1885 } 1886 - if pull.IsForkBased() { 1887 - repoAt := pull.PullSource.RepoAt.String() 1888 - recordPullSource = &tangled.RepoPull_Source{ 1889 - Branch: pull.PullSource.Branch, 1890 - Repo: &repoAt, 1891 - Sha: sourceRev, 1892 - } 1893 - } 1894 1895 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1896 Collection: tangled.RepoPullNSID, ··· 1898 Rkey: pull.Rkey, 1899 SwapRecord: ex.Cid, 1900 Record: &lexutil.LexiconTypeDecoder{ 1901 - Val: &tangled.RepoPull{ 1902 - Title: pull.Title, 1903 - Target: &tangled.RepoPull_Target{ 1904 - Repo: string(f.RepoAt()), 1905 - Branch: pull.TargetBranch, 1906 - }, 1907 - Patch: patch, // new patch 1908 - Source: recordPullSource, 1909 - CreatedAt: time.Now().Format(time.RFC3339), 1910 - }, 1911 }, 1912 }) 1913 if err != nil { ··· 1922 return 1923 } 1924 1925 - s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 1926 } 1927 1928 func (s *Pulls) resubmitStackedPullHelper( 1929 w http.ResponseWriter, 1930 r *http.Request, 1931 - f *reporesolver.ResolvedRepo, 1932 user *oauth.User, 1933 pull *models.Pull, 1934 patch string, ··· 1937 targetBranch := pull.TargetBranch 1938 1939 origStack, _ := r.Context().Value("stack").(models.Stack) 1940 - newStack, err := newStack(f, user, targetBranch, patch, pull.PullSource, stackId) 1941 if err != nil { 1942 log.Println("failed to create resubmitted stack", err) 1943 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") ··· 1992 } 1993 defer tx.Rollback() 1994 1995 // pds updates to make 1996 var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem 1997 ··· 2025 return 2026 } 2027 2028 record := p.AsRecord() 2029 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 2030 RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{ 2031 Collection: tangled.RepoPullNSID, ··· 2060 return 2061 } 2062 2063 record := np.AsRecord() 2064 - 2065 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 2066 RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{ 2067 Collection: tangled.RepoPullNSID, ··· 2079 tx, 2080 p.ParentChangeId, 2081 // these should be enough filters to be unique per-stack 2082 - db.FilterEq("repo_at", p.RepoAt.String()), 2083 - db.FilterEq("owner_did", p.OwnerDid), 2084 - db.FilterEq("change_id", p.ChangeId), 2085 ) 2086 2087 if err != nil { ··· 2098 return 2099 } 2100 2101 - client, err := s.oauth.AuthorizedClient(r) 2102 - if err != nil { 2103 - log.Println("failed to authorize client") 2104 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 2105 - return 2106 - } 2107 - 2108 _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 2109 Repo: user.Did, 2110 Writes: writes, ··· 2115 return 2116 } 2117 2118 - s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 2119 } 2120 2121 func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) { ··· 2168 2169 authorName := ident.Handle.String() 2170 mergeInput := &tangled.RepoMerge_Input{ 2171 - Did: f.OwnerDid(), 2172 Name: f.Name, 2173 Branch: pull.TargetBranch, 2174 Patch: patch, ··· 2233 s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p) 2234 } 2235 2236 - s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.Name, pull.PullId)) 2237 } 2238 2239 func (s *Pulls) ClosePull(w http.ResponseWriter, r *http.Request) { ··· 2253 } 2254 2255 // auth filter: only owner or collaborators can close 2256 - roles := f.RolesInRepo(user) 2257 isOwner := roles.IsOwner() 2258 isCollaborator := roles.IsCollaborator() 2259 isPullAuthor := user.Did == pull.OwnerDid ··· 2305 s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p) 2306 } 2307 2308 - s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 2309 } 2310 2311 func (s *Pulls) ReopenPull(w http.ResponseWriter, r *http.Request) { ··· 2326 } 2327 2328 // auth filter: only owner or collaborators can close 2329 - roles := f.RolesInRepo(user) 2330 isOwner := roles.IsOwner() 2331 isCollaborator := roles.IsCollaborator() 2332 isPullAuthor := user.Did == pull.OwnerDid ··· 2378 s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p) 2379 } 2380 2381 - s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 2382 } 2383 2384 - func newStack(f *reporesolver.ResolvedRepo, user *oauth.User, targetBranch, patch string, pullSource *models.PullSource, stackId string) (models.Stack, error) { 2385 formatPatches, err := patchutil.ExtractPatches(patch) 2386 if err != nil { 2387 return nil, fmt.Errorf("Failed to extract patches: %v", err) ··· 2406 body := fp.Body 2407 rkey := tid.TID() 2408 2409 initialSubmission := models.PullSubmission{ 2410 Patch: fp.Raw, 2411 SourceRev: fp.SHA, ··· 2416 Body: body, 2417 TargetBranch: targetBranch, 2418 OwnerDid: user.Did, 2419 - RepoAt: f.RepoAt(), 2420 Rkey: rkey, 2421 Submissions: []*models.PullSubmission{ 2422 &initialSubmission, 2423 },
··· 1 package pulls 2 3 import ( 4 + "context" 5 "database/sql" 6 "encoding/json" 7 "errors" ··· 19 "tangled.org/core/appview/config" 20 "tangled.org/core/appview/db" 21 pulls_indexer "tangled.org/core/appview/indexer/pulls" 22 + "tangled.org/core/appview/mentions" 23 "tangled.org/core/appview/models" 24 "tangled.org/core/appview/notify" 25 "tangled.org/core/appview/oauth" 26 "tangled.org/core/appview/pages" 27 "tangled.org/core/appview/pages/markup" 28 + "tangled.org/core/appview/pages/repoinfo" 29 "tangled.org/core/appview/reporesolver" 30 "tangled.org/core/appview/validator" 31 "tangled.org/core/appview/xrpcclient" 32 "tangled.org/core/idresolver" 33 + "tangled.org/core/orm" 34 "tangled.org/core/patchutil" 35 "tangled.org/core/rbac" 36 "tangled.org/core/tid" ··· 45 ) 46 47 type Pulls struct { 48 + oauth *oauth.OAuth 49 + repoResolver *reporesolver.RepoResolver 50 + pages *pages.Pages 51 + idResolver *idresolver.Resolver 52 + mentionsResolver *mentions.Resolver 53 + db *db.DB 54 + config *config.Config 55 + notifier notify.Notifier 56 + enforcer *rbac.Enforcer 57 + logger *slog.Logger 58 + validator *validator.Validator 59 + indexer *pulls_indexer.Indexer 60 } 61 62 func New( ··· 64 repoResolver *reporesolver.RepoResolver, 65 pages *pages.Pages, 66 resolver *idresolver.Resolver, 67 + mentionsResolver *mentions.Resolver, 68 db *db.DB, 69 config *config.Config, 70 notifier notify.Notifier, ··· 74 logger *slog.Logger, 75 ) *Pulls { 76 return &Pulls{ 77 + oauth: oauth, 78 + repoResolver: repoResolver, 79 + pages: pages, 80 + idResolver: resolver, 81 + mentionsResolver: mentionsResolver, 82 + db: db, 83 + config: config, 84 + notifier: notifier, 85 + enforcer: enforcer, 86 + logger: logger, 87 + validator: validator, 88 + indexer: indexer, 89 } 90 } 91 ··· 130 131 s.pages.PullActionsFragment(w, pages.PullActionsParams{ 132 LoggedInUser: user, 133 + RepoInfo: s.repoResolver.GetRepoInfo(r, user), 134 Pull: pull, 135 RoundNumber: roundNumber, 136 MergeCheck: mergeCheckResponse, ··· 157 return 158 } 159 160 + backlinks, err := db.GetBacklinks(s.db, pull.AtUri()) 161 + if err != nil { 162 + log.Println("failed to get pull backlinks", err) 163 + s.pages.Notice(w, "pull-error", "Failed to get pull. Try again later.") 164 + return 165 + } 166 + 167 // can be nil if this pull is not stacked 168 stack, _ := r.Context().Value("stack").(models.Stack) 169 abandonedPulls, _ := r.Context().Value("abandonedPulls").([]*models.Pull) ··· 174 if user != nil && user.Did == pull.OwnerDid { 175 resubmitResult = s.resubmitCheck(r, f, pull, stack) 176 } 177 178 m := make(map[string]models.Pipeline) 179 ··· 191 ps, err := db.GetPipelineStatuses( 192 s.db, 193 len(shas), 194 + orm.FilterEq("repo_owner", f.Did), 195 + orm.FilterEq("repo_name", f.Name), 196 + orm.FilterEq("knot", f.Knot), 197 + orm.FilterIn("sha", shas), 198 ) 199 if err != nil { 200 log.Printf("failed to fetch pipeline statuses: %s", err) ··· 218 219 labelDefs, err := db.GetLabelDefinitions( 220 s.db, 221 + orm.FilterIn("at_uri", f.Labels), 222 + orm.FilterContains("scope", tangled.RepoPullNSID), 223 ) 224 if err != nil { 225 log.Println("failed to fetch labels", err) ··· 234 235 s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 236 LoggedInUser: user, 237 + RepoInfo: s.repoResolver.GetRepoInfo(r, user), 238 Pull: pull, 239 Stack: stack, 240 AbandonedPulls: abandonedPulls, 241 + Backlinks: backlinks, 242 BranchDeleteStatus: branchDeleteStatus, 243 MergeCheck: mergeCheckResponse, 244 ResubmitCheck: resubmitResult, ··· 252 }) 253 } 254 255 + func (s *Pulls) mergeCheck(r *http.Request, f *models.Repo, pull *models.Pull, stack models.Stack) types.MergeCheckResponse { 256 if pull.State == models.PullMerged { 257 return types.MergeCheckResponse{} 258 } ··· 281 r.Context(), 282 &xrpcc, 283 &tangled.RepoMergeCheck_Input{ 284 + Did: f.Did, 285 Name: f.Name, 286 Branch: pull.TargetBranch, 287 Patch: patch, ··· 319 return result 320 } 321 322 + func (s *Pulls) branchDeleteStatus(r *http.Request, repo *models.Repo, pull *models.Pull) *models.BranchDeleteStatus { 323 if pull.State != models.PullMerged { 324 return nil 325 } ··· 330 } 331 332 var branch string 333 // check if the branch exists 334 // NOTE: appview could cache branches/tags etc. for every repo by listening for gitRefUpdates 335 if pull.IsBranchBased() { 336 branch = pull.PullSource.Branch 337 } else if pull.IsForkBased() { 338 branch = pull.PullSource.Branch 339 repo = pull.PullSource.Repo ··· 372 } 373 } 374 375 + func (s *Pulls) resubmitCheck(r *http.Request, repo *models.Repo, pull *models.Pull, stack models.Stack) pages.ResubmitResult { 376 if pull.State == models.PullMerged || pull.State == models.PullDeleted || pull.PullSource == nil { 377 return pages.Unknown 378 } ··· 392 repoName = sourceRepo.Name 393 } else { 394 // pulls within the same repo 395 + knot = repo.Knot 396 + ownerDid = repo.Did 397 + repoName = repo.Name 398 } 399 400 scheme := "http" ··· 406 Host: host, 407 } 408 409 + didSlashName := fmt.Sprintf("%s/%s", ownerDid, repoName) 410 + branchResp, err := tangled.RepoBranch(r.Context(), xrpcc, pull.PullSource.Branch, didSlashName) 411 if err != nil { 412 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 413 log.Println("failed to call XRPC repo.branches", xrpcerr) ··· 435 436 func (s *Pulls) RepoPullPatch(w http.ResponseWriter, r *http.Request) { 437 user := s.oauth.GetUser(r) 438 439 var diffOpts types.DiffOpts 440 if d := r.URL.Query().Get("diff"); d == "split" { ··· 463 464 s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{ 465 LoggedInUser: user, 466 + RepoInfo: s.repoResolver.GetRepoInfo(r, user), 467 Pull: pull, 468 Stack: stack, 469 Round: roundIdInt, ··· 477 func (s *Pulls) RepoPullInterdiff(w http.ResponseWriter, r *http.Request) { 478 user := s.oauth.GetUser(r) 479 480 var diffOpts types.DiffOpts 481 if d := r.URL.Query().Get("diff"); d == "split" { 482 diffOpts.Split = true ··· 521 522 s.pages.RepoPullInterdiffPage(w, pages.RepoPullInterdiffParams{ 523 LoggedInUser: s.oauth.GetUser(r), 524 + RepoInfo: s.repoResolver.GetRepoInfo(r, user), 525 Pull: pull, 526 Round: roundIdInt, 527 Interdiff: interdiff, ··· 598 599 pulls, err := db.GetPulls( 600 s.db, 601 + orm.FilterIn("id", ids), 602 ) 603 if err != nil { 604 log.Println("failed to get pulls", err) ··· 646 } 647 pulls = pulls[:n] 648 649 ps, err := db.GetPipelineStatuses( 650 s.db, 651 len(shas), 652 + orm.FilterEq("repo_owner", f.Did), 653 + orm.FilterEq("repo_name", f.Name), 654 + orm.FilterEq("knot", f.Knot), 655 + orm.FilterIn("sha", shas), 656 ) 657 if err != nil { 658 log.Printf("failed to fetch pipeline statuses: %s", err) ··· 665 666 labelDefs, err := db.GetLabelDefinitions( 667 s.db, 668 + orm.FilterIn("at_uri", f.Labels), 669 + orm.FilterContains("scope", tangled.RepoPullNSID), 670 ) 671 if err != nil { 672 log.Println("failed to fetch labels", err) ··· 681 682 s.pages.RepoPulls(w, pages.RepoPullsParams{ 683 LoggedInUser: s.oauth.GetUser(r), 684 + RepoInfo: s.repoResolver.GetRepoInfo(r, user), 685 Pulls: pulls, 686 LabelDefs: defs, 687 FilteringBy: state, ··· 692 } 693 694 func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) { 695 user := s.oauth.GetUser(r) 696 f, err := s.repoResolver.Resolve(r) 697 if err != nil { ··· 718 case http.MethodGet: 719 s.pages.PullNewCommentFragment(w, pages.PullNewCommentParams{ 720 LoggedInUser: user, 721 + RepoInfo: s.repoResolver.GetRepoInfo(r, user), 722 Pull: pull, 723 RoundNumber: roundNumber, 724 }) ··· 729 s.pages.Notice(w, "pull", "Comment body is required") 730 return 731 } 732 + 733 + mentions, references := s.mentionsResolver.Resolve(r.Context(), body) 734 735 // Start a transaction 736 tx, err := s.db.BeginTx(r.Context(), nil) ··· 774 Body: body, 775 CommentAt: atResp.Uri, 776 SubmissionId: pull.Submissions[roundNumber].ID, 777 + Mentions: mentions, 778 + References: references, 779 } 780 781 // Create the pull comment in the database with the commentAt field ··· 793 return 794 } 795 796 s.notifier.NewPullComment(r.Context(), comment, mentions) 797 798 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 799 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", ownerSlashRepo, pull.PullId, commentId)) 800 return 801 } 802 } ··· 820 Host: host, 821 } 822 823 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 824 xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 825 if err != nil { 826 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { ··· 847 848 s.pages.RepoNewPull(w, pages.RepoNewPullParams{ 849 LoggedInUser: user, 850 + RepoInfo: s.repoResolver.GetRepoInfo(r, user), 851 Branches: result.Branches, 852 Strategy: strategy, 853 SourceBranch: sourceBranch, ··· 870 } 871 872 // Determine PR type based on input parameters 873 + roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())} 874 + isPushAllowed := roles.IsPushAllowed() 875 isBranchBased := isPushAllowed && sourceBranch != "" && fromFork == "" 876 isForkBased := fromFork != "" && sourceBranch != "" 877 isPatchBased := patch != "" && !isBranchBased && !isForkBased ··· 969 func (s *Pulls) handleBranchBasedPull( 970 w http.ResponseWriter, 971 r *http.Request, 972 + repo *models.Repo, 973 user *oauth.User, 974 title, 975 body, ··· 981 if !s.config.Core.Dev { 982 scheme = "https" 983 } 984 + host := fmt.Sprintf("%s://%s", scheme, repo.Knot) 985 xrpcc := &indigoxrpc.Client{ 986 Host: host, 987 } 988 989 + didSlashRepo := fmt.Sprintf("%s/%s", repo.Did, repo.Name) 990 + xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, didSlashRepo, targetBranch, sourceBranch) 991 if err != nil { 992 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 993 log.Println("failed to call XRPC repo.compare", xrpcerr) ··· 1024 Sha: comparison.Rev2, 1025 } 1026 1027 + s.createPullRequest(w, r, repo, user, title, body, targetBranch, patch, combined, sourceRev, pullSource, recordPullSource, isStacked) 1028 } 1029 1030 + func (s *Pulls) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, repo *models.Repo, user *oauth.User, title, body, targetBranch, patch string, isStacked bool) { 1031 if err := s.validator.ValidatePatch(&patch); err != nil { 1032 s.logger.Error("patch validation failed", "err", err) 1033 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 1034 return 1035 } 1036 1037 + s.createPullRequest(w, r, repo, user, title, body, targetBranch, patch, "", "", nil, nil, isStacked) 1038 } 1039 1040 + func (s *Pulls) handleForkBasedPull(w http.ResponseWriter, r *http.Request, repo *models.Repo, user *oauth.User, forkRepo string, title, body, targetBranch, sourceBranch string, isStacked bool) { 1041 repoString := strings.SplitN(forkRepo, "/", 2) 1042 forkOwnerDid := repoString[0] 1043 repoName := repoString[1] ··· 1139 Sha: sourceRev, 1140 } 1141 1142 + s.createPullRequest(w, r, repo, user, title, body, targetBranch, patch, combined, sourceRev, pullSource, recordPullSource, isStacked) 1143 } 1144 1145 func (s *Pulls) createPullRequest( 1146 w http.ResponseWriter, 1147 r *http.Request, 1148 + repo *models.Repo, 1149 user *oauth.User, 1150 title, body, targetBranch string, 1151 patch string, ··· 1160 s.createStackedPullRequest( 1161 w, 1162 r, 1163 + repo, 1164 user, 1165 targetBranch, 1166 patch, ··· 1206 } 1207 } 1208 1209 + mentions, references := s.mentionsResolver.Resolve(r.Context(), body) 1210 + 1211 rkey := tid.TID() 1212 initialSubmission := models.PullSubmission{ 1213 Patch: patch, ··· 1219 Body: body, 1220 TargetBranch: targetBranch, 1221 OwnerDid: user.Did, 1222 + RepoAt: repo.RepoAt(), 1223 Rkey: rkey, 1224 + Mentions: mentions, 1225 + References: references, 1226 Submissions: []*models.PullSubmission{ 1227 &initialSubmission, 1228 }, ··· 1234 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1235 return 1236 } 1237 + pullId, err := db.NextPullId(tx, repo.RepoAt()) 1238 if err != nil { 1239 log.Println("failed to get pull id", err) 1240 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1241 return 1242 } 1243 1244 + blob, err := comatproto.RepoUploadBlob(r.Context(), client, strings.NewReader(patch)) 1245 + if err != nil { 1246 + log.Println("failed to upload patch", err) 1247 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1248 + return 1249 + } 1250 + 1251 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1252 Collection: tangled.RepoPullNSID, 1253 Repo: user.Did, ··· 1256 Val: &tangled.RepoPull{ 1257 Title: title, 1258 Target: &tangled.RepoPull_Target{ 1259 + Repo: string(repo.RepoAt()), 1260 Branch: targetBranch, 1261 }, 1262 + PatchBlob: blob.Blob, 1263 Source: recordPullSource, 1264 CreatedAt: time.Now().Format(time.RFC3339), 1265 }, ··· 1279 1280 s.notifier.NewPull(r.Context(), pull) 1281 1282 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo) 1283 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pullId)) 1284 } 1285 1286 func (s *Pulls) createStackedPullRequest( 1287 w http.ResponseWriter, 1288 r *http.Request, 1289 + repo *models.Repo, 1290 user *oauth.User, 1291 targetBranch string, 1292 patch string, ··· 1318 1319 // build a stack out of this patch 1320 stackId := uuid.New() 1321 + stack, err := s.newStack(r.Context(), repo, user, targetBranch, patch, pullSource, stackId.String()) 1322 if err != nil { 1323 log.Println("failed to create stack", err) 1324 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to create stack: %v", err)) ··· 1335 // apply all record creations at once 1336 var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem 1337 for _, p := range stack { 1338 + blob, err := comatproto.RepoUploadBlob(r.Context(), client, strings.NewReader(p.LatestPatch())) 1339 + if err != nil { 1340 + log.Println("failed to upload patch blob", err) 1341 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1342 + return 1343 + } 1344 + 1345 record := p.AsRecord() 1346 + record.PatchBlob = blob.Blob 1347 + writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 1348 RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{ 1349 Collection: tangled.RepoPullNSID, 1350 Rkey: &p.Rkey, ··· 1352 Val: &record, 1353 }, 1354 }, 1355 + }) 1356 } 1357 _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 1358 Repo: user.Did, ··· 1380 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1381 return 1382 } 1383 + 1384 } 1385 1386 if err = tx.Commit(); err != nil { ··· 1389 return 1390 } 1391 1392 + // notify about each pull 1393 + // 1394 + // this is performed after tx.Commit, because it could result in a locked DB otherwise 1395 + for _, p := range stack { 1396 + s.notifier.NewPull(r.Context(), p) 1397 + } 1398 + 1399 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo) 1400 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls", ownerSlashRepo)) 1401 } 1402 1403 func (s *Pulls) ValidatePatch(w http.ResponseWriter, r *http.Request) { ··· 1428 1429 func (s *Pulls) PatchUploadFragment(w http.ResponseWriter, r *http.Request) { 1430 user := s.oauth.GetUser(r) 1431 1432 s.pages.PullPatchUploadFragment(w, pages.PullPatchUploadParams{ 1433 + RepoInfo: s.repoResolver.GetRepoInfo(r, user), 1434 }) 1435 } 1436 ··· 1451 Host: host, 1452 } 1453 1454 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 1455 xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 1456 if err != nil { 1457 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { ··· 1484 } 1485 1486 s.pages.PullCompareBranchesFragment(w, pages.PullCompareBranchesParams{ 1487 + RepoInfo: s.repoResolver.GetRepoInfo(r, user), 1488 Branches: withoutDefault, 1489 }) 1490 } 1491 1492 func (s *Pulls) CompareForksFragment(w http.ResponseWriter, r *http.Request) { 1493 user := s.oauth.GetUser(r) 1494 1495 forks, err := db.GetForksByDid(s.db, user.Did) 1496 if err != nil { ··· 1499 } 1500 1501 s.pages.PullCompareForkFragment(w, pages.PullCompareForkParams{ 1502 + RepoInfo: s.repoResolver.GetRepoInfo(r, user), 1503 Forks: forks, 1504 Selected: r.URL.Query().Get("fork"), 1505 }) ··· 1521 // fork repo 1522 repo, err := db.GetRepo( 1523 s.db, 1524 + orm.FilterEq("did", forkOwnerDid), 1525 + orm.FilterEq("name", forkName), 1526 ) 1527 if err != nil { 1528 log.Println("failed to get repo", "did", forkOwnerDid, "name", forkName, "err", err) ··· 1567 Host: targetHost, 1568 } 1569 1570 + targetRepo := fmt.Sprintf("%s/%s", f.Did, f.Name) 1571 targetXrpcBytes, err := tangled.RepoBranches(r.Context(), targetXrpcc, "", 0, targetRepo) 1572 if err != nil { 1573 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { ··· 1592 }) 1593 1594 s.pages.PullCompareForkBranchesFragment(w, pages.PullCompareForkBranchesParams{ 1595 + RepoInfo: s.repoResolver.GetRepoInfo(r, user), 1596 SourceBranches: sourceBranches.Branches, 1597 TargetBranches: targetBranches.Branches, 1598 }) ··· 1600 1601 func (s *Pulls) ResubmitPull(w http.ResponseWriter, r *http.Request) { 1602 user := s.oauth.GetUser(r) 1603 1604 pull, ok := r.Context().Value("pull").(*models.Pull) 1605 if !ok { ··· 1611 switch r.Method { 1612 case http.MethodGet: 1613 s.pages.PullResubmitFragment(w, pages.PullResubmitParams{ 1614 + RepoInfo: s.repoResolver.GetRepoInfo(r, user), 1615 Pull: pull, 1616 }) 1617 return ··· 1678 return 1679 } 1680 1681 + roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())} 1682 + if !roles.IsPushAllowed() { 1683 log.Println("unauthorized user") 1684 w.WriteHeader(http.StatusUnauthorized) 1685 return ··· 1694 Host: host, 1695 } 1696 1697 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 1698 xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, pull.TargetBranch, pull.PullSource.Branch) 1699 if err != nil { 1700 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { ··· 1821 func (s *Pulls) resubmitPullHelper( 1822 w http.ResponseWriter, 1823 r *http.Request, 1824 + repo *models.Repo, 1825 user *oauth.User, 1826 pull *models.Pull, 1827 patch string, ··· 1830 ) { 1831 if pull.IsStacked() { 1832 log.Println("resubmitting stacked PR") 1833 + s.resubmitStackedPullHelper(w, r, repo, user, pull, patch, pull.StackId) 1834 return 1835 } 1836 ··· 1885 return 1886 } 1887 1888 + blob, err := comatproto.RepoUploadBlob(r.Context(), client, strings.NewReader(patch)) 1889 + if err != nil { 1890 + log.Println("failed to upload patch blob", err) 1891 + s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 1892 + return 1893 } 1894 + record := pull.AsRecord() 1895 + record.PatchBlob = blob.Blob 1896 + record.CreatedAt = time.Now().Format(time.RFC3339) 1897 1898 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1899 Collection: tangled.RepoPullNSID, ··· 1901 Rkey: pull.Rkey, 1902 SwapRecord: ex.Cid, 1903 Record: &lexutil.LexiconTypeDecoder{ 1904 + Val: &record, 1905 }, 1906 }) 1907 if err != nil { ··· 1916 return 1917 } 1918 1919 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo) 1920 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 1921 } 1922 1923 func (s *Pulls) resubmitStackedPullHelper( 1924 w http.ResponseWriter, 1925 r *http.Request, 1926 + repo *models.Repo, 1927 user *oauth.User, 1928 pull *models.Pull, 1929 patch string, ··· 1932 targetBranch := pull.TargetBranch 1933 1934 origStack, _ := r.Context().Value("stack").(models.Stack) 1935 + newStack, err := s.newStack(r.Context(), repo, user, targetBranch, patch, pull.PullSource, stackId) 1936 if err != nil { 1937 log.Println("failed to create resubmitted stack", err) 1938 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") ··· 1987 } 1988 defer tx.Rollback() 1989 1990 + client, err := s.oauth.AuthorizedClient(r) 1991 + if err != nil { 1992 + log.Println("failed to authorize client") 1993 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1994 + return 1995 + } 1996 + 1997 // pds updates to make 1998 var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem 1999 ··· 2027 return 2028 } 2029 2030 + blob, err := comatproto.RepoUploadBlob(r.Context(), client, strings.NewReader(patch)) 2031 + if err != nil { 2032 + log.Println("failed to upload patch blob", err) 2033 + s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 2034 + return 2035 + } 2036 record := p.AsRecord() 2037 + record.PatchBlob = blob.Blob 2038 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 2039 RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{ 2040 Collection: tangled.RepoPullNSID, ··· 2069 return 2070 } 2071 2072 + blob, err := comatproto.RepoUploadBlob(r.Context(), client, strings.NewReader(patch)) 2073 + if err != nil { 2074 + log.Println("failed to upload patch blob", err) 2075 + s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 2076 + return 2077 + } 2078 record := np.AsRecord() 2079 + record.PatchBlob = blob.Blob 2080 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 2081 RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{ 2082 Collection: tangled.RepoPullNSID, ··· 2094 tx, 2095 p.ParentChangeId, 2096 // these should be enough filters to be unique per-stack 2097 + orm.FilterEq("repo_at", p.RepoAt.String()), 2098 + orm.FilterEq("owner_did", p.OwnerDid), 2099 + orm.FilterEq("change_id", p.ChangeId), 2100 ) 2101 2102 if err != nil { ··· 2113 return 2114 } 2115 2116 _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 2117 Repo: user.Did, 2118 Writes: writes, ··· 2123 return 2124 } 2125 2126 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo) 2127 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 2128 } 2129 2130 func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) { ··· 2177 2178 authorName := ident.Handle.String() 2179 mergeInput := &tangled.RepoMerge_Input{ 2180 + Did: f.Did, 2181 Name: f.Name, 2182 Branch: pull.TargetBranch, 2183 Patch: patch, ··· 2242 s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p) 2243 } 2244 2245 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 2246 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 2247 } 2248 2249 func (s *Pulls) ClosePull(w http.ResponseWriter, r *http.Request) { ··· 2263 } 2264 2265 // auth filter: only owner or collaborators can close 2266 + roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())} 2267 isOwner := roles.IsOwner() 2268 isCollaborator := roles.IsCollaborator() 2269 isPullAuthor := user.Did == pull.OwnerDid ··· 2315 s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p) 2316 } 2317 2318 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 2319 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 2320 } 2321 2322 func (s *Pulls) ReopenPull(w http.ResponseWriter, r *http.Request) { ··· 2337 } 2338 2339 // auth filter: only owner or collaborators can close 2340 + roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())} 2341 isOwner := roles.IsOwner() 2342 isCollaborator := roles.IsCollaborator() 2343 isPullAuthor := user.Did == pull.OwnerDid ··· 2389 s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p) 2390 } 2391 2392 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 2393 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 2394 } 2395 2396 + func (s *Pulls) newStack(ctx context.Context, repo *models.Repo, user *oauth.User, targetBranch, patch string, pullSource *models.PullSource, stackId string) (models.Stack, error) { 2397 formatPatches, err := patchutil.ExtractPatches(patch) 2398 if err != nil { 2399 return nil, fmt.Errorf("Failed to extract patches: %v", err) ··· 2418 body := fp.Body 2419 rkey := tid.TID() 2420 2421 + mentions, references := s.mentionsResolver.Resolve(ctx, body) 2422 + 2423 initialSubmission := models.PullSubmission{ 2424 Patch: fp.Raw, 2425 SourceRev: fp.SHA, ··· 2430 Body: body, 2431 TargetBranch: targetBranch, 2432 OwnerDid: user.Did, 2433 + RepoAt: repo.RepoAt(), 2434 Rkey: rkey, 2435 + Mentions: mentions, 2436 + References: references, 2437 Submissions: []*models.PullSubmission{ 2438 &initialSubmission, 2439 },
+3 -2
appview/repo/archive.go
··· 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) ··· 31 xrpcc := &indigoxrpc.Client{ 32 Host: host, 33 } 34 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 35 - archiveBytes, err := tangled.RepoArchive(r.Context(), xrpcc, "tar.gz", "", ref, repo) 36 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 37 l.Error("failed to call XRPC repo.archive", "err", xrpcerr) 38 rp.pages.Error503(w)
··· 18 l := rp.logger.With("handler", "DownloadArchive") 19 ref := chi.URLParam(r, "ref") 20 ref, _ = url.PathUnescape(ref) 21 + ref = strings.TrimSuffix(ref, ".tar.gz") 22 f, err := rp.repoResolver.Resolve(r) 23 if err != nil { 24 l.Error("failed to get repo and knot", "err", err) ··· 32 xrpcc := &indigoxrpc.Client{ 33 Host: host, 34 } 35 + didSlashRepo := f.DidSlashRepo() 36 + archiveBytes, err := tangled.RepoArchive(r.Context(), xrpcc, "tar.gz", "", ref, didSlashRepo) 37 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 38 l.Error("failed to call XRPC repo.archive", "err", xrpcerr) 39 rp.pages.Error503(w)
+21 -14
appview/repo/artifact.go
··· 14 "tangled.org/core/appview/db" 15 "tangled.org/core/appview/models" 16 "tangled.org/core/appview/pages" 17 - "tangled.org/core/appview/reporesolver" 18 "tangled.org/core/appview/xrpcclient" 19 "tangled.org/core/tid" 20 "tangled.org/core/types" 21 ··· 131 132 rp.pages.RepoArtifactFragment(w, pages.RepoArtifactParams{ 133 LoggedInUser: user, 134 - RepoInfo: f.RepoInfo(user), 135 Artifact: artifact, 136 }) 137 } ··· 156 157 artifacts, err := db.GetArtifact( 158 rp.db, 159 - db.FilterEq("repo_at", f.RepoAt()), 160 - db.FilterEq("tag", tag.Tag.Hash[:]), 161 - db.FilterEq("name", filename), 162 ) 163 if err != nil { 164 log.Println("failed to get artifacts", err) ··· 174 175 artifact := artifacts[0] 176 177 - ownerPds := f.OwnerId.PDSEndpoint() 178 url, _ := url.Parse(fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob", ownerPds)) 179 q := url.Query() 180 q.Set("cid", artifact.BlobCid.String()) ··· 228 229 artifacts, err := db.GetArtifact( 230 rp.db, 231 - db.FilterEq("repo_at", f.RepoAt()), 232 - db.FilterEq("tag", tag[:]), 233 - db.FilterEq("name", filename), 234 ) 235 if err != nil { 236 log.Println("failed to get artifacts", err) ··· 270 defer tx.Rollback() 271 272 err = db.DeleteArtifact(tx, 273 - db.FilterEq("repo_at", f.RepoAt()), 274 - db.FilterEq("tag", artifact.Tag[:]), 275 - db.FilterEq("name", filename), 276 ) 277 if err != nil { 278 log.Println("failed to remove artifact record from db", err) ··· 290 w.Write([]byte{}) 291 } 292 293 - func (rp *Repo) resolveTag(ctx context.Context, f *reporesolver.ResolvedRepo, tagParam string) (*types.TagReference, error) { 294 tagParam, err := url.QueryUnescape(tagParam) 295 if err != nil { 296 return nil, err ··· 305 Host: host, 306 } 307 308 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 309 xrpcBytes, err := tangled.RepoTags(ctx, xrpcc, "", 0, repo) 310 if err != nil { 311 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
··· 14 "tangled.org/core/appview/db" 15 "tangled.org/core/appview/models" 16 "tangled.org/core/appview/pages" 17 "tangled.org/core/appview/xrpcclient" 18 + "tangled.org/core/orm" 19 "tangled.org/core/tid" 20 "tangled.org/core/types" 21 ··· 131 132 rp.pages.RepoArtifactFragment(w, pages.RepoArtifactParams{ 133 LoggedInUser: user, 134 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 135 Artifact: artifact, 136 }) 137 } ··· 156 157 artifacts, err := db.GetArtifact( 158 rp.db, 159 + orm.FilterEq("repo_at", f.RepoAt()), 160 + orm.FilterEq("tag", tag.Tag.Hash[:]), 161 + orm.FilterEq("name", filename), 162 ) 163 if err != nil { 164 log.Println("failed to get artifacts", err) ··· 174 175 artifact := artifacts[0] 176 177 + ownerId, err := rp.idResolver.ResolveIdent(r.Context(), f.Did) 178 + if err != nil { 179 + log.Println("failed to resolve repo owner did", f.Did, err) 180 + http.Error(w, "repository owner not found", http.StatusNotFound) 181 + return 182 + } 183 + 184 + ownerPds := ownerId.PDSEndpoint() 185 url, _ := url.Parse(fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob", ownerPds)) 186 q := url.Query() 187 q.Set("cid", artifact.BlobCid.String()) ··· 235 236 artifacts, err := db.GetArtifact( 237 rp.db, 238 + orm.FilterEq("repo_at", f.RepoAt()), 239 + orm.FilterEq("tag", tag[:]), 240 + orm.FilterEq("name", filename), 241 ) 242 if err != nil { 243 log.Println("failed to get artifacts", err) ··· 277 defer tx.Rollback() 278 279 err = db.DeleteArtifact(tx, 280 + orm.FilterEq("repo_at", f.RepoAt()), 281 + orm.FilterEq("tag", artifact.Tag[:]), 282 + orm.FilterEq("name", filename), 283 ) 284 if err != nil { 285 log.Println("failed to remove artifact record from db", err) ··· 297 w.Write([]byte{}) 298 } 299 300 + func (rp *Repo) resolveTag(ctx context.Context, f *models.Repo, tagParam string) (*types.TagReference, error) { 301 tagParam, err := url.QueryUnescape(tagParam) 302 if err != nil { 303 return nil, err ··· 312 Host: host, 313 } 314 315 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 316 xrpcBytes, err := tangled.RepoTags(ctx, xrpcc, "", 0, repo) 317 if err != nil { 318 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+11 -9
appview/repo/blob.go
··· 54 xrpcc := &indigoxrpc.Client{ 55 Host: host, 56 } 57 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.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) ··· 62 return 63 } 64 65 // Use XRPC response directly instead of converting to internal types 66 var breadcrumbs [][]string 67 - breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))}) 68 if filePath != "" { 69 for idx, elem := range strings.Split(filePath, "/") { 70 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))}) ··· 78 79 rp.pages.RepoBlob(w, pages.RepoBlobParams{ 80 LoggedInUser: user, 81 - RepoInfo: f.RepoInfo(user), 82 BreadCrumbs: breadcrumbs, 83 BlobView: blobView, 84 RepoBlob_Output: resp, ··· 105 if !rp.config.Core.Dev { 106 scheme = "https" 107 } 108 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name) 109 baseURL := &url.URL{ 110 Scheme: scheme, 111 Host: f.Knot, ··· 176 } 177 178 // NewBlobView creates a BlobView from the XRPC response 179 - func NewBlobView(resp *tangled.RepoBlob_Output, config *config.Config, f *reporesolver.ResolvedRepo, ref, filePath string, queryParams url.Values) models.BlobView { 180 view := models.BlobView{ 181 Contents: "", 182 Lines: 0, ··· 198 199 // Determine if binary 200 if resp.IsBinary != nil && *resp.IsBinary { 201 - view.ContentSrc = generateBlobURL(config, f, ref, filePath) 202 ext := strings.ToLower(filepath.Ext(resp.Path)) 203 204 switch ext { ··· 250 return view 251 } 252 253 - func generateBlobURL(config *config.Config, f *reporesolver.ResolvedRepo, ref, filePath string) string { 254 scheme := "http" 255 if !config.Core.Dev { 256 scheme = "https" 257 } 258 259 - repoName := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 260 baseURL := &url.URL{ 261 Scheme: scheme, 262 - Host: f.Knot, 263 Path: "/xrpc/sh.tangled.repo.blob", 264 } 265 query := baseURL.Query()
··· 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) ··· 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))}) ··· 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, ··· 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, ··· 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, ··· 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 { ··· 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()
+2 -2
appview/repo/branches.go
··· 29 xrpcc := &indigoxrpc.Client{ 30 Host: host, 31 } 32 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), 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) ··· 46 user := rp.oauth.GetUser(r) 47 rp.pages.RepoBranches(w, pages.RepoBranchesParams{ 48 LoggedInUser: user, 49 - RepoInfo: f.RepoInfo(user), 50 RepoBranchesResponse: result, 51 }) 52 }
··· 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) ··· 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 }
+4 -8
appview/repo/compare.go
··· 36 Host: host, 37 } 38 39 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), 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) ··· 88 return 89 } 90 91 - repoinfo := f.RepoInfo(user) 92 - 93 rp.pages.RepoCompareNew(w, pages.RepoCompareNewParams{ 94 LoggedInUser: user, 95 - RepoInfo: repoinfo, 96 Branches: branches, 97 Tags: tags.Tags, 98 Base: base, ··· 151 Host: host, 152 } 153 154 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 155 156 branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 157 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { ··· 202 diff = patchutil.AsNiceDiff(formatPatch.FormatPatchRaw, base) 203 } 204 205 - repoinfo := f.RepoInfo(user) 206 - 207 rp.pages.RepoCompare(w, pages.RepoCompareParams{ 208 LoggedInUser: user, 209 - RepoInfo: repoinfo, 210 Branches: branches.Branches, 211 Tags: tags.Tags, 212 Base: base,
··· 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) ··· 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, ··· 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 { ··· 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,
+24 -17
appview/repo/feed.go
··· 11 "tangled.org/core/appview/db" 12 "tangled.org/core/appview/models" 13 "tangled.org/core/appview/pagination" 14 - "tangled.org/core/appview/reporesolver" 15 16 "github.com/bluesky-social/indigo/atproto/syntax" 17 "github.com/gorilla/feeds" 18 ) 19 20 - func (rp *Repo) getRepoFeed(ctx context.Context, f *reporesolver.ResolvedRepo) (*feeds.Feed, error) { 21 const feedLimitPerType = 100 22 23 - pulls, err := db.GetPullsWithLimit(rp.db, feedLimitPerType, db.FilterEq("repo_at", f.RepoAt())) 24 if err != nil { 25 return nil, err 26 } ··· 28 issues, err := db.GetIssuesPaginated( 29 rp.db, 30 pagination.Page{Limit: feedLimitPerType}, 31 - db.FilterEq("repo_at", f.RepoAt()), 32 ) 33 if err != nil { 34 return nil, err 35 } 36 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"}, 40 Items: make([]*feeds.Item, 0), 41 Updated: time.UnixMilli(0), 42 } 43 44 for _, pull := range pulls { 45 - items, err := rp.createPullItems(ctx, pull, f) 46 if err != nil { 47 return nil, err 48 } ··· 50 } 51 52 for _, issue := range issues { 53 - item, err := rp.createIssueItem(ctx, issue, f) 54 if err != nil { 55 return nil, err 56 } ··· 71 return feed, nil 72 } 73 74 - func (rp *Repo) createPullItems(ctx context.Context, pull *models.Pull, f *reporesolver.ResolvedRepo) ([]*feeds.Item, error) { 75 owner, err := rp.idResolver.ResolveIdent(ctx, pull.OwnerDid) 76 if err != nil { 77 return nil, err ··· 80 var items []*feeds.Item 81 82 state := rp.getPullState(pull) 83 - description := rp.buildPullDescription(owner.Handle, state, pull, f.OwnerSlashRepo()) 84 85 mainItem := &feeds.Item{ 86 Title: fmt.Sprintf("[PR #%d] %s", pull.PullId, pull.Title), 87 Description: description, 88 - Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/pulls/%d", rp.config.Core.AppviewHost, f.OwnerSlashRepo(), pull.PullId)}, 89 Created: pull.Created, 90 Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)}, 91 } ··· 98 99 roundItem := &feeds.Item{ 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)}, 103 Created: round.Created, 104 Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)}, 105 } ··· 109 return items, nil 110 } 111 112 - func (rp *Repo) createIssueItem(ctx context.Context, issue models.Issue, f *reporesolver.ResolvedRepo) (*feeds.Item, error) { 113 owner, err := rp.idResolver.ResolveIdent(ctx, issue.Did) 114 if err != nil { 115 return nil, err ··· 122 123 return &feeds.Item{ 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)}, 127 Created: issue.Created, 128 Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)}, 129 }, nil ··· 152 log.Println("failed to fully resolve repo:", err) 153 return 154 } 155 156 - feed, err := rp.getRepoFeed(r.Context(), f) 157 if err != nil { 158 log.Println("failed to get repo feed:", err) 159 rp.pages.Error500(w)
··· 11 "tangled.org/core/appview/db" 12 "tangled.org/core/appview/models" 13 "tangled.org/core/appview/pagination" 14 + "tangled.org/core/orm" 15 16 + "github.com/bluesky-social/indigo/atproto/identity" 17 "github.com/bluesky-social/indigo/atproto/syntax" 18 "github.com/gorilla/feeds" 19 ) 20 21 + func (rp *Repo) getRepoFeed(ctx context.Context, repo *models.Repo, ownerSlashRepo string) (*feeds.Feed, error) { 22 const feedLimitPerType = 100 23 24 + pulls, err := db.GetPullsWithLimit(rp.db, feedLimitPerType, orm.FilterEq("repo_at", repo.RepoAt())) 25 if err != nil { 26 return nil, err 27 } ··· 29 issues, err := db.GetIssuesPaginated( 30 rp.db, 31 pagination.Page{Limit: feedLimitPerType}, 32 + orm.FilterEq("repo_at", repo.RepoAt()), 33 ) 34 if err != nil { 35 return nil, err 36 } 37 38 feed := &feeds.Feed{ 39 + Title: fmt.Sprintf("activity feed for @%s", ownerSlashRepo), 40 + Link: &feeds.Link{Href: fmt.Sprintf("%s/%s", rp.config.Core.AppviewHost, ownerSlashRepo), Type: "text/html", Rel: "alternate"}, 41 Items: make([]*feeds.Item, 0), 42 Updated: time.UnixMilli(0), 43 } 44 45 for _, pull := range pulls { 46 + items, err := rp.createPullItems(ctx, pull, repo, ownerSlashRepo) 47 if err != nil { 48 return nil, err 49 } ··· 51 } 52 53 for _, issue := range issues { 54 + item, err := rp.createIssueItem(ctx, issue, repo, ownerSlashRepo) 55 if err != nil { 56 return nil, err 57 } ··· 72 return feed, nil 73 } 74 75 + func (rp *Repo) createPullItems(ctx context.Context, pull *models.Pull, repo *models.Repo, ownerSlashRepo string) ([]*feeds.Item, error) { 76 owner, err := rp.idResolver.ResolveIdent(ctx, pull.OwnerDid) 77 if err != nil { 78 return nil, err ··· 81 var items []*feeds.Item 82 83 state := rp.getPullState(pull) 84 + description := rp.buildPullDescription(owner.Handle, state, pull, ownerSlashRepo) 85 86 mainItem := &feeds.Item{ 87 Title: fmt.Sprintf("[PR #%d] %s", pull.PullId, pull.Title), 88 Description: description, 89 + Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/pulls/%d", rp.config.Core.AppviewHost, ownerSlashRepo, pull.PullId)}, 90 Created: pull.Created, 91 Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)}, 92 } ··· 99 100 roundItem := &feeds.Item{ 101 Title: fmt.Sprintf("[PR #%d] %s (round #%d)", pull.PullId, pull.Title, round.RoundNumber), 102 + Description: fmt.Sprintf("@%s submitted changes (at round #%d) on PR #%d in @%s", owner.Handle, round.RoundNumber, pull.PullId, ownerSlashRepo), 103 + Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/pulls/%d/round/%d/", rp.config.Core.AppviewHost, ownerSlashRepo, pull.PullId, round.RoundNumber)}, 104 Created: round.Created, 105 Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)}, 106 } ··· 110 return items, nil 111 } 112 113 + func (rp *Repo) createIssueItem(ctx context.Context, issue models.Issue, repo *models.Repo, ownerSlashRepo string) (*feeds.Item, error) { 114 owner, err := rp.idResolver.ResolveIdent(ctx, issue.Did) 115 if err != nil { 116 return nil, err ··· 123 124 return &feeds.Item{ 125 Title: fmt.Sprintf("[Issue #%d] %s", issue.IssueId, issue.Title), 126 + Description: fmt.Sprintf("@%s %s issue #%d in @%s", owner.Handle, state, issue.IssueId, ownerSlashRepo), 127 + Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/issues/%d", rp.config.Core.AppviewHost, ownerSlashRepo, issue.IssueId)}, 128 Created: issue.Created, 129 Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)}, 130 }, nil ··· 153 log.Println("failed to fully resolve repo:", err) 154 return 155 } 156 + repoOwnerId, ok := r.Context().Value("resolvedId").(identity.Identity) 157 + if !ok || repoOwnerId.Handle.IsInvalidHandle() { 158 + log.Println("failed to get resolved repo owner id") 159 + return 160 + } 161 + ownerSlashRepo := repoOwnerId.Handle.String() + "/" + f.Name 162 163 + feed, err := rp.getRepoFeed(r.Context(), f, ownerSlashRepo) 164 if err != nil { 165 log.Println("failed to get repo feed:", err) 166 rp.pages.Error500(w)
+18 -19
appview/repo/index.go
··· 22 "tangled.org/core/appview/db" 23 "tangled.org/core/appview/models" 24 "tangled.org/core/appview/pages" 25 - "tangled.org/core/appview/reporesolver" 26 "tangled.org/core/appview/xrpcclient" 27 "tangled.org/core/types" 28 29 "github.com/go-chi/chi/v5" ··· 52 } 53 54 user := rp.oauth.GetUser(r) 55 - repoInfo := f.RepoInfo(user) 56 57 // Build index response from multiple XRPC calls 58 result, err := rp.buildIndexResponse(r.Context(), xrpcc, f, ref) ··· 62 rp.pages.RepoIndexPage(w, pages.RepoIndexParams{ 63 LoggedInUser: user, 64 NeedsKnotUpgrade: true, 65 - RepoInfo: repoInfo, 66 }) 67 return 68 } ··· 124 l.Error("failed to get email to did map", "err", err) 125 } 126 127 - vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, commitsTrunc) 128 if err != nil { 129 l.Error("failed to GetVerifiedObjectCommits", "err", err) 130 } ··· 140 for _, c := range commitsTrunc { 141 shas = append(shas, c.Hash.String()) 142 } 143 - pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas) 144 if err != nil { 145 l.Error("failed to fetch pipeline statuses", "err", err) 146 // non-fatal ··· 148 149 rp.pages.RepoIndexPage(w, pages.RepoIndexParams{ 150 LoggedInUser: user, 151 - RepoInfo: repoInfo, 152 TagMap: tagMap, 153 RepoIndexResponse: *result, 154 CommitsTrunc: commitsTrunc, ··· 165 func (rp *Repo) getLanguageInfo( 166 ctx context.Context, 167 l *slog.Logger, 168 - f *reporesolver.ResolvedRepo, 169 xrpcc *indigoxrpc.Client, 170 currentRef string, 171 isDefaultRef bool, ··· 173 // first attempt to fetch from db 174 langs, err := db.GetRepoLanguages( 175 rp.db, 176 - db.FilterEq("repo_at", f.RepoAt()), 177 - db.FilterEq("ref", currentRef), 178 ) 179 180 if err != nil || langs == nil { 181 // 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) 184 if err != nil { 185 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 186 l.Error("failed to call XRPC repo.languages", "err", xrpcerr) ··· 195 196 for _, lang := range ls.Languages { 197 langs = append(langs, models.RepoLanguage{ 198 - RepoAt: f.RepoAt(), 199 Ref: currentRef, 200 IsDefaultRef: isDefaultRef, 201 Language: lang.Name, ··· 210 defer tx.Rollback() 211 212 // update appview's cache 213 - err = db.UpdateRepoLanguages(tx, f.RepoAt(), currentRef, langs) 214 if err != nil { 215 // non-fatal 216 l.Error("failed to cache lang results", "err", err) ··· 255 } 256 257 // 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) 260 261 // first get branches to determine the ref if not specified 262 - branchesBytes, err := tangled.RepoBranches(ctx, xrpcc, "", 0, repo) 263 if err != nil { 264 return nil, fmt.Errorf("failed to call repoBranches: %w", err) 265 } ··· 303 wg.Add(1) 304 go func() { 305 defer wg.Done() 306 - tagsBytes, err := tangled.RepoTags(ctx, xrpcc, "", 0, repo) 307 if err != nil { 308 errs = errors.Join(errs, fmt.Errorf("failed to call repoTags: %w", err)) 309 return ··· 318 wg.Add(1) 319 go func() { 320 defer wg.Done() 321 - resp, err := tangled.RepoTree(ctx, xrpcc, "", ref, repo) 322 if err != nil { 323 errs = errors.Join(errs, fmt.Errorf("failed to call repoTree: %w", err)) 324 return ··· 330 wg.Add(1) 331 go func() { 332 defer wg.Done() 333 - logBytes, err := tangled.RepoLog(ctx, xrpcc, "", 50, "", ref, repo) 334 if err != nil { 335 errs = errors.Join(errs, fmt.Errorf("failed to call repoLog: %w", err)) 336 return
··· 22 "tangled.org/core/appview/db" 23 "tangled.org/core/appview/models" 24 "tangled.org/core/appview/pages" 25 "tangled.org/core/appview/xrpcclient" 26 + "tangled.org/core/orm" 27 "tangled.org/core/types" 28 29 "github.com/go-chi/chi/v5" ··· 52 } 53 54 user := rp.oauth.GetUser(r) 55 56 // Build index response from multiple XRPC calls 57 result, err := rp.buildIndexResponse(r.Context(), xrpcc, f, ref) ··· 61 rp.pages.RepoIndexPage(w, pages.RepoIndexParams{ 62 LoggedInUser: user, 63 NeedsKnotUpgrade: true, 64 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 65 }) 66 return 67 } ··· 123 l.Error("failed to get email to did map", "err", err) 124 } 125 126 + vc, err := commitverify.GetVerifiedCommits(rp.db, emailToDidMap, commitsTrunc) 127 if err != nil { 128 l.Error("failed to GetVerifiedObjectCommits", "err", err) 129 } ··· 139 for _, c := range commitsTrunc { 140 shas = append(shas, c.Hash.String()) 141 } 142 + pipelines, err := getPipelineStatuses(rp.db, f, shas) 143 if err != nil { 144 l.Error("failed to fetch pipeline statuses", "err", err) 145 // non-fatal ··· 147 148 rp.pages.RepoIndexPage(w, pages.RepoIndexParams{ 149 LoggedInUser: user, 150 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 151 TagMap: tagMap, 152 RepoIndexResponse: *result, 153 CommitsTrunc: commitsTrunc, ··· 164 func (rp *Repo) getLanguageInfo( 165 ctx context.Context, 166 l *slog.Logger, 167 + repo *models.Repo, 168 xrpcc *indigoxrpc.Client, 169 currentRef string, 170 isDefaultRef bool, ··· 172 // first attempt to fetch from db 173 langs, err := db.GetRepoLanguages( 174 rp.db, 175 + orm.FilterEq("repo_at", repo.RepoAt()), 176 + orm.FilterEq("ref", currentRef), 177 ) 178 179 if err != nil || langs == nil { 180 // non-fatal, fetch langs from ks via XRPC 181 + didSlashRepo := fmt.Sprintf("%s/%s", repo.Did, repo.Name) 182 + ls, err := tangled.RepoLanguages(ctx, xrpcc, currentRef, didSlashRepo) 183 if err != nil { 184 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 185 l.Error("failed to call XRPC repo.languages", "err", xrpcerr) ··· 194 195 for _, lang := range ls.Languages { 196 langs = append(langs, models.RepoLanguage{ 197 + RepoAt: repo.RepoAt(), 198 Ref: currentRef, 199 IsDefaultRef: isDefaultRef, 200 Language: lang.Name, ··· 209 defer tx.Rollback() 210 211 // update appview's cache 212 + err = db.UpdateRepoLanguages(tx, repo.RepoAt(), currentRef, langs) 213 if err != nil { 214 // non-fatal 215 l.Error("failed to cache lang results", "err", err) ··· 254 } 255 256 // buildIndexResponse creates a RepoIndexResponse by combining multiple xrpc calls in parallel 257 + func (rp *Repo) buildIndexResponse(ctx context.Context, xrpcc *indigoxrpc.Client, repo *models.Repo, ref string) (*types.RepoIndexResponse, error) { 258 + didSlashRepo := fmt.Sprintf("%s/%s", repo.Did, repo.Name) 259 260 // first get branches to determine the ref if not specified 261 + branchesBytes, err := tangled.RepoBranches(ctx, xrpcc, "", 0, didSlashRepo) 262 if err != nil { 263 return nil, fmt.Errorf("failed to call repoBranches: %w", err) 264 } ··· 302 wg.Add(1) 303 go func() { 304 defer wg.Done() 305 + tagsBytes, err := tangled.RepoTags(ctx, xrpcc, "", 0, didSlashRepo) 306 if err != nil { 307 errs = errors.Join(errs, fmt.Errorf("failed to call repoTags: %w", err)) 308 return ··· 317 wg.Add(1) 318 go func() { 319 defer wg.Done() 320 + resp, err := tangled.RepoTree(ctx, xrpcc, "", ref, didSlashRepo) 321 if err != nil { 322 errs = errors.Join(errs, fmt.Errorf("failed to call repoTree: %w", err)) 323 return ··· 329 wg.Add(1) 330 go func() { 331 defer wg.Done() 332 + logBytes, err := tangled.RepoLog(ctx, xrpcc, "", 50, "", ref, didSlashRepo) 333 if err != nil { 334 errs = errors.Join(errs, fmt.Errorf("failed to call repoLog: %w", err)) 335 return
+8 -11
appview/repo/log.go
··· 57 cursor = strconv.Itoa(offset) 58 } 59 60 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), 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) ··· 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 - repoInfo := f.RepoInfo(user) 125 126 var shas []string 127 for _, c := range xrpcResp.Commits { 128 shas = append(shas, c.Hash.String()) 129 } 130 - pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas) 131 if err != nil { 132 l.Error("failed to getPipelineStatuses", "err", err) 133 // non-fatal ··· 136 rp.pages.RepoLog(w, pages.RepoLogParams{ 137 LoggedInUser: user, 138 TagMap: tagMap, 139 - RepoInfo: repoInfo, 140 RepoLogResponse: xrpcResp, 141 EmailToDid: emailToDidMap, 142 VerifiedCommits: vc, ··· 174 Host: host, 175 } 176 177 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 178 xrpcBytes, err := tangled.RepoDiff(r.Context(), xrpcc, ref, repo) 179 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 180 l.Error("failed to call XRPC repo.diff", "err", xrpcerr) ··· 194 l.Error("failed to get email to did mapping", "err", err) 195 } 196 197 - vc, err := commitverify.GetVerifiedCommits(rp.db, emailToDidMap, []types.NiceDiff{*result.Diff}) 198 if err != nil { 199 l.Error("failed to GetVerifiedCommits", "err", err) 200 } 201 202 user := rp.oauth.GetUser(r) 203 - repoInfo := f.RepoInfo(user) 204 - pipelines, err := getPipelineStatuses(rp.db, repoInfo, []string{result.Diff.Commit.This}) 205 if err != nil { 206 l.Error("failed to getPipelineStatuses", "err", err) 207 // non-fatal ··· 213 214 rp.pages.RepoCommit(w, pages.RepoCommitParams{ 215 LoggedInUser: user, 216 - RepoInfo: f.RepoInfo(user), 217 RepoCommitResponse: result, 218 EmailToDid: emailToDidMap, 219 VerifiedCommit: vc,
··· 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) ··· 116 l.Error("failed to fetch email to did mapping", "err", err) 117 } 118 119 + vc, err := commitverify.GetVerifiedCommits(rp.db, emailToDidMap, xrpcResp.Commits) 120 if err != nil { 121 l.Error("failed to GetVerifiedObjectCommits", "err", err) 122 } 123 124 var shas []string 125 for _, c := range xrpcResp.Commits { 126 shas = append(shas, c.Hash.String()) 127 } 128 + pipelines, err := getPipelineStatuses(rp.db, f, shas) 129 if err != nil { 130 l.Error("failed to getPipelineStatuses", "err", err) 131 // non-fatal ··· 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, ··· 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) ··· 192 l.Error("failed to get email to did mapping", "err", err) 193 } 194 195 + vc, err := commitverify.GetVerifiedCommits(rp.db, emailToDidMap, []types.Commit{result.Diff.Commit}) 196 if err != nil { 197 l.Error("failed to GetVerifiedCommits", "err", err) 198 } 199 200 user := rp.oauth.GetUser(r) 201 + pipelines, err := getPipelineStatuses(rp.db, f, []string{result.Diff.Commit.This}) 202 if err != nil { 203 l.Error("failed to getPipelineStatuses", "err", err) 204 // non-fatal ··· 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,
+5 -4
appview/repo/opengraph.go
··· 16 "tangled.org/core/appview/db" 17 "tangled.org/core/appview/models" 18 "tangled.org/core/appview/ogcard" 19 "tangled.org/core/types" 20 ) 21 ··· 236 dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2) 237 dollyY := statsY + iconBaselineOffset - dollySize/2 + 25 238 dollyColor := color.RGBA{180, 180, 180, 255} // light gray 239 - err = dollyArea.DrawDollySilhouette(dollyX, dollyY, dollySize, dollyColor) 240 if err != nil { 241 log.Printf("dolly silhouette not available (this is ok): %v", err) 242 } ··· 338 var languageStats []types.RepoLanguageDetails 339 langs, err := db.GetRepoLanguages( 340 rp.db, 341 - db.FilterEq("repo_at", f.RepoAt()), 342 - db.FilterEq("is_default_ref", 1), 343 ) 344 if err != nil { 345 log.Printf("failed to get language stats from db: %v", err) ··· 374 }) 375 } 376 377 - card, err := rp.drawRepoSummaryCard(&f.Repo, languageStats) 378 if err != nil { 379 log.Println("failed to draw repo summary card", err) 380 http.Error(w, "failed to draw repo summary card", http.StatusInternalServerError)
··· 16 "tangled.org/core/appview/db" 17 "tangled.org/core/appview/models" 18 "tangled.org/core/appview/ogcard" 19 + "tangled.org/core/orm" 20 "tangled.org/core/types" 21 ) 22 ··· 237 dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2) 238 dollyY := statsY + iconBaselineOffset - dollySize/2 + 25 239 dollyColor := color.RGBA{180, 180, 180, 255} // light gray 240 + err = dollyArea.DrawDolly(dollyX, dollyY, dollySize, dollyColor) 241 if err != nil { 242 log.Printf("dolly silhouette not available (this is ok): %v", err) 243 } ··· 339 var languageStats []types.RepoLanguageDetails 340 langs, err := db.GetRepoLanguages( 341 rp.db, 342 + orm.FilterEq("repo_at", f.RepoAt()), 343 + orm.FilterEq("is_default_ref", 1), 344 ) 345 if err != nil { 346 log.Printf("failed to get language stats from db: %v", err) ··· 375 }) 376 } 377 378 + card, err := rp.drawRepoSummaryCard(f, languageStats) 379 if err != nil { 380 log.Println("failed to draw repo summary card", err) 381 http.Error(w, "failed to draw repo summary card", http.StatusInternalServerError)
+37 -35
appview/repo/repo.go
··· 24 xrpcclient "tangled.org/core/appview/xrpcclient" 25 "tangled.org/core/eventconsumer" 26 "tangled.org/core/idresolver" 27 "tangled.org/core/rbac" 28 "tangled.org/core/tid" 29 "tangled.org/core/xrpc/serviceauth" ··· 118 } 119 } 120 121 - newRepo := f.Repo 122 newRepo.Spindle = newSpindle 123 record := newRepo.AsRecord() 124 ··· 257 l.Info("wrote label record to PDS") 258 259 // update the repo to subscribe to this label 260 - newRepo := f.Repo 261 newRepo.Labels = append(newRepo.Labels, aturi) 262 repoRecord := newRepo.AsRecord() 263 ··· 345 // get form values 346 labelId := r.FormValue("label-id") 347 348 - label, err := db.GetLabelDefinition(rp.db, db.FilterEq("id", labelId)) 349 if err != nil { 350 fail("Failed to find label definition.", err) 351 return ··· 369 } 370 371 // update repo record to remove the label reference 372 - newRepo := f.Repo 373 var updated []string 374 removedAt := label.AtUri().String() 375 for _, l := range newRepo.Labels { ··· 409 410 err = db.UnsubscribeLabel( 411 tx, 412 - db.FilterEq("repo_at", f.RepoAt()), 413 - db.FilterEq("label_at", removedAt), 414 ) 415 if err != nil { 416 fail("Failed to unsubscribe label.", err) 417 return 418 } 419 420 - err = db.DeleteLabelDefinition(tx, db.FilterEq("id", label.Id)) 421 if err != nil { 422 fail("Failed to delete label definition.", err) 423 return ··· 456 } 457 458 labelAts := r.Form["label"] 459 - _, err = db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", labelAts)) 460 if err != nil { 461 fail("Failed to subscribe to label.", err) 462 return 463 } 464 465 - newRepo := f.Repo 466 newRepo.Labels = append(newRepo.Labels, labelAts...) 467 468 // dedup ··· 477 return 478 } 479 480 - ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey) 481 if err != nil { 482 fail("Failed to update labels, no record found on PDS.", err) 483 return ··· 542 } 543 544 labelAts := r.Form["label"] 545 - _, err = db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", labelAts)) 546 if err != nil { 547 fail("Failed to unsubscribe to label.", err) 548 return 549 } 550 551 // update repo record to remove the label reference 552 - newRepo := f.Repo 553 var updated []string 554 for _, l := range newRepo.Labels { 555 if !slices.Contains(labelAts, l) { ··· 565 return 566 } 567 568 - ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey) 569 if err != nil { 570 fail("Failed to update labels, no record found on PDS.", err) 571 return ··· 582 583 err = db.UnsubscribeLabel( 584 rp.db, 585 - db.FilterEq("repo_at", f.RepoAt()), 586 - db.FilterIn("label_at", labelAts), 587 ) 588 if err != nil { 589 fail("Failed to unsubscribe label.", err) ··· 612 613 labelDefs, err := db.GetLabelDefinitions( 614 rp.db, 615 - db.FilterIn("at_uri", f.Repo.Labels), 616 - db.FilterContains("scope", subject.Collection().String()), 617 ) 618 if err != nil { 619 l.Error("failed to fetch label defs", "err", err) ··· 625 defs[l.AtUri().String()] = &l 626 } 627 628 - states, err := db.GetLabels(rp.db, db.FilterEq("subject", subject)) 629 if err != nil { 630 l.Error("failed to build label state", "err", err) 631 return ··· 635 user := rp.oauth.GetUser(r) 636 rp.pages.LabelPanel(w, pages.LabelPanelParams{ 637 LoggedInUser: user, 638 - RepoInfo: f.RepoInfo(user), 639 Defs: defs, 640 Subject: subject.String(), 641 State: state, ··· 660 661 labelDefs, err := db.GetLabelDefinitions( 662 rp.db, 663 - db.FilterIn("at_uri", f.Repo.Labels), 664 - db.FilterContains("scope", subject.Collection().String()), 665 ) 666 if err != nil { 667 l.Error("failed to fetch labels", "err", err) ··· 673 defs[l.AtUri().String()] = &l 674 } 675 676 - states, err := db.GetLabels(rp.db, db.FilterEq("subject", subject)) 677 if err != nil { 678 l.Error("failed to build label state", "err", err) 679 return ··· 683 user := rp.oauth.GetUser(r) 684 rp.pages.EditLabelPanel(w, pages.EditLabelPanelParams{ 685 LoggedInUser: user, 686 - RepoInfo: f.RepoInfo(user), 687 Defs: defs, 688 Subject: subject.String(), 689 State: state, ··· 864 r.Context(), 865 client, 866 &tangled.RepoDelete_Input{ 867 - Did: f.OwnerDid(), 868 Name: f.Name, 869 Rkey: f.Rkey, 870 }, ··· 902 l.Info("removed collaborators") 903 904 // remove repo RBAC 905 - err = rp.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo()) 906 if err != nil { 907 rp.pages.Notice(w, noticeId, "Failed to update RBAC rules") 908 return 909 } 910 911 // remove repo from db 912 - err = db.RemoveRepo(tx, f.OwnerDid(), f.Name) 913 if err != nil { 914 rp.pages.Notice(w, noticeId, "Failed to update appview") 915 return ··· 930 return 931 } 932 933 - rp.pages.HxRedirect(w, fmt.Sprintf("/%s", f.OwnerDid())) 934 } 935 936 func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) { ··· 959 return 960 } 961 962 - repoInfo := f.RepoInfo(user) 963 - if repoInfo.Source == nil { 964 rp.pages.Notice(w, "repo", "This repository is not a fork.") 965 return 966 } ··· 971 &tangled.RepoForkSync_Input{ 972 Did: user.Did, 973 Name: f.Name, 974 - Source: repoInfo.Source.RepoAt().String(), 975 Branch: ref, 976 }, 977 ) ··· 1007 rp.pages.ForkRepo(w, pages.ForkRepoParams{ 1008 LoggedInUser: user, 1009 Knots: knots, 1010 - RepoInfo: f.RepoInfo(user), 1011 }) 1012 1013 case http.MethodPost: ··· 1037 // in the user's account. 1038 existingRepo, err := db.GetRepo( 1039 rp.db, 1040 - db.FilterEq("did", user.Did), 1041 - db.FilterEq("name", forkName), 1042 ) 1043 if err != nil { 1044 if !errors.Is(err, sql.ErrNoRows) { ··· 1058 uri = "http" 1059 } 1060 1061 - forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.Repo.Name) 1062 l = l.With("cloneUrl", forkSourceUrl) 1063 1064 sourceAt := f.RepoAt().String() ··· 1071 Knot: targetKnot, 1072 Rkey: rkey, 1073 Source: sourceAt, 1074 - Description: f.Repo.Description, 1075 Created: time.Now(), 1076 Labels: rp.config.Label.DefaultLabelDefs, 1077 } ··· 1130 } 1131 defer rollback() 1132 1133 client, err := rp.oauth.ServiceClient( 1134 r, 1135 oauth.WithService(targetKnot), 1136 oauth.WithLxm(tangled.RepoCreateNSID), 1137 oauth.WithDev(rp.config.Core.Dev), 1138 ) 1139 if err != nil { 1140 l.Error("could not create service client", "err", err)
··· 24 xrpcclient "tangled.org/core/appview/xrpcclient" 25 "tangled.org/core/eventconsumer" 26 "tangled.org/core/idresolver" 27 + "tangled.org/core/orm" 28 "tangled.org/core/rbac" 29 "tangled.org/core/tid" 30 "tangled.org/core/xrpc/serviceauth" ··· 119 } 120 } 121 122 + newRepo := *f 123 newRepo.Spindle = newSpindle 124 record := newRepo.AsRecord() 125 ··· 258 l.Info("wrote label record to PDS") 259 260 // update the repo to subscribe to this label 261 + newRepo := *f 262 newRepo.Labels = append(newRepo.Labels, aturi) 263 repoRecord := newRepo.AsRecord() 264 ··· 346 // get form values 347 labelId := r.FormValue("label-id") 348 349 + label, err := db.GetLabelDefinition(rp.db, orm.FilterEq("id", labelId)) 350 if err != nil { 351 fail("Failed to find label definition.", err) 352 return ··· 370 } 371 372 // update repo record to remove the label reference 373 + newRepo := *f 374 var updated []string 375 removedAt := label.AtUri().String() 376 for _, l := range newRepo.Labels { ··· 410 411 err = db.UnsubscribeLabel( 412 tx, 413 + orm.FilterEq("repo_at", f.RepoAt()), 414 + orm.FilterEq("label_at", removedAt), 415 ) 416 if err != nil { 417 fail("Failed to unsubscribe label.", err) 418 return 419 } 420 421 + err = db.DeleteLabelDefinition(tx, orm.FilterEq("id", label.Id)) 422 if err != nil { 423 fail("Failed to delete label definition.", err) 424 return ··· 457 } 458 459 labelAts := r.Form["label"] 460 + _, err = db.GetLabelDefinitions(rp.db, orm.FilterIn("at_uri", labelAts)) 461 if err != nil { 462 fail("Failed to subscribe to label.", err) 463 return 464 } 465 466 + newRepo := *f 467 newRepo.Labels = append(newRepo.Labels, labelAts...) 468 469 // dedup ··· 478 return 479 } 480 481 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Did, f.Rkey) 482 if err != nil { 483 fail("Failed to update labels, no record found on PDS.", err) 484 return ··· 543 } 544 545 labelAts := r.Form["label"] 546 + _, err = db.GetLabelDefinitions(rp.db, orm.FilterIn("at_uri", labelAts)) 547 if err != nil { 548 fail("Failed to unsubscribe to label.", err) 549 return 550 } 551 552 // update repo record to remove the label reference 553 + newRepo := *f 554 var updated []string 555 for _, l := range newRepo.Labels { 556 if !slices.Contains(labelAts, l) { ··· 566 return 567 } 568 569 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Did, f.Rkey) 570 if err != nil { 571 fail("Failed to update labels, no record found on PDS.", err) 572 return ··· 583 584 err = db.UnsubscribeLabel( 585 rp.db, 586 + orm.FilterEq("repo_at", f.RepoAt()), 587 + orm.FilterIn("label_at", labelAts), 588 ) 589 if err != nil { 590 fail("Failed to unsubscribe label.", err) ··· 613 614 labelDefs, err := db.GetLabelDefinitions( 615 rp.db, 616 + orm.FilterIn("at_uri", f.Labels), 617 + orm.FilterContains("scope", subject.Collection().String()), 618 ) 619 if err != nil { 620 l.Error("failed to fetch label defs", "err", err) ··· 626 defs[l.AtUri().String()] = &l 627 } 628 629 + states, err := db.GetLabels(rp.db, orm.FilterEq("subject", subject)) 630 if err != nil { 631 l.Error("failed to build label state", "err", err) 632 return ··· 636 user := rp.oauth.GetUser(r) 637 rp.pages.LabelPanel(w, pages.LabelPanelParams{ 638 LoggedInUser: user, 639 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 640 Defs: defs, 641 Subject: subject.String(), 642 State: state, ··· 661 662 labelDefs, err := db.GetLabelDefinitions( 663 rp.db, 664 + orm.FilterIn("at_uri", f.Labels), 665 + orm.FilterContains("scope", subject.Collection().String()), 666 ) 667 if err != nil { 668 l.Error("failed to fetch labels", "err", err) ··· 674 defs[l.AtUri().String()] = &l 675 } 676 677 + states, err := db.GetLabels(rp.db, orm.FilterEq("subject", subject)) 678 if err != nil { 679 l.Error("failed to build label state", "err", err) 680 return ··· 684 user := rp.oauth.GetUser(r) 685 rp.pages.EditLabelPanel(w, pages.EditLabelPanelParams{ 686 LoggedInUser: user, 687 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 688 Defs: defs, 689 Subject: subject.String(), 690 State: state, ··· 865 r.Context(), 866 client, 867 &tangled.RepoDelete_Input{ 868 + Did: f.Did, 869 Name: f.Name, 870 Rkey: f.Rkey, 871 }, ··· 903 l.Info("removed collaborators") 904 905 // remove repo RBAC 906 + err = rp.enforcer.RemoveRepo(f.Did, f.Knot, f.DidSlashRepo()) 907 if err != nil { 908 rp.pages.Notice(w, noticeId, "Failed to update RBAC rules") 909 return 910 } 911 912 // remove repo from db 913 + err = db.RemoveRepo(tx, f.Did, f.Name) 914 if err != nil { 915 rp.pages.Notice(w, noticeId, "Failed to update appview") 916 return ··· 931 return 932 } 933 934 + rp.pages.HxRedirect(w, fmt.Sprintf("/%s", f.Did)) 935 } 936 937 func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) { ··· 960 return 961 } 962 963 + if f.Source == "" { 964 rp.pages.Notice(w, "repo", "This repository is not a fork.") 965 return 966 } ··· 971 &tangled.RepoForkSync_Input{ 972 Did: user.Did, 973 Name: f.Name, 974 + Source: f.Source, 975 Branch: ref, 976 }, 977 ) ··· 1007 rp.pages.ForkRepo(w, pages.ForkRepoParams{ 1008 LoggedInUser: user, 1009 Knots: knots, 1010 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 1011 }) 1012 1013 case http.MethodPost: ··· 1037 // in the user's account. 1038 existingRepo, err := db.GetRepo( 1039 rp.db, 1040 + orm.FilterEq("did", user.Did), 1041 + orm.FilterEq("name", forkName), 1042 ) 1043 if err != nil { 1044 if !errors.Is(err, sql.ErrNoRows) { ··· 1058 uri = "http" 1059 } 1060 1061 + forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.Did, f.Name) 1062 l = l.With("cloneUrl", forkSourceUrl) 1063 1064 sourceAt := f.RepoAt().String() ··· 1071 Knot: targetKnot, 1072 Rkey: rkey, 1073 Source: sourceAt, 1074 + Description: f.Description, 1075 Created: time.Now(), 1076 Labels: rp.config.Label.DefaultLabelDefs, 1077 } ··· 1130 } 1131 defer rollback() 1132 1133 + // TODO: this could coordinate better with the knot to recieve a clone status 1134 client, err := rp.oauth.ServiceClient( 1135 r, 1136 oauth.WithService(targetKnot), 1137 oauth.WithLxm(tangled.RepoCreateNSID), 1138 oauth.WithDev(rp.config.Core.Dev), 1139 + oauth.WithTimeout(time.Second*20), // big repos take time to clone 1140 ) 1141 if err != nil { 1142 l.Error("could not create service client", "err", err)
+17 -19
appview/repo/repo_util.go
··· 1 package repo 2 3 import ( 4 "slices" 5 "sort" 6 "strings" 7 8 "tangled.org/core/appview/db" 9 "tangled.org/core/appview/models" 10 - "tangled.org/core/appview/pages/repoinfo" 11 "tangled.org/core/types" 12 - 13 - "github.com/go-git/go-git/v5/plumbing/object" 14 ) 15 16 func sortFiles(files []types.NiceTree) { ··· 43 }) 44 } 45 46 - func uniqueEmails(commits []*object.Commit) []string { 47 emails := make(map[string]struct{}) 48 for _, commit := range commits { 49 - if commit.Author.Email != "" { 50 - emails[commit.Author.Email] = struct{}{} 51 - } 52 - if commit.Committer.Email != "" { 53 - emails[commit.Committer.Email] = struct{}{} 54 } 55 } 56 - var uniqueEmails []string 57 - for email := range emails { 58 - uniqueEmails = append(uniqueEmails, email) 59 - } 60 - return uniqueEmails 61 } 62 63 func balanceIndexItems(commitCount, branchCount, tagCount, fileCount int) (commitsTrunc int, branchesTrunc int, tagsTrunc int) { ··· 93 // golang is so blessed that it requires 35 lines of imperative code for this 94 func getPipelineStatuses( 95 d *db.DB, 96 - repoInfo repoinfo.RepoInfo, 97 shas []string, 98 ) (map[string]models.Pipeline, error) { 99 m := make(map[string]models.Pipeline) ··· 105 ps, err := db.GetPipelineStatuses( 106 d, 107 len(shas), 108 - db.FilterEq("repo_owner", repoInfo.OwnerDid), 109 - db.FilterEq("repo_name", repoInfo.Name), 110 - db.FilterEq("knot", repoInfo.Knot), 111 - db.FilterIn("sha", shas), 112 ) 113 if err != nil { 114 return nil, err
··· 1 package repo 2 3 import ( 4 + "maps" 5 "slices" 6 "sort" 7 "strings" 8 9 "tangled.org/core/appview/db" 10 "tangled.org/core/appview/models" 11 + "tangled.org/core/orm" 12 "tangled.org/core/types" 13 ) 14 15 func sortFiles(files []types.NiceTree) { ··· 42 }) 43 } 44 45 + func uniqueEmails(commits []types.Commit) []string { 46 emails := make(map[string]struct{}) 47 for _, commit := range commits { 48 + emails[commit.Author.Email] = struct{}{} 49 + emails[commit.Committer.Email] = struct{}{} 50 + for _, c := range commit.CoAuthors() { 51 + emails[c.Email] = struct{}{} 52 } 53 } 54 + 55 + // delete empty emails if any, from the set 56 + delete(emails, "") 57 + 58 + return slices.Collect(maps.Keys(emails)) 59 } 60 61 func balanceIndexItems(commitCount, branchCount, tagCount, fileCount int) (commitsTrunc int, branchesTrunc int, tagsTrunc int) { ··· 91 // golang is so blessed that it requires 35 lines of imperative code for this 92 func getPipelineStatuses( 93 d *db.DB, 94 + repo *models.Repo, 95 shas []string, 96 ) (map[string]models.Pipeline, error) { 97 m := make(map[string]models.Pipeline) ··· 103 ps, err := db.GetPipelineStatuses( 104 d, 105 len(shas), 106 + orm.FilterEq("repo_owner", repo.Did), 107 + orm.FilterEq("repo_name", repo.Name), 108 + orm.FilterEq("knot", repo.Knot), 109 + orm.FilterIn("sha", shas), 110 ) 111 if err != nil { 112 return nil, err
+40 -11
appview/repo/settings.go
··· 10 11 "tangled.org/core/api/tangled" 12 "tangled.org/core/appview/db" 13 "tangled.org/core/appview/oauth" 14 "tangled.org/core/appview/pages" 15 xrpcclient "tangled.org/core/appview/xrpcclient" 16 "tangled.org/core/types" 17 18 comatproto "github.com/bluesky-social/indigo/api/atproto" ··· 194 Host: host, 195 } 196 197 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 198 xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 199 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 200 l.Error("failed to call XRPC repo.branches", "err", xrpcerr) ··· 209 return 210 } 211 212 - defaultLabels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", rp.config.Label.DefaultLabelDefs)) 213 if err != nil { 214 l.Error("failed to fetch labels", "err", err) 215 rp.pages.Error503(w) 216 return 217 } 218 219 - labels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Repo.Labels)) 220 if err != nil { 221 l.Error("failed to fetch labels", "err", err) 222 rp.pages.Error503(w) ··· 237 labels = labels[:n] 238 239 subscribedLabels := make(map[string]struct{}) 240 - for _, l := range f.Repo.Labels { 241 subscribedLabels[l] = struct{}{} 242 } 243 ··· 254 255 rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{ 256 LoggedInUser: user, 257 - RepoInfo: f.RepoInfo(user), 258 Branches: result.Branches, 259 Labels: labels, 260 DefaultLabels: defaultLabels, ··· 271 f, err := rp.repoResolver.Resolve(r) 272 user := rp.oauth.GetUser(r) 273 274 - repoCollaborators, err := f.Collaborators(r.Context()) 275 if err != nil { 276 l.Error("failed to get collaborators", "err", err) 277 } 278 279 rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{ 280 LoggedInUser: user, 281 - RepoInfo: f.RepoInfo(user), 282 Tabs: settingsTabs, 283 Tab: "access", 284 - Collaborators: repoCollaborators, 285 }) 286 } 287 ··· 292 user := rp.oauth.GetUser(r) 293 294 // all spindles that the repo owner is a member of 295 - spindles, err := rp.enforcer.GetSpindlesForUser(f.OwnerDid()) 296 if err != nil { 297 l.Error("failed to fetch spindles", "err", err) 298 return ··· 339 340 rp.pages.RepoPipelineSettings(w, pages.RepoPipelineSettingsParams{ 341 LoggedInUser: user, 342 - RepoInfo: f.RepoInfo(user), 343 Tabs: settingsTabs, 344 Tab: "pipelines", 345 Spindles: spindles, ··· 388 } 389 l.Debug("got", "topicsStr", topicStr, "topics", topics) 390 391 - newRepo := f.Repo 392 newRepo.Description = description 393 newRepo.Website = website 394 newRepo.Topics = topics
··· 10 11 "tangled.org/core/api/tangled" 12 "tangled.org/core/appview/db" 13 + "tangled.org/core/appview/models" 14 "tangled.org/core/appview/oauth" 15 "tangled.org/core/appview/pages" 16 xrpcclient "tangled.org/core/appview/xrpcclient" 17 + "tangled.org/core/orm" 18 "tangled.org/core/types" 19 20 comatproto "github.com/bluesky-social/indigo/api/atproto" ··· 196 Host: host, 197 } 198 199 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 200 xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 201 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 202 l.Error("failed to call XRPC repo.branches", "err", xrpcerr) ··· 211 return 212 } 213 214 + defaultLabels, err := db.GetLabelDefinitions(rp.db, orm.FilterIn("at_uri", rp.config.Label.DefaultLabelDefs)) 215 if err != nil { 216 l.Error("failed to fetch labels", "err", err) 217 rp.pages.Error503(w) 218 return 219 } 220 221 + labels, err := db.GetLabelDefinitions(rp.db, orm.FilterIn("at_uri", f.Labels)) 222 if err != nil { 223 l.Error("failed to fetch labels", "err", err) 224 rp.pages.Error503(w) ··· 239 labels = labels[:n] 240 241 subscribedLabels := make(map[string]struct{}) 242 + for _, l := range f.Labels { 243 subscribedLabels[l] = struct{}{} 244 } 245 ··· 256 257 rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{ 258 LoggedInUser: user, 259 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 260 Branches: result.Branches, 261 Labels: labels, 262 DefaultLabels: defaultLabels, ··· 273 f, err := rp.repoResolver.Resolve(r) 274 user := rp.oauth.GetUser(r) 275 276 + collaborators, err := func(repo *models.Repo) ([]pages.Collaborator, error) { 277 + repoCollaborators, err := rp.enforcer.E.GetImplicitUsersForResourceByDomain(repo.DidSlashRepo(), repo.Knot) 278 + if err != nil { 279 + return nil, err 280 + } 281 + var collaborators []pages.Collaborator 282 + for _, item := range repoCollaborators { 283 + // currently only two roles: owner and member 284 + var role string 285 + switch item[3] { 286 + case "repo:owner": 287 + role = "owner" 288 + case "repo:collaborator": 289 + role = "collaborator" 290 + default: 291 + continue 292 + } 293 + 294 + did := item[0] 295 + 296 + c := pages.Collaborator{ 297 + Did: did, 298 + Role: role, 299 + } 300 + collaborators = append(collaborators, c) 301 + } 302 + return collaborators, nil 303 + }(f) 304 if err != nil { 305 l.Error("failed to get collaborators", "err", err) 306 } 307 308 rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{ 309 LoggedInUser: user, 310 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 311 Tabs: settingsTabs, 312 Tab: "access", 313 + Collaborators: collaborators, 314 }) 315 } 316 ··· 321 user := rp.oauth.GetUser(r) 322 323 // all spindles that the repo owner is a member of 324 + spindles, err := rp.enforcer.GetSpindlesForUser(f.Did) 325 if err != nil { 326 l.Error("failed to fetch spindles", "err", err) 327 return ··· 368 369 rp.pages.RepoPipelineSettings(w, pages.RepoPipelineSettingsParams{ 370 LoggedInUser: user, 371 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 372 Tabs: settingsTabs, 373 Tab: "pipelines", 374 Spindles: spindles, ··· 417 } 418 l.Debug("got", "topicsStr", topicStr, "topics", topics) 419 420 + newRepo := *f 421 newRepo.Description = description 422 newRepo.Website = website 423 newRepo.Topics = topics
+4 -3
appview/repo/tags.go
··· 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" ··· 31 xrpcc := &indigoxrpc.Client{ 32 Host: host, 33 } 34 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), 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) ··· 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 ··· 71 user := rp.oauth.GetUser(r) 72 rp.pages.RepoTags(w, pages.RepoTagsParams{ 73 LoggedInUser: user, 74 - RepoInfo: f.RepoInfo(user), 75 RepoTagsResponse: result, 76 ArtifactMap: artifactMap, 77 DanglingArtifacts: danglingArtifacts,
··· 10 "tangled.org/core/appview/models" 11 "tangled.org/core/appview/pages" 12 xrpcclient "tangled.org/core/appview/xrpcclient" 13 + "tangled.org/core/orm" 14 "tangled.org/core/types" 15 16 indigoxrpc "github.com/bluesky-social/indigo/xrpc" ··· 32 xrpcc := &indigoxrpc.Client{ 33 Host: host, 34 } 35 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 36 xrpcBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 37 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 38 l.Error("failed to call XRPC repo.tags", "err", xrpcerr) ··· 45 rp.pages.Error503(w) 46 return 47 } 48 + artifacts, err := db.GetArtifact(rp.db, orm.FilterEq("repo_at", f.RepoAt())) 49 if err != nil { 50 l.Error("failed grab artifacts", "err", err) 51 return ··· 72 user := rp.oauth.GetUser(r) 73 rp.pages.RepoTags(w, pages.RepoTagsParams{ 74 LoggedInUser: user, 75 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 76 RepoTagsResponse: result, 77 ArtifactMap: artifactMap, 78 DanglingArtifacts: danglingArtifacts,
+6 -4
appview/repo/tree.go
··· 9 10 "tangled.org/core/api/tangled" 11 "tangled.org/core/appview/pages" 12 xrpcclient "tangled.org/core/appview/xrpcclient" 13 "tangled.org/core/types" 14 ··· 39 xrpcc := &indigoxrpc.Client{ 40 Host: host, 41 } 42 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 43 xrpcResp, err := tangled.RepoTree(r.Context(), xrpcc, treePath, ref, repo) 44 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 45 l.Error("failed to call XRPC repo.tree", "err", xrpcerr) ··· 79 result.ReadmeFileName = xrpcResp.Readme.Filename 80 result.Readme = xrpcResp.Readme.Contents 81 } 82 // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated, 83 // so we can safely redirect to the "parent" (which is the same file). 84 if len(result.Files) == 0 && result.Parent == treePath { 85 - redirectTo := fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), url.PathEscape(ref), result.Parent) 86 http.Redirect(w, r, redirectTo, http.StatusFound) 87 return 88 } 89 user := rp.oauth.GetUser(r) 90 var breadcrumbs [][]string 91 - breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))}) 92 if treePath != "" { 93 for idx, elem := range strings.Split(treePath, "/") { 94 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))}) ··· 100 LoggedInUser: user, 101 BreadCrumbs: breadcrumbs, 102 TreePath: treePath, 103 - RepoInfo: f.RepoInfo(user), 104 RepoTreeResponse: result, 105 }) 106 }
··· 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 ··· 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) ··· 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))}) ··· 102 LoggedInUser: user, 103 BreadCrumbs: breadcrumbs, 104 TreePath: treePath, 105 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 106 RepoTreeResponse: result, 107 }) 108 }
+98 -161
appview/reporesolver/resolver.go
··· 1 package reporesolver 2 3 import ( 4 - "context" 5 - "database/sql" 6 - "errors" 7 "fmt" 8 "log" 9 "net/http" ··· 12 "strings" 13 14 "github.com/bluesky-social/indigo/atproto/identity" 15 - securejoin "github.com/cyphar/filepath-securejoin" 16 "github.com/go-chi/chi/v5" 17 "tangled.org/core/appview/config" 18 "tangled.org/core/appview/db" 19 "tangled.org/core/appview/models" 20 "tangled.org/core/appview/oauth" 21 - "tangled.org/core/appview/pages" 22 "tangled.org/core/appview/pages/repoinfo" 23 - "tangled.org/core/idresolver" 24 "tangled.org/core/rbac" 25 ) 26 27 - type ResolvedRepo struct { 28 - models.Repo 29 - OwnerId identity.Identity 30 - CurrentDir string 31 - Ref string 32 - 33 - rr *RepoResolver 34 } 35 36 - type RepoResolver struct { 37 - config *config.Config 38 - enforcer *rbac.Enforcer 39 - idResolver *idresolver.Resolver 40 - execer db.Execer 41 } 42 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} 45 } 46 47 - func (rr *RepoResolver) Resolve(r *http.Request) (*ResolvedRepo, error) { 48 repo, ok := r.Context().Value("repo").(*models.Repo) 49 if !ok { 50 log.Println("malformed middleware: `repo` not exist in context") 51 return nil, fmt.Errorf("malformed middleware") 52 } 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 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() 78 } 79 80 - func (f *ResolvedRepo) OwnerSlashRepo() string { 81 - handle := f.OwnerId.Handle 82 - 83 - var p string 84 - if handle != "" && !handle.IsInvalidHandle() { 85 - p, _ = securejoin.SecureJoin(fmt.Sprintf("@%s", handle), f.Name) 86 - } else { 87 - p, _ = securejoin.SecureJoin(f.OwnerDid(), f.Name) 88 } 89 90 - return p 91 - } 92 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 97 } 98 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 110 } 111 - 112 - did := item[0] 113 - 114 - c := pages.Collaborator{ 115 - Did: did, 116 - Handle: "", 117 - Role: role, 118 } 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() 132 } 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) 164 - } 165 - 166 var sourceRepo *models.Repo 167 - if source != "" { 168 - sourceRepo, err = db.GetRepoByAtUri(f.rr.execer, source) 169 if err != nil { 170 log.Println("failed to get repo by at uri", err) 171 } 172 } 173 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 - } 181 182 - knot := f.Knot 183 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 - } 205 206 - if sourceRepo != nil { 207 - repoInfo.Source = sourceRepo 208 - repoInfo.SourceHandle = sourceHandle.Handle.String() 209 } 210 211 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 } 222 223 // extractPathAfterRef gets the actual repository path
··· 1 package reporesolver 2 3 import ( 4 "fmt" 5 "log" 6 "net/http" ··· 9 "strings" 10 11 "github.com/bluesky-social/indigo/atproto/identity" 12 "github.com/go-chi/chi/v5" 13 "tangled.org/core/appview/config" 14 "tangled.org/core/appview/db" 15 "tangled.org/core/appview/models" 16 "tangled.org/core/appview/oauth" 17 "tangled.org/core/appview/pages/repoinfo" 18 "tangled.org/core/rbac" 19 ) 20 21 + type RepoResolver struct { 22 + config *config.Config 23 + enforcer *rbac.Enforcer 24 + execer db.Execer 25 } 26 27 + func New(config *config.Config, enforcer *rbac.Enforcer, execer db.Execer) *RepoResolver { 28 + return &RepoResolver{config: config, enforcer: enforcer, execer: execer} 29 } 30 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) 41 } 42 43 + // TODO: move this out of `RepoResolver` struct 44 + func (rr *RepoResolver) Resolve(r *http.Request) (*models.Repo, error) { 45 repo, ok := r.Context().Value("repo").(*models.Repo) 46 if !ok { 47 log.Println("malformed middleware: `repo` not exist in context") 48 return nil, fmt.Errorf("malformed middleware") 49 } 50 51 + return repo, nil 52 } 53 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") 63 } 64 65 + // get dir/ref 66 + currentDir := extractCurrentDir(r.URL.EscapedPath()) 67 + ref := chi.URLParam(r, "ref") 68 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()) 75 } 76 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) 82 } 83 + issueCount, err := db.GetIssueCount(rr.execer, repoAt) 84 + if err != nil { 85 + log.Println("failed to get issue count for ", repoAt) 86 } 87 + pullCount, err := db.GetPullCount(rr.execer, repoAt) 88 + if err != nil { 89 + log.Println("failed to get pull count for ", repoAt) 90 + } 91 + stats = &models.RepoStats{ 92 + StarCount: starCount, 93 + IssueCount: issueCount, 94 + PullCount: pullCount, 95 } 96 } 97 98 var sourceRepo *models.Repo 99 + var err error 100 + if repo.Source != "" { 101 + sourceRepo, err = db.GetRepoByAtUri(rr.execer, repo.Source) 102 if err != nil { 103 log.Println("failed to get repo by at uri", err) 104 } 105 } 106 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, 119 120 + // fork repo upstream 121 + Source: sourceRepo, 122 123 + // page context 124 + CurrentDir: currentDir, 125 + Ref: ref, 126 127 + // info related to the session 128 + IsStarred: isStarred, 129 + Roles: roles, 130 } 131 132 return repoInfo 133 } 134 135 + // extractCurrentDir gets the current directory for markdown link resolution. 136 + // for blob paths, returns the parent dir. for tree paths, returns the path itself. 137 + // 138 + // /@user/repo/blob/main/docs/README.md => docs 139 + // /@user/repo/tree/main/docs => docs 140 + func extractCurrentDir(fullPath string) string { 141 + fullPath = strings.TrimPrefix(fullPath, "/") 142 + 143 + blobPattern := regexp.MustCompile(`blob/[^/]+/(.*)$`) 144 + if matches := blobPattern.FindStringSubmatch(fullPath); len(matches) > 1 { 145 + return path.Dir(matches[1]) 146 } 147 + 148 + treePattern := regexp.MustCompile(`tree/[^/]+/(.*)$`) 149 + if matches := treePattern.FindStringSubmatch(fullPath); len(matches) > 1 { 150 + dir := strings.TrimSuffix(matches[1], "/") 151 + if dir == "" { 152 + return "." 153 + } 154 + return dir 155 + } 156 + 157 + return "." 158 } 159 160 // extractPathAfterRef gets the actual repository path
+22
appview/reporesolver/resolver_test.go
···
··· 1 + package reporesolver 2 + 3 + import "testing" 4 + 5 + func TestExtractCurrentDir(t *testing.T) { 6 + tests := []struct { 7 + path string 8 + want string 9 + }{ 10 + {"/@user/repo/blob/main/docs/README.md", "docs"}, 11 + {"/@user/repo/blob/main/README.md", "."}, 12 + {"/@user/repo/tree/main/docs", "docs"}, 13 + {"/@user/repo/tree/main/docs/", "docs"}, 14 + {"/@user/repo/tree/main", "."}, 15 + } 16 + 17 + for _, tt := range tests { 18 + if got := extractCurrentDir(tt.path); got != tt.want { 19 + t.Errorf("extractCurrentDir(%q) = %q, want %q", tt.path, got, tt.want) 20 + } 21 + } 22 + }
+5 -4
appview/serververify/verify.go
··· 9 "tangled.org/core/api/tangled" 10 "tangled.org/core/appview/db" 11 "tangled.org/core/appview/xrpcclient" 12 "tangled.org/core/rbac" 13 ) 14 ··· 76 // mark this spindle as verified in the db 77 rowId, err := db.VerifySpindle( 78 tx, 79 - db.FilterEq("owner", owner), 80 - db.FilterEq("instance", instance), 81 ) 82 if err != nil { 83 return 0, fmt.Errorf("failed to write to DB: %w", err) ··· 115 // mark as registered 116 err = db.MarkRegistered( 117 tx, 118 - db.FilterEq("did", owner), 119 - db.FilterEq("domain", domain), 120 ) 121 if err != nil { 122 return fmt.Errorf("failed to register domain: %w", err)
··· 9 "tangled.org/core/api/tangled" 10 "tangled.org/core/appview/db" 11 "tangled.org/core/appview/xrpcclient" 12 + "tangled.org/core/orm" 13 "tangled.org/core/rbac" 14 ) 15 ··· 77 // mark this spindle as verified in the db 78 rowId, err := db.VerifySpindle( 79 tx, 80 + orm.FilterEq("owner", owner), 81 + orm.FilterEq("instance", instance), 82 ) 83 if err != nil { 84 return 0, fmt.Errorf("failed to write to DB: %w", err) ··· 116 // mark as registered 117 err = db.MarkRegistered( 118 tx, 119 + orm.FilterEq("did", owner), 120 + orm.FilterEq("domain", domain), 121 ) 122 if err != nil { 123 return fmt.Errorf("failed to register domain: %w", err)
+2
appview/settings/settings.go
··· 43 {"Name": "keys", "Icon": "key"}, 44 {"Name": "emails", "Icon": "mail"}, 45 {"Name": "notifications", "Icon": "bell"}, 46 } 47 ) 48
··· 43 {"Name": "keys", "Icon": "key"}, 44 {"Name": "emails", "Icon": "mail"}, 45 {"Name": "notifications", "Icon": "bell"}, 46 + {"Name": "knots", "Icon": "volleyball"}, 47 + {"Name": "spindles", "Icon": "spool"}, 48 } 49 ) 50
+44 -31
appview/spindles/spindles.go
··· 20 "tangled.org/core/appview/serververify" 21 "tangled.org/core/appview/xrpcclient" 22 "tangled.org/core/idresolver" 23 "tangled.org/core/rbac" 24 "tangled.org/core/tid" 25 ··· 38 Logger *slog.Logger 39 } 40 41 func (s *Spindles) Router() http.Handler { 42 r := chi.NewRouter() 43 ··· 58 user := s.OAuth.GetUser(r) 59 all, err := db.GetSpindles( 60 s.Db, 61 - db.FilterEq("owner", user.Did), 62 ) 63 if err != nil { 64 s.Logger.Error("failed to fetch spindles", "err", err) ··· 69 s.Pages.Spindles(w, pages.SpindlesParams{ 70 LoggedInUser: user, 71 Spindles: all, 72 }) 73 } 74 ··· 86 87 spindles, err := db.GetSpindles( 88 s.Db, 89 - db.FilterEq("instance", instance), 90 - db.FilterEq("owner", user.Did), 91 - db.FilterIsNot("verified", "null"), 92 ) 93 if err != nil || len(spindles) != 1 { 94 l.Error("failed to get spindle", "err", err, "len(spindles)", len(spindles)) ··· 108 repos, err := db.GetRepos( 109 s.Db, 110 0, 111 - db.FilterEq("spindle", instance), 112 ) 113 if err != nil { 114 l.Error("failed to get spindle repos", "err", err) ··· 127 Spindle: spindle, 128 Members: members, 129 Repos: repoMap, 130 }) 131 } 132 ··· 273 274 spindles, err := db.GetSpindles( 275 s.Db, 276 - db.FilterEq("owner", user.Did), 277 - db.FilterEq("instance", instance), 278 ) 279 if err != nil || len(spindles) != 1 { 280 l.Error("failed to retrieve instance", "err", err, "len(spindles)", len(spindles)) ··· 302 // remove spindle members first 303 err = db.RemoveSpindleMember( 304 tx, 305 - db.FilterEq("did", user.Did), 306 - db.FilterEq("instance", instance), 307 ) 308 if err != nil { 309 l.Error("failed to remove spindle members", "err", err) ··· 313 314 err = db.DeleteSpindle( 315 tx, 316 - db.FilterEq("owner", user.Did), 317 - db.FilterEq("instance", instance), 318 ) 319 if err != nil { 320 l.Error("failed to delete spindle", "err", err) ··· 365 366 shouldRedirect := r.Header.Get("shouldRedirect") 367 if shouldRedirect == "true" { 368 - s.Pages.HxRedirect(w, "/spindles") 369 return 370 } 371 ··· 393 394 spindles, err := db.GetSpindles( 395 s.Db, 396 - db.FilterEq("owner", user.Did), 397 - db.FilterEq("instance", instance), 398 ) 399 if err != nil || len(spindles) != 1 { 400 l.Error("failed to retrieve instance", "err", err, "len(spindles)", len(spindles)) ··· 436 437 verifiedSpindle, err := db.GetSpindles( 438 s.Db, 439 - db.FilterEq("id", rowId), 440 ) 441 if err != nil || len(verifiedSpindle) != 1 { 442 l.Error("failed get new spindle", "err", err) ··· 469 470 spindles, err := db.GetSpindles( 471 s.Db, 472 - db.FilterEq("owner", user.Did), 473 - db.FilterEq("instance", instance), 474 ) 475 if err != nil || len(spindles) != 1 { 476 l.Error("failed to retrieve instance", "err", err, "len(spindles)", len(spindles)) ··· 581 } 582 583 // success 584 - s.Pages.HxRedirect(w, fmt.Sprintf("/spindles/%s", instance)) 585 } 586 587 func (s *Spindles) removeMember(w http.ResponseWriter, r *http.Request) { ··· 605 606 spindles, err := db.GetSpindles( 607 s.Db, 608 - db.FilterEq("owner", user.Did), 609 - db.FilterEq("instance", instance), 610 ) 611 if err != nil || len(spindles) != 1 { 612 l.Error("failed to retrieve instance", "err", err, "len(spindles)", len(spindles)) ··· 635 s.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 636 return 637 } 638 - if memberId.Handle.IsInvalidHandle() { 639 - l.Error("failed to resolve member identity to handle") 640 - s.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 641 - return 642 - } 643 644 tx, err := s.Db.Begin() 645 if err != nil { ··· 655 // get the record from the DB first: 656 members, err := db.GetSpindleMembers( 657 s.Db, 658 - db.FilterEq("did", user.Did), 659 - db.FilterEq("instance", instance), 660 - db.FilterEq("subject", memberId.DID), 661 ) 662 if err != nil || len(members) != 1 { 663 l.Error("failed to get member", "err", err) ··· 668 // remove from db 669 if err = db.RemoveSpindleMember( 670 tx, 671 - db.FilterEq("did", user.Did), 672 - db.FilterEq("instance", instance), 673 - db.FilterEq("subject", memberId.DID), 674 ); err != nil { 675 l.Error("failed to remove spindle member", "err", err) 676 fail()
··· 20 "tangled.org/core/appview/serververify" 21 "tangled.org/core/appview/xrpcclient" 22 "tangled.org/core/idresolver" 23 + "tangled.org/core/orm" 24 "tangled.org/core/rbac" 25 "tangled.org/core/tid" 26 ··· 39 Logger *slog.Logger 40 } 41 42 + type tab = map[string]any 43 + 44 + var ( 45 + spindlesTabs []tab = []tab{ 46 + {"Name": "profile", "Icon": "user"}, 47 + {"Name": "keys", "Icon": "key"}, 48 + {"Name": "emails", "Icon": "mail"}, 49 + {"Name": "notifications", "Icon": "bell"}, 50 + {"Name": "knots", "Icon": "volleyball"}, 51 + {"Name": "spindles", "Icon": "spool"}, 52 + } 53 + ) 54 + 55 func (s *Spindles) Router() http.Handler { 56 r := chi.NewRouter() 57 ··· 72 user := s.OAuth.GetUser(r) 73 all, err := db.GetSpindles( 74 s.Db, 75 + orm.FilterEq("owner", user.Did), 76 ) 77 if err != nil { 78 s.Logger.Error("failed to fetch spindles", "err", err) ··· 83 s.Pages.Spindles(w, pages.SpindlesParams{ 84 LoggedInUser: user, 85 Spindles: all, 86 + Tabs: spindlesTabs, 87 + Tab: "spindles", 88 }) 89 } 90 ··· 102 103 spindles, err := db.GetSpindles( 104 s.Db, 105 + orm.FilterEq("instance", instance), 106 + orm.FilterEq("owner", user.Did), 107 + orm.FilterIsNot("verified", "null"), 108 ) 109 if err != nil || len(spindles) != 1 { 110 l.Error("failed to get spindle", "err", err, "len(spindles)", len(spindles)) ··· 124 repos, err := db.GetRepos( 125 s.Db, 126 0, 127 + orm.FilterEq("spindle", instance), 128 ) 129 if err != nil { 130 l.Error("failed to get spindle repos", "err", err) ··· 143 Spindle: spindle, 144 Members: members, 145 Repos: repoMap, 146 + Tabs: spindlesTabs, 147 + Tab: "spindles", 148 }) 149 } 150 ··· 291 292 spindles, err := db.GetSpindles( 293 s.Db, 294 + orm.FilterEq("owner", user.Did), 295 + orm.FilterEq("instance", instance), 296 ) 297 if err != nil || len(spindles) != 1 { 298 l.Error("failed to retrieve instance", "err", err, "len(spindles)", len(spindles)) ··· 320 // remove spindle members first 321 err = db.RemoveSpindleMember( 322 tx, 323 + orm.FilterEq("did", user.Did), 324 + orm.FilterEq("instance", instance), 325 ) 326 if err != nil { 327 l.Error("failed to remove spindle members", "err", err) ··· 331 332 err = db.DeleteSpindle( 333 tx, 334 + orm.FilterEq("owner", user.Did), 335 + orm.FilterEq("instance", instance), 336 ) 337 if err != nil { 338 l.Error("failed to delete spindle", "err", err) ··· 383 384 shouldRedirect := r.Header.Get("shouldRedirect") 385 if shouldRedirect == "true" { 386 + s.Pages.HxRedirect(w, "/settings/spindles") 387 return 388 } 389 ··· 411 412 spindles, err := db.GetSpindles( 413 s.Db, 414 + orm.FilterEq("owner", user.Did), 415 + orm.FilterEq("instance", instance), 416 ) 417 if err != nil || len(spindles) != 1 { 418 l.Error("failed to retrieve instance", "err", err, "len(spindles)", len(spindles)) ··· 454 455 verifiedSpindle, err := db.GetSpindles( 456 s.Db, 457 + orm.FilterEq("id", rowId), 458 ) 459 if err != nil || len(verifiedSpindle) != 1 { 460 l.Error("failed get new spindle", "err", err) ··· 487 488 spindles, err := db.GetSpindles( 489 s.Db, 490 + orm.FilterEq("owner", user.Did), 491 + orm.FilterEq("instance", instance), 492 ) 493 if err != nil || len(spindles) != 1 { 494 l.Error("failed to retrieve instance", "err", err, "len(spindles)", len(spindles)) ··· 599 } 600 601 // success 602 + s.Pages.HxRedirect(w, fmt.Sprintf("/settings/spindles/%s", instance)) 603 } 604 605 func (s *Spindles) removeMember(w http.ResponseWriter, r *http.Request) { ··· 623 624 spindles, err := db.GetSpindles( 625 s.Db, 626 + orm.FilterEq("owner", user.Did), 627 + orm.FilterEq("instance", instance), 628 ) 629 if err != nil || len(spindles) != 1 { 630 l.Error("failed to retrieve instance", "err", err, "len(spindles)", len(spindles)) ··· 653 s.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 654 return 655 } 656 657 tx, err := s.Db.Begin() 658 if err != nil { ··· 668 // get the record from the DB first: 669 members, err := db.GetSpindleMembers( 670 s.Db, 671 + orm.FilterEq("did", user.Did), 672 + orm.FilterEq("instance", instance), 673 + orm.FilterEq("subject", memberId.DID), 674 ) 675 if err != nil || len(members) != 1 { 676 l.Error("failed to get member", "err", err) ··· 681 // remove from db 682 if err = db.RemoveSpindleMember( 683 tx, 684 + orm.FilterEq("did", user.Did), 685 + orm.FilterEq("instance", instance), 686 + orm.FilterEq("subject", memberId.DID), 687 ); err != nil { 688 l.Error("failed to remove spindle member", "err", err) 689 fail()
+6 -5
appview/state/gfi.go
··· 11 "tangled.org/core/appview/pages" 12 "tangled.org/core/appview/pagination" 13 "tangled.org/core/consts" 14 ) 15 16 func (s *State) GoodFirstIssues(w http.ResponseWriter, r *http.Request) { ··· 20 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 } 29 30 - repoLabels, err := db.GetRepoLabels(s.db, db.FilterEq("label_at", goodFirstIssueLabel)) 31 if err != nil { 32 log.Println("failed to get repo labels", err) 33 s.pages.Error503(w) ··· 55 pagination.Page{ 56 Limit: 500, 57 }, 58 - db.FilterIn("repo_at", repoUris), 59 - db.FilterEq("open", 1), 60 ) 61 if err != nil { 62 log.Println("failed to get issues", err) ··· 132 } 133 134 if len(uriList) > 0 { 135 - allLabelDefs, err = db.GetLabelDefinitions(s.db, db.FilterIn("at_uri", uriList)) 136 if err != nil { 137 log.Println("failed to fetch labels", err) 138 }
··· 11 "tangled.org/core/appview/pages" 12 "tangled.org/core/appview/pagination" 13 "tangled.org/core/consts" 14 + "tangled.org/core/orm" 15 ) 16 17 func (s *State) GoodFirstIssues(w http.ResponseWriter, r *http.Request) { ··· 21 22 goodFirstIssueLabel := s.config.Label.GoodFirstIssue 23 24 + gfiLabelDef, err := db.GetLabelDefinition(s.db, orm.FilterEq("at_uri", goodFirstIssueLabel)) 25 if err != nil { 26 log.Println("failed to get gfi label def", err) 27 s.pages.Error500(w) 28 return 29 } 30 31 + repoLabels, err := db.GetRepoLabels(s.db, orm.FilterEq("label_at", goodFirstIssueLabel)) 32 if err != nil { 33 log.Println("failed to get repo labels", err) 34 s.pages.Error503(w) ··· 56 pagination.Page{ 57 Limit: 500, 58 }, 59 + orm.FilterIn("repo_at", repoUris), 60 + orm.FilterEq("open", 1), 61 ) 62 if err != nil { 63 log.Println("failed to get issues", err) ··· 133 } 134 135 if len(uriList) > 0 { 136 + allLabelDefs, err = db.GetLabelDefinitions(s.db, orm.FilterIn("at_uri", uriList)) 137 if err != nil { 138 log.Println("failed to fetch labels", err) 139 }
+17
appview/state/git_http.go
··· 25 26 } 27 28 func (s *State) UploadPack(w http.ResponseWriter, r *http.Request) { 29 user, ok := r.Context().Value("resolvedId").(identity.Identity) 30 if !ok {
··· 25 26 } 27 28 + func (s *State) UploadArchive(w http.ResponseWriter, r *http.Request) { 29 + user, ok := r.Context().Value("resolvedId").(identity.Identity) 30 + if !ok { 31 + http.Error(w, "failed to resolve user", http.StatusInternalServerError) 32 + return 33 + } 34 + repo := r.Context().Value("repo").(*models.Repo) 35 + 36 + scheme := "https" 37 + if s.config.Core.Dev { 38 + scheme = "http" 39 + } 40 + 41 + targetURL := fmt.Sprintf("%s://%s/%s/%s/git-upload-archive?%s", scheme, repo.Knot, user.DID, repo.Name, r.URL.RawQuery) 42 + s.proxyRequest(w, r, targetURL) 43 + } 44 + 45 func (s *State) UploadPack(w http.ResponseWriter, r *http.Request) { 46 user, ok := r.Context().Value("resolvedId").(identity.Identity) 47 if !ok {
+6 -5
appview/state/knotstream.go
··· 16 ec "tangled.org/core/eventconsumer" 17 "tangled.org/core/eventconsumer/cursor" 18 "tangled.org/core/log" 19 "tangled.org/core/rbac" 20 "tangled.org/core/workflow" 21 ··· 30 31 knots, err := db.GetRegistrations( 32 d, 33 - db.FilterIsNot("registered", "null"), 34 ) 35 if err != nil { 36 return nil, err ··· 143 repos, err := db.GetRepos( 144 d, 145 0, 146 - db.FilterEq("did", record.RepoDid), 147 - db.FilterEq("name", record.RepoName), 148 ) 149 if err != nil { 150 return fmt.Errorf("failed to look for repo in DB (%s/%s): %w", record.RepoDid, record.RepoName, err) ··· 209 repos, err := db.GetRepos( 210 d, 211 0, 212 - db.FilterEq("did", record.TriggerMetadata.Repo.Did), 213 - db.FilterEq("name", record.TriggerMetadata.Repo.Repo), 214 ) 215 if err != nil { 216 return fmt.Errorf("failed to look for repo in DB: nsid %s, rkey %s, %w", msg.Nsid, msg.Rkey, err)
··· 16 ec "tangled.org/core/eventconsumer" 17 "tangled.org/core/eventconsumer/cursor" 18 "tangled.org/core/log" 19 + "tangled.org/core/orm" 20 "tangled.org/core/rbac" 21 "tangled.org/core/workflow" 22 ··· 31 32 knots, err := db.GetRegistrations( 33 d, 34 + orm.FilterIsNot("registered", "null"), 35 ) 36 if err != nil { 37 return nil, err ··· 144 repos, err := db.GetRepos( 145 d, 146 0, 147 + orm.FilterEq("did", record.RepoDid), 148 + orm.FilterEq("name", record.RepoName), 149 ) 150 if err != nil { 151 return fmt.Errorf("failed to look for repo in DB (%s/%s): %w", record.RepoDid, record.RepoName, err) ··· 210 repos, err := db.GetRepos( 211 d, 212 0, 213 + orm.FilterEq("did", record.TriggerMetadata.Repo.Did), 214 + orm.FilterEq("name", record.TriggerMetadata.Repo.Repo), 215 ) 216 if err != nil { 217 return fmt.Errorf("failed to look for repo in DB: nsid %s, rkey %s, %w", msg.Nsid, msg.Rkey, err)
+29
appview/state/manifest.go
···
··· 1 + package state 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + ) 7 + 8 + // https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest 9 + // https://www.w3.org/TR/appmanifest/ 10 + var manifestData = map[string]any{ 11 + "name": "tangled", 12 + "description": "tightly-knit social coding.", 13 + "icons": []map[string]string{ 14 + { 15 + "src": "/static/logos/dolly.svg", 16 + "sizes": "144x144", 17 + }, 18 + }, 19 + "start_url": "/", 20 + "id": "https://tangled.org", 21 + "display": "standalone", 22 + "background_color": "#111827", 23 + "theme_color": "#111827", 24 + } 25 + 26 + func (p *State) WebAppManifest(w http.ResponseWriter, r *http.Request) { 27 + w.Header().Set("Content-Type", "application/manifest+json") 28 + json.NewEncoder(w).Encode(manifestData) 29 + }
+30 -21
appview/state/profile.go
··· 19 "tangled.org/core/appview/db" 20 "tangled.org/core/appview/models" 21 "tangled.org/core/appview/pages" 22 ) 23 24 func (s *State) Profile(w http.ResponseWriter, r *http.Request) { ··· 56 return nil, fmt.Errorf("failed to get profile: %w", err) 57 } 58 59 - repoCount, err := db.CountRepos(s.db, db.FilterEq("did", did)) 60 if err != nil { 61 return nil, fmt.Errorf("failed to get repo count: %w", err) 62 } 63 64 - stringCount, err := db.CountStrings(s.db, db.FilterEq("did", did)) 65 if err != nil { 66 return nil, fmt.Errorf("failed to get string count: %w", err) 67 } 68 69 - starredCount, err := db.CountStars(s.db, db.FilterEq("starred_by_did", did)) 70 if err != nil { 71 return nil, fmt.Errorf("failed to get starred repo count: %w", err) 72 } ··· 86 startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC) 87 punchcard, err := db.MakePunchcard( 88 s.db, 89 - db.FilterEq("did", did), 90 - db.FilterGte("date", startOfYear.Format(time.DateOnly)), 91 - db.FilterLte("date", now.Format(time.DateOnly)), 92 ) 93 if err != nil { 94 return nil, fmt.Errorf("failed to get punchcard for %s: %w", did, err) ··· 96 97 return &pages.ProfileCard{ 98 UserDid: did, 99 - UserHandle: ident.Handle.String(), 100 Profile: profile, 101 FollowStatus: followStatus, 102 Stats: pages.ProfileStats{ ··· 119 s.pages.Error500(w) 120 return 121 } 122 - l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 123 124 repos, err := db.GetRepos( 125 s.db, 126 0, 127 - db.FilterEq("did", profile.UserDid), 128 ) 129 if err != nil { 130 l.Error("failed to fetch repos", "err", err) ··· 162 l.Error("failed to create timeline", "err", err) 163 } 164 165 s.pages.ProfileOverview(w, pages.ProfileOverviewParams{ 166 LoggedInUser: s.oauth.GetUser(r), 167 Card: profile, ··· 180 s.pages.Error500(w) 181 return 182 } 183 - l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 184 185 repos, err := db.GetRepos( 186 s.db, 187 0, 188 - db.FilterEq("did", profile.UserDid), 189 ) 190 if err != nil { 191 l.Error("failed to get repos", "err", err) ··· 209 s.pages.Error500(w) 210 return 211 } 212 - l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 213 214 - stars, err := db.GetStars(s.db, 0, db.FilterEq("starred_by_did", profile.UserDid)) 215 if err != nil { 216 l.Error("failed to get stars", "err", err) 217 s.pages.Error500(w) ··· 219 } 220 var repos []models.Repo 221 for _, s := range stars { 222 - if s.Repo != nil { 223 - repos = append(repos, *s.Repo) 224 - } 225 } 226 227 err = s.pages.ProfileStarred(w, pages.ProfileStarredParams{ ··· 240 s.pages.Error500(w) 241 return 242 } 243 - l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 244 245 - strings, err := db.GetStrings(s.db, 0, db.FilterEq("did", profile.UserDid)) 246 if err != nil { 247 l.Error("failed to get strings", "err", err) 248 s.pages.Error500(w) ··· 272 if err != nil { 273 return nil, err 274 } 275 - l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 276 277 loggedInUser := s.oauth.GetUser(r) 278 params := FollowsPageParams{ ··· 294 followDids = append(followDids, extractDid(follow)) 295 } 296 297 - profiles, err := db.GetProfiles(s.db, db.FilterIn("did", followDids)) 298 if err != nil { 299 l.Error("failed to get profiles", "followDids", followDids, "err", err) 300 return &params, err ··· 697 log.Printf("getting profile data for %s: %s", user.Did, err) 698 } 699 700 - repos, err := db.GetRepos(s.db, 0, db.FilterEq("did", user.Did)) 701 if err != nil { 702 log.Printf("getting repos for %s: %s", user.Did, err) 703 }
··· 19 "tangled.org/core/appview/db" 20 "tangled.org/core/appview/models" 21 "tangled.org/core/appview/pages" 22 + "tangled.org/core/orm" 23 ) 24 25 func (s *State) Profile(w http.ResponseWriter, r *http.Request) { ··· 57 return nil, fmt.Errorf("failed to get profile: %w", err) 58 } 59 60 + repoCount, err := db.CountRepos(s.db, orm.FilterEq("did", did)) 61 if err != nil { 62 return nil, fmt.Errorf("failed to get repo count: %w", err) 63 } 64 65 + stringCount, err := db.CountStrings(s.db, orm.FilterEq("did", did)) 66 if err != nil { 67 return nil, fmt.Errorf("failed to get string count: %w", err) 68 } 69 70 + starredCount, err := db.CountStars(s.db, orm.FilterEq("did", did)) 71 if err != nil { 72 return nil, fmt.Errorf("failed to get starred repo count: %w", err) 73 } ··· 87 startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC) 88 punchcard, err := db.MakePunchcard( 89 s.db, 90 + orm.FilterEq("did", did), 91 + orm.FilterGte("date", startOfYear.Format(time.DateOnly)), 92 + orm.FilterLte("date", now.Format(time.DateOnly)), 93 ) 94 if err != nil { 95 return nil, fmt.Errorf("failed to get punchcard for %s: %w", did, err) ··· 97 98 return &pages.ProfileCard{ 99 UserDid: did, 100 Profile: profile, 101 FollowStatus: followStatus, 102 Stats: pages.ProfileStats{ ··· 119 s.pages.Error500(w) 120 return 121 } 122 + l = l.With("profileDid", profile.UserDid) 123 124 repos, err := db.GetRepos( 125 s.db, 126 0, 127 + orm.FilterEq("did", profile.UserDid), 128 ) 129 if err != nil { 130 l.Error("failed to fetch repos", "err", err) ··· 162 l.Error("failed to create timeline", "err", err) 163 } 164 165 + // populate commit counts in the timeline, using the punchcard 166 + now := time.Now() 167 + for _, p := range profile.Punchcard.Punches { 168 + years := now.Year() - p.Date.Year() 169 + months := int(now.Month() - p.Date.Month()) 170 + monthsAgo := years*12 + months 171 + if monthsAgo >= 0 && monthsAgo < len(timeline.ByMonth) { 172 + timeline.ByMonth[monthsAgo].Commits += p.Count 173 + } 174 + } 175 + 176 s.pages.ProfileOverview(w, pages.ProfileOverviewParams{ 177 LoggedInUser: s.oauth.GetUser(r), 178 Card: profile, ··· 191 s.pages.Error500(w) 192 return 193 } 194 + l = l.With("profileDid", profile.UserDid) 195 196 repos, err := db.GetRepos( 197 s.db, 198 0, 199 + orm.FilterEq("did", profile.UserDid), 200 ) 201 if err != nil { 202 l.Error("failed to get repos", "err", err) ··· 220 s.pages.Error500(w) 221 return 222 } 223 + l = l.With("profileDid", profile.UserDid) 224 225 + stars, err := db.GetRepoStars(s.db, 0, orm.FilterEq("did", profile.UserDid)) 226 if err != nil { 227 l.Error("failed to get stars", "err", err) 228 s.pages.Error500(w) ··· 230 } 231 var repos []models.Repo 232 for _, s := range stars { 233 + repos = append(repos, *s.Repo) 234 } 235 236 err = s.pages.ProfileStarred(w, pages.ProfileStarredParams{ ··· 249 s.pages.Error500(w) 250 return 251 } 252 + l = l.With("profileDid", profile.UserDid) 253 254 + strings, err := db.GetStrings(s.db, 0, orm.FilterEq("did", profile.UserDid)) 255 if err != nil { 256 l.Error("failed to get strings", "err", err) 257 s.pages.Error500(w) ··· 281 if err != nil { 282 return nil, err 283 } 284 + l = l.With("profileDid", profile.UserDid) 285 286 loggedInUser := s.oauth.GetUser(r) 287 params := FollowsPageParams{ ··· 303 followDids = append(followDids, extractDid(follow)) 304 } 305 306 + profiles, err := db.GetProfiles(s.db, orm.FilterIn("did", followDids)) 307 if err != nil { 308 l.Error("failed to get profiles", "followDids", followDids, "err", err) 309 return &params, err ··· 706 log.Printf("getting profile data for %s: %s", user.Did, err) 707 } 708 709 + repos, err := db.GetRepos(s.db, 0, orm.FilterEq("did", user.Did)) 710 if err != nil { 711 log.Printf("getting repos for %s: %s", user.Did, err) 712 }
+11 -5
appview/state/router.go
··· 32 s.pages, 33 ) 34 35 - router.Get("/favicon.svg", s.Favicon) 36 - router.Get("/favicon.ico", s.Favicon) 37 - router.Get("/pwa-manifest.json", s.PWAManifest) 38 router.Get("/robots.txt", s.RobotsTxt) 39 40 userRouter := s.UserRouter(&middleware) ··· 101 102 // These routes get proxied to the knot 103 r.Get("/info/refs", s.InfoRefs) 104 r.Post("/git-upload-pack", s.UploadPack) 105 r.Post("/git-receive-pack", s.ReceivePack) 106 ··· 108 }) 109 110 r.NotFound(func(w http.ResponseWriter, r *http.Request) { 111 s.pages.Error404(w) 112 }) 113 ··· 166 167 r.Mount("/settings", s.SettingsRouter()) 168 r.Mount("/strings", s.StringsRouter(mw)) 169 - r.Mount("/knots", s.KnotsRouter()) 170 - r.Mount("/spindles", s.SpindlesRouter()) 171 r.Mount("/notifications", s.NotificationsRouter(mw)) 172 173 r.Mount("/signup", s.SignupRouter()) ··· 179 r.Get("/brand", s.Brand) 180 181 r.NotFound(func(w http.ResponseWriter, r *http.Request) { 182 s.pages.Error404(w) 183 }) 184 return r ··· 261 issues := issues.New( 262 s.oauth, 263 s.repoResolver, 264 s.pages, 265 s.idResolver, 266 s.db, 267 s.config, 268 s.notifier, ··· 279 s.repoResolver, 280 s.pages, 281 s.idResolver, 282 s.db, 283 s.config, 284 s.notifier,
··· 32 s.pages, 33 ) 34 35 + router.Get("/pwa-manifest.json", s.WebAppManifest) 36 router.Get("/robots.txt", s.RobotsTxt) 37 38 userRouter := s.UserRouter(&middleware) ··· 99 100 // These routes get proxied to the knot 101 r.Get("/info/refs", s.InfoRefs) 102 + r.Post("/git-upload-archive", s.UploadArchive) 103 r.Post("/git-upload-pack", s.UploadPack) 104 r.Post("/git-receive-pack", s.ReceivePack) 105 ··· 107 }) 108 109 r.NotFound(func(w http.ResponseWriter, r *http.Request) { 110 + w.WriteHeader(http.StatusNotFound) 111 s.pages.Error404(w) 112 }) 113 ··· 166 167 r.Mount("/settings", s.SettingsRouter()) 168 r.Mount("/strings", s.StringsRouter(mw)) 169 + 170 + r.Mount("/settings/knots", s.KnotsRouter()) 171 + r.Mount("/settings/spindles", s.SpindlesRouter()) 172 + 173 r.Mount("/notifications", s.NotificationsRouter(mw)) 174 175 r.Mount("/signup", s.SignupRouter()) ··· 181 r.Get("/brand", s.Brand) 182 183 r.NotFound(func(w http.ResponseWriter, r *http.Request) { 184 + w.WriteHeader(http.StatusNotFound) 185 s.pages.Error404(w) 186 }) 187 return r ··· 264 issues := issues.New( 265 s.oauth, 266 s.repoResolver, 267 + s.enforcer, 268 s.pages, 269 s.idResolver, 270 + s.mentionsResolver, 271 s.db, 272 s.config, 273 s.notifier, ··· 284 s.repoResolver, 285 s.pages, 286 s.idResolver, 287 + s.mentionsResolver, 288 s.db, 289 s.config, 290 s.notifier,
+2 -1
appview/state/spindlestream.go
··· 17 ec "tangled.org/core/eventconsumer" 18 "tangled.org/core/eventconsumer/cursor" 19 "tangled.org/core/log" 20 "tangled.org/core/rbac" 21 spindle "tangled.org/core/spindle/models" 22 ) ··· 27 28 spindles, err := db.GetSpindles( 29 d, 30 - db.FilterIsNot("verified", "null"), 31 ) 32 if err != nil { 33 return nil, err
··· 17 ec "tangled.org/core/eventconsumer" 18 "tangled.org/core/eventconsumer/cursor" 19 "tangled.org/core/log" 20 + "tangled.org/core/orm" 21 "tangled.org/core/rbac" 22 spindle "tangled.org/core/spindle/models" 23 ) ··· 28 29 spindles, err := db.GetSpindles( 30 d, 31 + orm.FilterIsNot("verified", "null"), 32 ) 33 if err != nil { 34 return nil, err
+9 -13
appview/state/star.go
··· 57 log.Println("created atproto record: ", resp.Uri) 58 59 star := &models.Star{ 60 - StarredByDid: currentUser.Did, 61 - RepoAt: subjectUri, 62 - Rkey: rkey, 63 } 64 65 err = db.AddStar(s.db, star) ··· 75 76 s.notifier.NewStar(r.Context(), star) 77 78 - s.pages.RepoStarFragment(w, pages.RepoStarFragmentParams{ 79 IsStarred: true, 80 - RepoAt: subjectUri, 81 - Stats: models.RepoStats{ 82 - StarCount: starCount, 83 - }, 84 }) 85 86 return ··· 117 118 s.notifier.DeleteStar(r.Context(), star) 119 120 - s.pages.RepoStarFragment(w, pages.RepoStarFragmentParams{ 121 IsStarred: false, 122 - RepoAt: subjectUri, 123 - Stats: models.RepoStats{ 124 - StarCount: starCount, 125 - }, 126 }) 127 128 return
··· 57 log.Println("created atproto record: ", resp.Uri) 58 59 star := &models.Star{ 60 + Did: currentUser.Did, 61 + RepoAt: subjectUri, 62 + Rkey: rkey, 63 } 64 65 err = db.AddStar(s.db, star) ··· 75 76 s.notifier.NewStar(r.Context(), star) 77 78 + s.pages.StarBtnFragment(w, pages.StarBtnFragmentParams{ 79 IsStarred: true, 80 + SubjectAt: subjectUri, 81 + StarCount: starCount, 82 }) 83 84 return ··· 115 116 s.notifier.DeleteStar(r.Context(), star) 117 118 + s.pages.StarBtnFragment(w, pages.StarBtnFragmentParams{ 119 IsStarred: false, 120 + SubjectAt: subjectUri, 121 + StarCount: starCount, 122 }) 123 124 return
+30 -60
appview/state/state.go
··· 15 "tangled.org/core/appview/config" 16 "tangled.org/core/appview/db" 17 "tangled.org/core/appview/indexer" 18 "tangled.org/core/appview/models" 19 "tangled.org/core/appview/notify" 20 dbnotify "tangled.org/core/appview/notify/db" ··· 29 "tangled.org/core/jetstream" 30 "tangled.org/core/log" 31 tlog "tangled.org/core/log" 32 "tangled.org/core/rbac" 33 "tangled.org/core/tid" 34 ··· 42 ) 43 44 type State struct { 45 - db *db.DB 46 - notifier notify.Notifier 47 - indexer *indexer.Indexer 48 - oauth *oauth.OAuth 49 - enforcer *rbac.Enforcer 50 - pages *pages.Pages 51 - idResolver *idresolver.Resolver 52 - posthog posthog.Client 53 - jc *jetstream.JetstreamClient 54 - config *config.Config 55 - repoResolver *reporesolver.RepoResolver 56 - knotstream *eventconsumer.Consumer 57 - spindlestream *eventconsumer.Consumer 58 - logger *slog.Logger 59 - validator *validator.Validator 60 } 61 62 func Make(ctx context.Context, config *config.Config) (*State, error) { ··· 96 } 97 validator := validator.New(d, res, enforcer) 98 99 - repoResolver := reporesolver.New(config, enforcer, res, d) 100 101 wrapper := db.DbWrapper{Execer: d} 102 jc, err := jetstream.NewJetstreamClient( ··· 178 enforcer, 179 pages, 180 res, 181 posthog, 182 jc, 183 config, ··· 196 return s.db.Close() 197 } 198 199 - func (s *State) Favicon(w http.ResponseWriter, r *http.Request) { 200 - w.Header().Set("Content-Type", "image/svg+xml") 201 - w.Header().Set("Cache-Control", "public, max-age=31536000") // one year 202 - w.Header().Set("ETag", `"favicon-svg-v1"`) 203 - 204 - if match := r.Header.Get("If-None-Match"); match == `"favicon-svg-v1"` { 205 - w.WriteHeader(http.StatusNotModified) 206 - return 207 - } 208 - 209 - s.pages.Favicon(w) 210 - } 211 - 212 func (s *State) RobotsTxt(w http.ResponseWriter, r *http.Request) { 213 w.Header().Set("Content-Type", "text/plain") 214 w.Header().Set("Cache-Control", "public, max-age=86400") // one day ··· 219 w.Write([]byte(robotsTxt)) 220 } 221 222 - // https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest 223 - const manifestJson = `{ 224 - "name": "tangled", 225 - "description": "tightly-knit social coding.", 226 - "icons": [ 227 - { 228 - "src": "/favicon.svg", 229 - "sizes": "144x144" 230 - } 231 - ], 232 - "start_url": "/", 233 - "id": "org.tangled", 234 - 235 - "display": "standalone", 236 - "background_color": "#111827", 237 - "theme_color": "#111827" 238 - }` 239 - 240 - func (p *State) PWAManifest(w http.ResponseWriter, r *http.Request) { 241 - w.Header().Set("Content-Type", "application/json") 242 - w.Write([]byte(manifestJson)) 243 - } 244 - 245 func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) { 246 user := s.oauth.GetUser(r) 247 s.pages.TermsOfService(w, pages.TermsOfServiceParams{ ··· 294 return 295 } 296 297 - gfiLabel, err := db.GetLabelDefinition(s.db, db.FilterEq("at_uri", s.config.Label.GoodFirstIssue)) 298 if err != nil { 299 // non-fatal 300 } ··· 318 319 regs, err := db.GetRegistrations( 320 s.db, 321 - db.FilterEq("did", user.Did), 322 - db.FilterEq("needs_upgrade", 1), 323 ) 324 if err != nil { 325 l.Error("non-fatal: failed to get registrations", "err", err) ··· 327 328 spindles, err := db.GetSpindles( 329 s.db, 330 - db.FilterEq("owner", user.Did), 331 - db.FilterEq("needs_upgrade", 1), 332 ) 333 if err != nil { 334 l.Error("non-fatal: failed to get spindles", "err", err) ··· 499 // Check for existing repos 500 existingRepo, err := db.GetRepo( 501 s.db, 502 - db.FilterEq("did", user.Did), 503 - db.FilterEq("name", repoName), 504 ) 505 if err == nil && existingRepo != nil { 506 l.Info("repo exists") ··· 660 } 661 662 func BackfillDefaultDefs(e db.Execer, r *idresolver.Resolver, defaults []string) error { 663 - defaultLabels, err := db.GetLabelDefinitions(e, db.FilterIn("at_uri", defaults)) 664 if err != nil { 665 return err 666 }
··· 15 "tangled.org/core/appview/config" 16 "tangled.org/core/appview/db" 17 "tangled.org/core/appview/indexer" 18 + "tangled.org/core/appview/mentions" 19 "tangled.org/core/appview/models" 20 "tangled.org/core/appview/notify" 21 dbnotify "tangled.org/core/appview/notify/db" ··· 30 "tangled.org/core/jetstream" 31 "tangled.org/core/log" 32 tlog "tangled.org/core/log" 33 + "tangled.org/core/orm" 34 "tangled.org/core/rbac" 35 "tangled.org/core/tid" 36 ··· 44 ) 45 46 type State struct { 47 + db *db.DB 48 + notifier notify.Notifier 49 + indexer *indexer.Indexer 50 + oauth *oauth.OAuth 51 + enforcer *rbac.Enforcer 52 + pages *pages.Pages 53 + idResolver *idresolver.Resolver 54 + mentionsResolver *mentions.Resolver 55 + posthog posthog.Client 56 + jc *jetstream.JetstreamClient 57 + config *config.Config 58 + repoResolver *reporesolver.RepoResolver 59 + knotstream *eventconsumer.Consumer 60 + spindlestream *eventconsumer.Consumer 61 + logger *slog.Logger 62 + validator *validator.Validator 63 } 64 65 func Make(ctx context.Context, config *config.Config) (*State, error) { ··· 99 } 100 validator := validator.New(d, res, enforcer) 101 102 + repoResolver := reporesolver.New(config, enforcer, d) 103 + 104 + mentionsResolver := mentions.New(config, res, d, log.SubLogger(logger, "mentionsResolver")) 105 106 wrapper := db.DbWrapper{Execer: d} 107 jc, err := jetstream.NewJetstreamClient( ··· 183 enforcer, 184 pages, 185 res, 186 + mentionsResolver, 187 posthog, 188 jc, 189 config, ··· 202 return s.db.Close() 203 } 204 205 func (s *State) RobotsTxt(w http.ResponseWriter, r *http.Request) { 206 w.Header().Set("Content-Type", "text/plain") 207 w.Header().Set("Cache-Control", "public, max-age=86400") // one day ··· 212 w.Write([]byte(robotsTxt)) 213 } 214 215 func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) { 216 user := s.oauth.GetUser(r) 217 s.pages.TermsOfService(w, pages.TermsOfServiceParams{ ··· 264 return 265 } 266 267 + gfiLabel, err := db.GetLabelDefinition(s.db, orm.FilterEq("at_uri", s.config.Label.GoodFirstIssue)) 268 if err != nil { 269 // non-fatal 270 } ··· 288 289 regs, err := db.GetRegistrations( 290 s.db, 291 + orm.FilterEq("did", user.Did), 292 + orm.FilterEq("needs_upgrade", 1), 293 ) 294 if err != nil { 295 l.Error("non-fatal: failed to get registrations", "err", err) ··· 297 298 spindles, err := db.GetSpindles( 299 s.db, 300 + orm.FilterEq("owner", user.Did), 301 + orm.FilterEq("needs_upgrade", 1), 302 ) 303 if err != nil { 304 l.Error("non-fatal: failed to get spindles", "err", err) ··· 469 // Check for existing repos 470 existingRepo, err := db.GetRepo( 471 s.db, 472 + orm.FilterEq("did", user.Did), 473 + orm.FilterEq("name", repoName), 474 ) 475 if err == nil && existingRepo != nil { 476 l.Info("repo exists") ··· 630 } 631 632 func BackfillDefaultDefs(e db.Execer, r *idresolver.Resolver, defaults []string) error { 633 + defaultLabels, err := db.GetLabelDefinitions(e, orm.FilterIn("at_uri", defaults)) 634 if err != nil { 635 return err 636 }
+21 -8
appview/strings/strings.go
··· 17 "tangled.org/core/appview/pages" 18 "tangled.org/core/appview/pages/markup" 19 "tangled.org/core/idresolver" 20 "tangled.org/core/tid" 21 22 "github.com/bluesky-social/indigo/api/atproto" ··· 108 strings, err := db.GetStrings( 109 s.Db, 110 0, 111 - db.FilterEq("did", id.DID), 112 - db.FilterEq("rkey", rkey), 113 ) 114 if err != nil { 115 l.Error("failed to fetch string", "err", err) ··· 148 showRendered = r.URL.Query().Get("code") != "true" 149 } 150 151 s.Pages.SingleString(w, pages.SingleStringParams{ 152 - LoggedInUser: s.OAuth.GetUser(r), 153 RenderToggle: renderToggle, 154 ShowRendered: showRendered, 155 - String: string, 156 Stats: string.Stats(), 157 Owner: id, 158 }) 159 } ··· 187 all, err := db.GetStrings( 188 s.Db, 189 0, 190 - db.FilterEq("did", id.DID), 191 - db.FilterEq("rkey", rkey), 192 ) 193 if err != nil { 194 l.Error("failed to fetch string", "err", err) ··· 396 397 if err := db.DeleteString( 398 s.Db, 399 - db.FilterEq("did", user.Did), 400 - db.FilterEq("rkey", rkey), 401 ); err != nil { 402 fail("Failed to delete string.", err) 403 return
··· 17 "tangled.org/core/appview/pages" 18 "tangled.org/core/appview/pages/markup" 19 "tangled.org/core/idresolver" 20 + "tangled.org/core/orm" 21 "tangled.org/core/tid" 22 23 "github.com/bluesky-social/indigo/api/atproto" ··· 109 strings, err := db.GetStrings( 110 s.Db, 111 0, 112 + orm.FilterEq("did", id.DID), 113 + orm.FilterEq("rkey", rkey), 114 ) 115 if err != nil { 116 l.Error("failed to fetch string", "err", err) ··· 149 showRendered = r.URL.Query().Get("code") != "true" 150 } 151 152 + starCount, err := db.GetStarCount(s.Db, string.AtUri()) 153 + if err != nil { 154 + l.Error("failed to get star count", "err", err) 155 + } 156 + user := s.OAuth.GetUser(r) 157 + isStarred := false 158 + if user != nil { 159 + isStarred = db.GetStarStatus(s.Db, user.Did, string.AtUri()) 160 + } 161 + 162 s.Pages.SingleString(w, pages.SingleStringParams{ 163 + LoggedInUser: user, 164 RenderToggle: renderToggle, 165 ShowRendered: showRendered, 166 + String: &string, 167 Stats: string.Stats(), 168 + IsStarred: isStarred, 169 + StarCount: starCount, 170 Owner: id, 171 }) 172 } ··· 200 all, err := db.GetStrings( 201 s.Db, 202 0, 203 + orm.FilterEq("did", id.DID), 204 + orm.FilterEq("rkey", rkey), 205 ) 206 if err != nil { 207 l.Error("failed to fetch string", "err", err) ··· 409 410 if err := db.DeleteString( 411 s.Db, 412 + orm.FilterEq("did", user.Did), 413 + orm.FilterEq("rkey", rkey), 414 ); err != nil { 415 fail("Failed to delete string.", err) 416 return
+2 -1
appview/validator/issue.go
··· 6 7 "tangled.org/core/appview/db" 8 "tangled.org/core/appview/models" 9 ) 10 11 func (v *Validator) ValidateIssueComment(comment *models.IssueComment) error { 12 // if comments have parents, only ingest ones that are 1 level deep 13 if comment.ReplyTo != nil { 14 - parents, err := db.GetIssueComments(v.db, db.FilterEq("at_uri", *comment.ReplyTo)) 15 if err != nil { 16 return fmt.Errorf("failed to fetch parent comment: %w", err) 17 }
··· 6 7 "tangled.org/core/appview/db" 8 "tangled.org/core/appview/models" 9 + "tangled.org/core/orm" 10 ) 11 12 func (v *Validator) ValidateIssueComment(comment *models.IssueComment) error { 13 // if comments have parents, only ingest ones that are 1 level deep 14 if comment.ReplyTo != nil { 15 + parents, err := db.GetIssueComments(v.db, orm.FilterEq("at_uri", *comment.ReplyTo)) 16 if err != nil { 17 return fmt.Errorf("failed to fetch parent comment: %w", err) 18 }
+182
cmd/dolly/main.go
···
··· 1 + package main 2 + 3 + import ( 4 + "bytes" 5 + "flag" 6 + "fmt" 7 + "image" 8 + "image/color" 9 + "image/png" 10 + "os" 11 + "path/filepath" 12 + "strconv" 13 + "strings" 14 + "text/template" 15 + 16 + "github.com/srwiley/oksvg" 17 + "github.com/srwiley/rasterx" 18 + "golang.org/x/image/draw" 19 + "tangled.org/core/appview/pages" 20 + "tangled.org/core/ico" 21 + ) 22 + 23 + func main() { 24 + var ( 25 + size string 26 + fillColor string 27 + output string 28 + ) 29 + 30 + flag.StringVar(&size, "size", "512x512", "Output size in format WIDTHxHEIGHT (e.g., 512x512)") 31 + flag.StringVar(&fillColor, "color", "#000000", "Fill color in hex format (e.g., #FF5733)") 32 + flag.StringVar(&output, "output", "dolly.svg", "Output file path (format detected from extension: .svg, .png, or .ico)") 33 + flag.Parse() 34 + 35 + width, height, err := parseSize(size) 36 + if err != nil { 37 + fmt.Fprintf(os.Stderr, "Error parsing size: %v\n", err) 38 + os.Exit(1) 39 + } 40 + 41 + // Detect format from file extension 42 + ext := strings.ToLower(filepath.Ext(output)) 43 + format := strings.TrimPrefix(ext, ".") 44 + 45 + if format != "svg" && format != "png" && format != "ico" { 46 + fmt.Fprintf(os.Stderr, "Invalid file extension: %s. Must be .svg, .png, or .ico\n", ext) 47 + os.Exit(1) 48 + } 49 + 50 + if !isValidHexColor(fillColor) { 51 + fmt.Fprintf(os.Stderr, "Invalid color format: %s. Use hex format like #FF5733\n", fillColor) 52 + os.Exit(1) 53 + } 54 + 55 + svgData, err := dolly(fillColor) 56 + if err != nil { 57 + fmt.Fprintf(os.Stderr, "Error generating SVG: %v\n", err) 58 + os.Exit(1) 59 + } 60 + 61 + // Create output directory if it doesn't exist 62 + dir := filepath.Dir(output) 63 + if dir != "" && dir != "." { 64 + if err := os.MkdirAll(dir, 0755); err != nil { 65 + fmt.Fprintf(os.Stderr, "Error creating output directory: %v\n", err) 66 + os.Exit(1) 67 + } 68 + } 69 + 70 + switch format { 71 + case "svg": 72 + err = saveSVG(svgData, output, width, height) 73 + case "png": 74 + err = savePNG(svgData, output, width, height) 75 + case "ico": 76 + err = saveICO(svgData, output, width, height) 77 + } 78 + 79 + if err != nil { 80 + fmt.Fprintf(os.Stderr, "Error saving file: %v\n", err) 81 + os.Exit(1) 82 + } 83 + 84 + fmt.Printf("Successfully generated %s (%dx%d)\n", output, width, height) 85 + } 86 + 87 + func dolly(hexColor string) ([]byte, error) { 88 + tpl, err := template.New("dolly"). 89 + ParseFS(pages.Files, "templates/fragments/dolly/logo.html") 90 + if err != nil { 91 + return nil, err 92 + } 93 + 94 + var svgData bytes.Buffer 95 + if err := tpl.ExecuteTemplate(&svgData, "fragments/dolly/logo", pages.DollyParams{ 96 + FillColor: hexColor, 97 + }); err != nil { 98 + return nil, err 99 + } 100 + 101 + return svgData.Bytes(), nil 102 + } 103 + 104 + func svgToImage(svgData []byte, w, h int) (image.Image, error) { 105 + icon, err := oksvg.ReadIconStream(bytes.NewReader(svgData)) 106 + if err != nil { 107 + return nil, fmt.Errorf("error parsing SVG: %v", err) 108 + } 109 + 110 + icon.SetTarget(0, 0, float64(w), float64(h)) 111 + rgba := image.NewRGBA(image.Rect(0, 0, w, h)) 112 + draw.Draw(rgba, rgba.Bounds(), &image.Uniform{color.Transparent}, image.Point{}, draw.Src) 113 + scanner := rasterx.NewScannerGV(w, h, rgba, rgba.Bounds()) 114 + raster := rasterx.NewDasher(w, h, scanner) 115 + icon.Draw(raster, 1.0) 116 + 117 + return rgba, nil 118 + } 119 + 120 + func parseSize(size string) (int, int, error) { 121 + parts := strings.Split(size, "x") 122 + if len(parts) != 2 { 123 + return 0, 0, fmt.Errorf("invalid size format, use WIDTHxHEIGHT") 124 + } 125 + 126 + width, err := strconv.Atoi(parts[0]) 127 + if err != nil { 128 + return 0, 0, fmt.Errorf("invalid width: %v", err) 129 + } 130 + 131 + height, err := strconv.Atoi(parts[1]) 132 + if err != nil { 133 + return 0, 0, fmt.Errorf("invalid height: %v", err) 134 + } 135 + 136 + if width <= 0 || height <= 0 { 137 + return 0, 0, fmt.Errorf("width and height must be positive") 138 + } 139 + 140 + return width, height, nil 141 + } 142 + 143 + func isValidHexColor(hex string) bool { 144 + if len(hex) != 7 || hex[0] != '#' { 145 + return false 146 + } 147 + _, err := strconv.ParseUint(hex[1:], 16, 32) 148 + return err == nil 149 + } 150 + 151 + func saveSVG(svgData []byte, filepath string, _, _ int) error { 152 + return os.WriteFile(filepath, svgData, 0644) 153 + } 154 + 155 + func savePNG(svgData []byte, filepath string, width, height int) error { 156 + img, err := svgToImage(svgData, width, height) 157 + if err != nil { 158 + return err 159 + } 160 + 161 + f, err := os.Create(filepath) 162 + if err != nil { 163 + return err 164 + } 165 + defer f.Close() 166 + 167 + return png.Encode(f, img) 168 + } 169 + 170 + func saveICO(svgData []byte, filepath string, width, height int) error { 171 + img, err := svgToImage(svgData, width, height) 172 + if err != nil { 173 + return err 174 + } 175 + 176 + icoData, err := ico.ImageToIco(img) 177 + if err != nil { 178 + return err 179 + } 180 + 181 + return os.WriteFile(filepath, icoData, 0644) 182 + }
+1 -34
crypto/verify.go
··· 5 "crypto/sha256" 6 "encoding/base64" 7 "fmt" 8 - "strings" 9 10 "github.com/hiddeco/sshsig" 11 "golang.org/x/crypto/ssh" 12 - "tangled.org/core/types" 13 ) 14 15 func VerifySignature(pubKey, signature, payload []byte) (error, bool) { ··· 28 // multiple algorithms but sha-512 is most secure, and git's ssh signing defaults 29 // to sha-512 for all key types anyway. 30 err = sshsig.Verify(buf, sig, pub, sshsig.HashSHA512, "git") 31 - return err, err == nil 32 - } 33 34 - // VerifyCommitSignature reconstructs the payload used to sign a commit. This is 35 - // essentially the git cat-file output but without the gpgsig header. 36 - // 37 - // Caveats: signature verification will fail on commits with more than one parent, 38 - // i.e. merge commits, because types.NiceDiff doesn't carry more than one Parent field 39 - // and we are unable to reconstruct the payload correctly. 40 - // 41 - // Ideally this should directly operate on an *object.Commit. 42 - func VerifyCommitSignature(pubKey string, commit types.NiceDiff) (error, bool) { 43 - signature := commit.Commit.PGPSignature 44 - 45 - author := bytes.NewBuffer([]byte{}) 46 - committer := bytes.NewBuffer([]byte{}) 47 - commit.Commit.Author.Encode(author) 48 - commit.Commit.Committer.Encode(committer) 49 - 50 - payload := strings.Builder{} 51 - 52 - fmt.Fprintf(&payload, "tree %s\n", commit.Commit.Tree) 53 - if commit.Commit.Parent != "" { 54 - fmt.Fprintf(&payload, "parent %s\n", commit.Commit.Parent) 55 - } 56 - fmt.Fprintf(&payload, "author %s\n", author.String()) 57 - fmt.Fprintf(&payload, "committer %s\n", committer.String()) 58 - if commit.Commit.ChangedId != "" { 59 - fmt.Fprintf(&payload, "change-id %s\n", commit.Commit.ChangedId) 60 - } 61 - fmt.Fprintf(&payload, "\n%s", commit.Commit.Message) 62 - 63 - return VerifySignature([]byte(pubKey), []byte(signature), []byte(payload.String())) 64 } 65 66 // SSHFingerprint computes the fingerprint of the supplied ssh pubkey.
··· 5 "crypto/sha256" 6 "encoding/base64" 7 "fmt" 8 9 "github.com/hiddeco/sshsig" 10 "golang.org/x/crypto/ssh" 11 ) 12 13 func VerifySignature(pubKey, signature, payload []byte) (error, bool) { ··· 26 // multiple algorithms but sha-512 is most secure, and git's ssh signing defaults 27 // to sha-512 for all key types anyway. 28 err = sshsig.Verify(buf, sig, pub, sshsig.HashSHA512, "git") 29 30 + return err, err == nil 31 } 32 33 // SSHFingerprint computes the fingerprint of the supplied ssh pubkey.
+1527
docs/DOCS.md
···
··· 1 + --- 2 + title: Tangled docs 3 + author: The Tangled Contributors 4 + date: 21 Sun, Dec 2025 5 + abstract: | 6 + Tangled is a decentralized code hosting and collaboration 7 + platform. Every component of Tangled is open-source and 8 + self-hostable. [tangled.org](https://tangled.org) also 9 + provides hosting and CI services that are free to use. 10 + 11 + There are several models for decentralized code 12 + collaboration platforms, ranging from ActivityPubโ€™s 13 + (Forgejo) federated model, to Radicleโ€™s entirely P2P model. 14 + Our approach attempts to be the best of both worlds by 15 + adopting the AT Protocolโ€”a protocol for building decentralized 16 + social applications with a central identity 17 + 18 + Our approach to this is the idea of โ€œknotsโ€. Knots are 19 + lightweight, headless servers that enable users to host Git 20 + repositories with ease. Knots are designed for either single 21 + or multi-tenant use which is perfect for self-hosting on a 22 + Raspberry Pi at home, or larger โ€œcommunityโ€ servers. By 23 + default, Tangled provides managed knots where you can host 24 + your repositories for free. 25 + 26 + The appview at tangled.org acts as a consolidated "view" 27 + into the whole network, allowing users to access, clone and 28 + contribute to repositories hosted across different knots 29 + seamlessly. 30 + --- 31 + 32 + # Quick start guide 33 + 34 + ## Login or sign up 35 + 36 + You can [login](https://tangled.org) by using your AT Protocol 37 + account. If you are unclear on what that means, simply head 38 + to the [signup](https://tangled.org/signup) page and create 39 + an account. By doing so, you will be choosing Tangled as 40 + your account provider (you will be granted a handle of the 41 + form `user.tngl.sh`). 42 + 43 + In the AT Protocol network, users are free to choose their account 44 + provider (known as a "Personal Data Service", or PDS), and 45 + login to applications that support AT accounts. 46 + 47 + You can think of it as "one account for all of the atmosphere"! 48 + 49 + If you already have an AT account (you may have one if you 50 + signed up to Bluesky, for example), you can login with the 51 + same handle on Tangled (so just use `user.bsky.social` on 52 + the login page). 53 + 54 + ## Add an SSH key 55 + 56 + Once you are logged in, you can start creating repositories 57 + and pushing code. Tangled supports pushing git repositories 58 + over SSH. 59 + 60 + First, you'll need to generate an SSH key if you don't 61 + already have one: 62 + 63 + ```bash 64 + ssh-keygen -t ed25519 -C "foo@bar.com" 65 + ``` 66 + 67 + When prompted, save the key to the default location 68 + (`~/.ssh/id_ed25519`) and optionally set a passphrase. 69 + 70 + Copy your public key to your clipboard: 71 + 72 + ```bash 73 + # on X11 74 + cat ~/.ssh/id_ed25519.pub | xclip -sel c 75 + 76 + # on wayland 77 + cat ~/.ssh/id_ed25519.pub | wl-copy 78 + 79 + # on macos 80 + cat ~/.ssh/id_ed25519.pub | pbcopy 81 + ``` 82 + 83 + Now, navigate to 'Settings' -> 'Keys' and hit 'Add Key', 84 + paste your public key, give it a descriptive name, and hit 85 + save. 86 + 87 + ## Create a repository 88 + 89 + Once your SSH key is added, create your first repository: 90 + 91 + 1. Hit the green `+` icon on the topbar, and select 92 + repository 93 + 2. Enter a repository name 94 + 3. Add a description 95 + 4. Choose a knotserver to host this repository on 96 + 5. Hit create 97 + 98 + Knots are self-hostable, lightweight Git servers that can 99 + host your repository. Unlike traditional code forges, your 100 + code can live on any server. Read the [Knots](TODO) section 101 + for more. 102 + 103 + ## Configure SSH 104 + 105 + To ensure Git uses the correct SSH key and connects smoothly 106 + to Tangled, add this configuration to your `~/.ssh/config` 107 + file: 108 + 109 + ``` 110 + Host tangled.org 111 + Hostname tangled.org 112 + User git 113 + IdentityFile ~/.ssh/id_ed25519 114 + AddressFamily inet 115 + ``` 116 + 117 + This tells SSH to use your specific key when connecting to 118 + Tangled and prevents authentication issues if you have 119 + multiple SSH keys. 120 + 121 + Note that this configuration only works for knotservers that 122 + are hosted by tangled.org. If you use a custom knot, refer 123 + to the [Knots](TODO) section. 124 + 125 + ## Push your first repository 126 + 127 + Initialize a new Git repository: 128 + 129 + ```bash 130 + mkdir my-project 131 + cd my-project 132 + 133 + git init 134 + echo "# My Project" > README.md 135 + ``` 136 + 137 + Add some content and push! 138 + 139 + ```bash 140 + git add README.md 141 + git commit -m "Initial commit" 142 + git remote add origin git@tangled.org:user.tngl.sh/my-project 143 + git push -u origin main 144 + ``` 145 + 146 + That's it! Your code is now hosted on Tangled. 147 + 148 + ## Migrating an existing repository 149 + 150 + Moving your repositories from GitHub, GitLab, Bitbucket, or 151 + any other Git forge to Tangled is straightforward. You'll 152 + simply change your repository's remote URL. At the moment, 153 + Tangled does not have any tooling to migrate data such as 154 + GitHub issues or pull requests. 155 + 156 + First, create a new repository on tangled.org as described 157 + in the [Quick Start Guide](#create-a-repository). 158 + 159 + Navigate to your existing local repository: 160 + 161 + ```bash 162 + cd /path/to/your/existing/repo 163 + ``` 164 + 165 + You can inspect your existing Git remote like so: 166 + 167 + ```bash 168 + git remote -v 169 + ``` 170 + 171 + You'll see something like: 172 + 173 + ``` 174 + origin git@github.com:username/my-project (fetch) 175 + origin git@github.com:username/my-project (push) 176 + ``` 177 + 178 + Update the remote URL to point to tangled: 179 + 180 + ```bash 181 + git remote set-url origin git@tangled.org:user.tngl.sh/my-project 182 + ``` 183 + 184 + Verify the change: 185 + 186 + ```bash 187 + git remote -v 188 + ``` 189 + 190 + You should now see: 191 + 192 + ``` 193 + origin git@tangled.org:user.tngl.sh/my-project (fetch) 194 + origin git@tangled.org:user.tngl.sh/my-project (push) 195 + ``` 196 + 197 + Push all your branches and tags to Tangled: 198 + 199 + ```bash 200 + git push -u origin --all 201 + git push -u origin --tags 202 + ``` 203 + 204 + Your repository is now migrated to Tangled! All commit 205 + history, branches, and tags have been preserved. 206 + 207 + ## Mirroring a repository to Tangled 208 + 209 + If you want to maintain your repository on multiple forges 210 + simultaneously, for example, keeping your primary repository 211 + on GitHub while mirroring to Tangled for backup or 212 + redundancy, you can do so by adding multiple remotes. 213 + 214 + You can configure your local repository to push to both 215 + Tangled and, say, GitHub. You may already have the following 216 + setup: 217 + 218 + ``` 219 + $ git remote -v 220 + origin git@github.com:username/my-project (fetch) 221 + origin git@github.com:username/my-project (push) 222 + ``` 223 + 224 + Now add Tangled as an additional push URL to the same 225 + remote: 226 + 227 + ```bash 228 + git remote set-url --add --push origin git@tangled.org:user.tngl.sh/my-project 229 + ``` 230 + 231 + You also need to re-add the original URL as a push 232 + destination (Git replaces the push URL when you use `--add` 233 + the first time): 234 + 235 + ```bash 236 + git remote set-url --add --push origin git@github.com:username/my-project 237 + ``` 238 + 239 + Verify your configuration: 240 + 241 + ``` 242 + $ git remote -v 243 + origin git@github.com:username/repo (fetch) 244 + origin git@tangled.org:username/my-project (push) 245 + origin git@github.com:username/repo (push) 246 + ``` 247 + 248 + Notice that there's one fetch URL (the primary remote) and 249 + two push URLs. Now, whenever you push, Git will 250 + automatically push to both remotes: 251 + 252 + ```bash 253 + git push origin main 254 + ``` 255 + 256 + This single command pushes your `main` branch to both GitHub 257 + and Tangled simultaneously. 258 + 259 + To push all branches and tags: 260 + 261 + ```bash 262 + git push origin --all 263 + git push origin --tags 264 + ``` 265 + 266 + If you prefer more control over which remote you push to, 267 + you can maintain separate remotes: 268 + 269 + ```bash 270 + git remote add github git@github.com:username/my-project 271 + git remote add tangled git@tangled.org:username/my-project 272 + ``` 273 + 274 + Then push to each explicitly: 275 + 276 + ```bash 277 + git push github main 278 + git push tangled main 279 + ``` 280 + 281 + # Knot self-hosting guide 282 + 283 + So you want to run your own knot server? Great! Here are a few prerequisites: 284 + 285 + 1. A server of some kind (a VPS, a Raspberry Pi, etc.). Preferably running a Linux distribution of some kind. 286 + 2. A (sub)domain name. People generally use `knot.example.com`. 287 + 3. A valid SSL certificate for your domain. 288 + 289 + ## NixOS 290 + 291 + Refer to the [knot 292 + module](https://tangled.org/tangled.org/core/blob/master/nix/modules/knot.nix) 293 + for a full list of options. Sample configurations: 294 + 295 + - [The test VM](https://tangled.org/tangled.org/core/blob/master/nix/vm.nix#L85) 296 + - [@pyrox.dev/nix](https://tangled.org/pyrox.dev/nix/blob/d19571cc1b5fe01035e1e6951ec8cf8a476b4dee/hosts/marvin/services/tangled.nix#L15-25) 297 + 298 + ## Docker 299 + 300 + Refer to 301 + [@tangled.org/knot-docker](https://tangled.org/@tangled.org/knot-docker). 302 + Note that this is community maintained. 303 + 304 + ## Manual setup 305 + 306 + First, clone this repository: 307 + 308 + ``` 309 + git clone https://tangled.org/@tangled.org/core 310 + ``` 311 + 312 + Then, build the `knot` CLI. This is the knot administration 313 + and operation tool. For the purpose of this guide, we're 314 + only concerned with these subcommands: 315 + 316 + * `knot server`: the main knot server process, typically 317 + run as a supervised service 318 + * `knot guard`: handles role-based access control for git 319 + over SSH (you'll never have to run this yourself) 320 + * `knot keys`: fetches SSH keys associated with your knot; 321 + we'll use this to generate the SSH 322 + `AuthorizedKeysCommand` 323 + 324 + ``` 325 + cd core 326 + export CGO_ENABLED=1 327 + go build -o knot ./cmd/knot 328 + ``` 329 + 330 + Next, move the `knot` binary to a location owned by `root` -- 331 + `/usr/local/bin/` is a good choice. Make sure the binary itself is also owned by `root`: 332 + 333 + ``` 334 + sudo mv knot /usr/local/bin/knot 335 + sudo chown root:root /usr/local/bin/knot 336 + ``` 337 + 338 + This is necessary because SSH `AuthorizedKeysCommand` requires [really 339 + specific permissions](https://stackoverflow.com/a/27638306). The 340 + `AuthorizedKeysCommand` specifies a command that is run by `sshd` to 341 + retrieve a user's public SSH keys dynamically for authentication. Let's 342 + set that up. 343 + 344 + ``` 345 + sudo tee /etc/ssh/sshd_config.d/authorized_keys_command.conf <<EOF 346 + Match User git 347 + AuthorizedKeysCommand /usr/local/bin/knot keys -o authorized-keys 348 + AuthorizedKeysCommandUser nobody 349 + EOF 350 + ``` 351 + 352 + Then, reload `sshd`: 353 + 354 + ``` 355 + sudo systemctl reload ssh 356 + ``` 357 + 358 + Next, create the `git` user. We'll use the `git` user's home directory 359 + to store repositories: 360 + 361 + ``` 362 + sudo adduser git 363 + ``` 364 + 365 + Create `/home/git/.knot.env` with the following, updating the values as 366 + necessary. The `KNOT_SERVER_OWNER` should be set to your 367 + DID, you can find your DID in the [Settings](https://tangled.sh/settings) page. 368 + 369 + ``` 370 + KNOT_REPO_SCAN_PATH=/home/git 371 + KNOT_SERVER_HOSTNAME=knot.example.com 372 + APPVIEW_ENDPOINT=https://tangled.org 373 + KNOT_SERVER_OWNER=did:plc:foobar 374 + KNOT_SERVER_INTERNAL_LISTEN_ADDR=127.0.0.1:5444 375 + KNOT_SERVER_LISTEN_ADDR=127.0.0.1:5555 376 + ``` 377 + 378 + If you run a Linux distribution that uses systemd, you can use the provided 379 + service file to run the server. Copy 380 + [`knotserver.service`](/systemd/knotserver.service) 381 + to `/etc/systemd/system/`. Then, run: 382 + 383 + ``` 384 + systemctl enable knotserver 385 + systemctl start knotserver 386 + ``` 387 + 388 + The last step is to configure a reverse proxy like Nginx or Caddy to front your 389 + knot. Here's an example configuration for Nginx: 390 + 391 + ``` 392 + server { 393 + listen 80; 394 + listen [::]:80; 395 + server_name knot.example.com; 396 + 397 + location / { 398 + proxy_pass http://localhost:5555; 399 + proxy_set_header Host $host; 400 + proxy_set_header X-Real-IP $remote_addr; 401 + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 402 + proxy_set_header X-Forwarded-Proto $scheme; 403 + } 404 + 405 + # wss endpoint for git events 406 + location /events { 407 + proxy_set_header X-Forwarded-For $remote_addr; 408 + proxy_set_header Host $http_host; 409 + proxy_set_header Upgrade websocket; 410 + proxy_set_header Connection Upgrade; 411 + proxy_pass http://localhost:5555; 412 + } 413 + # additional config for SSL/TLS go here. 414 + } 415 + 416 + ``` 417 + 418 + Remember to use Let's Encrypt or similar to procure a certificate for your 419 + knot domain. 420 + 421 + You should now have a running knot server! You can finalize 422 + your registration by hitting the `verify` button on the 423 + [/settings/knots](https://tangled.org/settings/knots) page. This simply creates 424 + a record on your PDS to announce the existence of the knot. 425 + 426 + ### Custom paths 427 + 428 + (This section applies to manual setup only. Docker users should edit the mounts 429 + in `docker-compose.yml` instead.) 430 + 431 + Right now, the database and repositories of your knot lives in `/home/git`. You 432 + can move these paths if you'd like to store them in another folder. Be careful 433 + when adjusting these paths: 434 + 435 + * Stop your knot when moving data (e.g. `systemctl stop knotserver`) to prevent 436 + any possible side effects. Remember to restart it once you're done. 437 + * Make backups before moving in case something goes wrong. 438 + * Make sure the `git` user can read and write from the new paths. 439 + 440 + #### Database 441 + 442 + As an example, let's say the current database is at `/home/git/knotserver.db`, 443 + and we want to move it to `/home/git/database/knotserver.db`. 444 + 445 + Copy the current database to the new location. Make sure to copy the `.db-shm` 446 + and `.db-wal` files if they exist. 447 + 448 + ``` 449 + mkdir /home/git/database 450 + cp /home/git/knotserver.db* /home/git/database 451 + ``` 452 + 453 + In the environment (e.g. `/home/git/.knot.env`), set `KNOT_SERVER_DB_PATH` to 454 + the new file path (_not_ the directory): 455 + 456 + ``` 457 + KNOT_SERVER_DB_PATH=/home/git/database/knotserver.db 458 + ``` 459 + 460 + #### Repositories 461 + 462 + As an example, let's say the repositories are currently in `/home/git`, and we 463 + want to move them into `/home/git/repositories`. 464 + 465 + Create the new folder, then move the existing repositories (if there are any): 466 + 467 + ``` 468 + mkdir /home/git/repositories 469 + # move all DIDs into the new folder; these will vary for you! 470 + mv /home/git/did:plc:wshs7t2adsemcrrd4snkeqli /home/git/repositories 471 + ``` 472 + 473 + In the environment (e.g. `/home/git/.knot.env`), update `KNOT_REPO_SCAN_PATH` 474 + to the new directory: 475 + 476 + ``` 477 + KNOT_REPO_SCAN_PATH=/home/git/repositories 478 + ``` 479 + 480 + Similarly, update your `sshd` `AuthorizedKeysCommand` to use the updated 481 + repository path: 482 + 483 + ``` 484 + sudo tee /etc/ssh/sshd_config.d/authorized_keys_command.conf <<EOF 485 + Match User git 486 + AuthorizedKeysCommand /usr/local/bin/knot keys -o authorized-keys -git-dir /home/git/repositories 487 + AuthorizedKeysCommandUser nobody 488 + EOF 489 + ``` 490 + 491 + Make sure to restart your SSH server! 492 + 493 + #### MOTD (message of the day) 494 + 495 + To configure the MOTD used ("Welcome to this knot!" by default), edit the 496 + `/home/git/motd` file: 497 + 498 + ``` 499 + printf "Hi from this knot!\n" > /home/git/motd 500 + ``` 501 + 502 + Note that you should add a newline at the end if setting a non-empty message 503 + since the knot won't do this for you. 504 + 505 + # Spindles 506 + 507 + ## Pipelines 508 + 509 + Spindle workflows allow you to write CI/CD pipelines in a 510 + simple format. They're located in the `.tangled/workflows` 511 + directory at the root of your repository, and are defined 512 + using YAML. 513 + 514 + The fields are: 515 + 516 + - [Trigger](#trigger): A **required** field that defines 517 + when a workflow should be triggered. 518 + - [Engine](#engine): A **required** field that defines which 519 + engine a workflow should run on. 520 + - [Clone options](#clone-options): An **optional** field 521 + that defines how the repository should be cloned. 522 + - [Dependencies](#dependencies): An **optional** field that 523 + allows you to list dependencies you may need. 524 + - [Environment](#environment): An **optional** field that 525 + allows you to define environment variables. 526 + - [Steps](#steps): An **optional** field that allows you to 527 + define what steps should run in the workflow. 528 + 529 + ### Trigger 530 + 531 + The first thing to add to a workflow is the trigger, which 532 + defines when a workflow runs. This is defined using a `when` 533 + field, which takes in a list of conditions. Each condition 534 + has the following fields: 535 + 536 + - `event`: This is a **required** field that defines when 537 + your workflow should run. It's a list that can take one or 538 + more of the following values: 539 + - `push`: The workflow should run every time a commit is 540 + pushed to the repository. 541 + - `pull_request`: The workflow should run every time a 542 + pull request is made or updated. 543 + - `manual`: The workflow can be triggered manually. 544 + - `branch`: Defines which branches the workflow should run 545 + for. If used with the `push` event, commits to the 546 + branch(es) listed here will trigger the workflow. If used 547 + with the `pull_request` event, updates to pull requests 548 + targeting the branch(es) listed here will trigger the 549 + workflow. This field has no effect with the `manual` 550 + event. Supports glob patterns using `*` and `**` (e.g., 551 + `main`, `develop`, `release-*`). Either `branch` or `tag` 552 + (or both) must be specified for `push` events. 553 + - `tag`: Defines which tags the workflow should run for. 554 + Only used with the `push` event - when tags matching the 555 + pattern(s) listed here are pushed, the workflow will 556 + trigger. This field has no effect with `pull_request` or 557 + `manual` events. Supports glob patterns using `*` and `**` 558 + (e.g., `v*`, `v1.*`, `release-**`). Either `branch` or 559 + `tag` (or both) must be specified for `push` events. 560 + 561 + For example, if you'd like to define a workflow that runs 562 + when commits are pushed to the `main` and `develop` 563 + branches, or when pull requests that target the `main` 564 + branch are updated, or manually, you can do so with: 565 + 566 + ```yaml 567 + when: 568 + - event: ["push", "manual"] 569 + branch: ["main", "develop"] 570 + - event: ["pull_request"] 571 + branch: ["main"] 572 + ``` 573 + 574 + You can also trigger workflows on tag pushes. For instance, 575 + to run a deployment workflow when tags matching `v*` are 576 + pushed: 577 + 578 + ```yaml 579 + when: 580 + - event: ["push"] 581 + tag: ["v*"] 582 + ``` 583 + 584 + You can even combine branch and tag patterns in a single 585 + constraint (the workflow triggers if either matches): 586 + 587 + ```yaml 588 + when: 589 + - event: ["push"] 590 + branch: ["main", "release-*"] 591 + tag: ["v*", "stable"] 592 + ``` 593 + 594 + ### Engine 595 + 596 + Next is the engine on which the workflow should run, defined 597 + using the **required** `engine` field. The currently 598 + supported engines are: 599 + 600 + - `nixery`: This uses an instance of 601 + [Nixery](https://nixery.dev) to run steps, which allows 602 + you to add [dependencies](#dependencies) from 603 + Nixpkgs (https://github.com/NixOS/nixpkgs). You can 604 + search for packages on https://search.nixos.org, and 605 + there's a pretty good chance the package(s) you're looking 606 + for will be there. 607 + 608 + Example: 609 + 610 + ```yaml 611 + engine: "nixery" 612 + ``` 613 + 614 + ### Clone options 615 + 616 + When a workflow starts, the first step is to clone the 617 + repository. You can customize this behavior using the 618 + **optional** `clone` field. It has the following fields: 619 + 620 + - `skip`: Setting this to `true` will skip cloning the 621 + repository. This can be useful if your workflow is doing 622 + something that doesn't require anything from the 623 + repository itself. This is `false` by default. 624 + - `depth`: This sets the number of commits, or the "clone 625 + depth", to fetch from the repository. For example, if you 626 + set this to 2, the last 2 commits will be fetched. By 627 + default, the depth is set to 1, meaning only the most 628 + recent commit will be fetched, which is the commit that 629 + triggered the workflow. 630 + - `submodules`: If you use Git submodules 631 + (https://git-scm.com/book/en/v2/Git-Tools-Submodules) 632 + in your repository, setting this field to `true` will 633 + recursively fetch all submodules. This is `false` by 634 + default. 635 + 636 + The default settings are: 637 + 638 + ```yaml 639 + clone: 640 + skip: false 641 + depth: 1 642 + submodules: false 643 + ``` 644 + 645 + ### Dependencies 646 + 647 + Usually when you're running a workflow, you'll need 648 + additional dependencies. The `dependencies` field lets you 649 + define which dependencies to get, and from where. It's a 650 + key-value map, with the key being the registry to fetch 651 + dependencies from, and the value being the list of 652 + dependencies to fetch. 653 + 654 + Say you want to fetch Node.js and Go from `nixpkgs`, and a 655 + package called `my_pkg` you've made from your own registry 656 + at your repository at 657 + `https://tangled.org/@example.com/my_pkg`. You can define 658 + those dependencies like so: 659 + 660 + ```yaml 661 + dependencies: 662 + # nixpkgs 663 + nixpkgs: 664 + - nodejs 665 + - go 666 + # custom registry 667 + git+https://tangled.org/@example.com/my_pkg: 668 + - my_pkg 669 + ``` 670 + 671 + Now these dependencies are available to use in your 672 + workflow! 673 + 674 + ### Environment 675 + 676 + The `environment` field allows you define environment 677 + variables that will be available throughout the entire 678 + workflow. **Do not put secrets here, these environment 679 + variables are visible to anyone viewing the repository. You 680 + can add secrets for pipelines in your repository's 681 + settings.** 682 + 683 + Example: 684 + 685 + ```yaml 686 + environment: 687 + GOOS: "linux" 688 + GOARCH: "arm64" 689 + NODE_ENV: "production" 690 + MY_ENV_VAR: "MY_ENV_VALUE" 691 + ``` 692 + 693 + ### Steps 694 + 695 + The `steps` field allows you to define what steps should run 696 + in the workflow. It's a list of step objects, each with the 697 + following fields: 698 + 699 + - `name`: This field allows you to give your step a name. 700 + This name is visible in your workflow runs, and is used to 701 + describe what the step is doing. 702 + - `command`: This field allows you to define a command to 703 + run in that step. The step is run in a Bash shell, and the 704 + logs from the command will be visible in the pipelines 705 + page on the Tangled website. The 706 + [dependencies](#dependencies) you added will be available 707 + to use here. 708 + - `environment`: Similar to the global 709 + [environment](#environment) config, this **optional** 710 + field is a key-value map that allows you to set 711 + environment variables for the step. **Do not put secrets 712 + here, these environment variables are visible to anyone 713 + viewing the repository. You can add secrets for pipelines 714 + in your repository's settings.** 715 + 716 + Example: 717 + 718 + ```yaml 719 + steps: 720 + - name: "Build backend" 721 + command: "go build" 722 + environment: 723 + GOOS: "darwin" 724 + GOARCH: "arm64" 725 + - name: "Build frontend" 726 + command: "npm run build" 727 + environment: 728 + NODE_ENV: "production" 729 + ``` 730 + 731 + ### Complete workflow 732 + 733 + ```yaml 734 + # .tangled/workflows/build.yml 735 + 736 + when: 737 + - event: ["push", "manual"] 738 + branch: ["main", "develop"] 739 + - event: ["pull_request"] 740 + branch: ["main"] 741 + 742 + engine: "nixery" 743 + 744 + # using the default values 745 + clone: 746 + skip: false 747 + depth: 1 748 + submodules: false 749 + 750 + dependencies: 751 + # nixpkgs 752 + nixpkgs: 753 + - nodejs 754 + - go 755 + # custom registry 756 + git+https://tangled.org/@example.com/my_pkg: 757 + - my_pkg 758 + 759 + environment: 760 + GOOS: "linux" 761 + GOARCH: "arm64" 762 + NODE_ENV: "production" 763 + MY_ENV_VAR: "MY_ENV_VALUE" 764 + 765 + steps: 766 + - name: "Build backend" 767 + command: "go build" 768 + environment: 769 + GOOS: "darwin" 770 + GOARCH: "arm64" 771 + - name: "Build frontend" 772 + command: "npm run build" 773 + environment: 774 + NODE_ENV: "production" 775 + ``` 776 + 777 + If you want another example of a workflow, you can look at 778 + the one [Tangled uses to build the 779 + project](https://tangled.org/@tangled.org/core/blob/master/.tangled/workflows/build.yml). 780 + 781 + ## Self-hosting guide 782 + 783 + ### Prerequisites 784 + 785 + * Go 786 + * Docker (the only supported backend currently) 787 + 788 + ### Configuration 789 + 790 + Spindle is configured using environment variables. The following environment variables are available: 791 + 792 + * `SPINDLE_SERVER_LISTEN_ADDR`: The address the server listens on (default: `"0.0.0.0:6555"`). 793 + * `SPINDLE_SERVER_DB_PATH`: The path to the SQLite database file (default: `"spindle.db"`). 794 + * `SPINDLE_SERVER_HOSTNAME`: The hostname of the server (required). 795 + * `SPINDLE_SERVER_JETSTREAM_ENDPOINT`: The endpoint of the Jetstream server (default: `"wss://jetstream1.us-west.bsky.network/subscribe"`). 796 + * `SPINDLE_SERVER_DEV`: A boolean indicating whether the server is running in development mode (default: `false`). 797 + * `SPINDLE_SERVER_OWNER`: The DID of the owner (required). 798 + * `SPINDLE_PIPELINES_NIXERY`: The Nixery URL (default: `"nixery.tangled.sh"`). 799 + * `SPINDLE_PIPELINES_WORKFLOW_TIMEOUT`: The default workflow timeout (default: `"5m"`). 800 + * `SPINDLE_PIPELINES_LOG_DIR`: The directory to store workflow logs (default: `"/var/log/spindle"`). 801 + 802 + ### Running spindle 803 + 804 + 1. **Set the environment variables.** For example: 805 + 806 + ```shell 807 + export SPINDLE_SERVER_HOSTNAME="your-hostname" 808 + export SPINDLE_SERVER_OWNER="your-did" 809 + ``` 810 + 811 + 2. **Build the Spindle binary.** 812 + 813 + ```shell 814 + cd core 815 + go mod download 816 + go build -o cmd/spindle/spindle cmd/spindle/main.go 817 + ``` 818 + 819 + 3. **Create the log directory.** 820 + 821 + ```shell 822 + sudo mkdir -p /var/log/spindle 823 + sudo chown $USER:$USER -R /var/log/spindle 824 + ``` 825 + 826 + 4. **Run the Spindle binary.** 827 + 828 + ```shell 829 + ./cmd/spindle/spindle 830 + ``` 831 + 832 + Spindle will now start, connect to the Jetstream server, and begin processing pipelines. 833 + 834 + ## Architecture 835 + 836 + Spindle is a small CI runner service. Here's a high-level overview of how it operates: 837 + 838 + * Listens for [`sh.tangled.spindle.member`](/lexicons/spindle/member.json) and 839 + [`sh.tangled.repo`](/lexicons/repo.json) records on the Jetstream. 840 + * When a new repo record comes through (typically when you add a spindle to a 841 + repo from the settings), spindle then resolves the underlying knot and 842 + subscribes to repo events (see: 843 + [`sh.tangled.pipeline`](/lexicons/pipeline.json)). 844 + * The spindle engine then handles execution of the pipeline, with results and 845 + logs beamed on the spindle event stream over WebSocket 846 + 847 + ### The engine 848 + 849 + At present, the only supported backend is Docker (and Podman, if Docker 850 + compatibility is enabled, so that `/run/docker.sock` is created). spindle 851 + executes each step in the pipeline in a fresh container, with state persisted 852 + across steps within the `/tangled/workspace` directory. 853 + 854 + The base image for the container is constructed on the fly using 855 + [Nixery](https://nixery.dev), which is handy for caching layers for frequently 856 + used packages. 857 + 858 + The pipeline manifest is [specified here](https://docs.tangled.org/spindles.html#pipelines). 859 + 860 + ## Secrets with openbao 861 + 862 + This document covers setting up spindle to use OpenBao for secrets 863 + management via OpenBao Proxy instead of the default SQLite backend. 864 + 865 + ### Overview 866 + 867 + Spindle now uses OpenBao Proxy for secrets management. The proxy handles 868 + authentication automatically using AppRole credentials, while spindle 869 + connects to the local proxy instead of directly to the OpenBao server. 870 + 871 + This approach provides better security, automatic token renewal, and 872 + simplified application code. 873 + 874 + ### Installation 875 + 876 + Install OpenBao from Nixpkgs: 877 + 878 + ```bash 879 + nix shell nixpkgs#openbao # for a local server 880 + ``` 881 + 882 + ### Setup 883 + 884 + The setup process can is documented for both local development and production. 885 + 886 + #### Local development 887 + 888 + Start OpenBao in dev mode: 889 + 890 + ```bash 891 + bao server -dev -dev-root-token-id="root" -dev-listen-address=127.0.0.1:8201 892 + ``` 893 + 894 + This starts OpenBao on `http://localhost:8201` with a root token. 895 + 896 + Set up environment for bao CLI: 897 + 898 + ```bash 899 + export BAO_ADDR=http://localhost:8200 900 + export BAO_TOKEN=root 901 + ``` 902 + 903 + #### Production 904 + 905 + You would typically use a systemd service with a 906 + configuration file. Refer to 907 + [@tangled.org/infra](https://tangled.org/@tangled.org/infra) 908 + for how this can be achieved using Nix. 909 + 910 + Then, initialize the bao server: 911 + 912 + ```bash 913 + bao operator init -key-shares=1 -key-threshold=1 914 + ``` 915 + 916 + This will print out an unseal key and a root key. Save them 917 + somewhere (like a password manager). Then unseal the vault 918 + to begin setting it up: 919 + 920 + ```bash 921 + bao operator unseal <unseal_key> 922 + ``` 923 + 924 + All steps below remain the same across both dev and 925 + production setups. 926 + 927 + #### Configure openbao server 928 + 929 + Create the spindle KV mount: 930 + 931 + ```bash 932 + bao secrets enable -path=spindle -version=2 kv 933 + ``` 934 + 935 + Set up AppRole authentication and policy: 936 + 937 + Create a policy file `spindle-policy.hcl`: 938 + 939 + ```hcl 940 + # Full access to spindle KV v2 data 941 + path "spindle/data/*" { 942 + capabilities = ["create", "read", "update", "delete"] 943 + } 944 + 945 + # Access to metadata for listing and management 946 + path "spindle/metadata/*" { 947 + capabilities = ["list", "read", "delete", "update"] 948 + } 949 + 950 + # Allow listing at root level 951 + path "spindle/" { 952 + capabilities = ["list"] 953 + } 954 + 955 + # Required for connection testing and health checks 956 + path "auth/token/lookup-self" { 957 + capabilities = ["read"] 958 + } 959 + ``` 960 + 961 + Apply the policy and create an AppRole: 962 + 963 + ```bash 964 + bao policy write spindle-policy spindle-policy.hcl 965 + bao auth enable approle 966 + bao write auth/approle/role/spindle \ 967 + token_policies="spindle-policy" \ 968 + token_ttl=1h \ 969 + token_max_ttl=4h \ 970 + bind_secret_id=true \ 971 + secret_id_ttl=0 \ 972 + secret_id_num_uses=0 973 + ``` 974 + 975 + Get the credentials: 976 + 977 + ```bash 978 + # Get role ID (static) 979 + ROLE_ID=$(bao read -field=role_id auth/approle/role/spindle/role-id) 980 + 981 + # Generate secret ID 982 + SECRET_ID=$(bao write -f -field=secret_id auth/approle/role/spindle/secret-id) 983 + 984 + echo "Role ID: $ROLE_ID" 985 + echo "Secret ID: $SECRET_ID" 986 + ``` 987 + 988 + #### Create proxy configuration 989 + 990 + Create the credential files: 991 + 992 + ```bash 993 + # Create directory for OpenBao files 994 + mkdir -p /tmp/openbao 995 + 996 + # Save credentials 997 + echo "$ROLE_ID" > /tmp/openbao/role-id 998 + echo "$SECRET_ID" > /tmp/openbao/secret-id 999 + chmod 600 /tmp/openbao/role-id /tmp/openbao/secret-id 1000 + ``` 1001 + 1002 + Create a proxy configuration file `/tmp/openbao/proxy.hcl`: 1003 + 1004 + ```hcl 1005 + # OpenBao server connection 1006 + vault { 1007 + address = "http://localhost:8200" 1008 + } 1009 + 1010 + # Auto-Auth using AppRole 1011 + auto_auth { 1012 + method "approle" { 1013 + mount_path = "auth/approle" 1014 + config = { 1015 + role_id_file_path = "/tmp/openbao/role-id" 1016 + secret_id_file_path = "/tmp/openbao/secret-id" 1017 + } 1018 + } 1019 + 1020 + # Optional: write token to file for debugging 1021 + sink "file" { 1022 + config = { 1023 + path = "/tmp/openbao/token" 1024 + mode = 0640 1025 + } 1026 + } 1027 + } 1028 + 1029 + # Proxy listener for spindle 1030 + listener "tcp" { 1031 + address = "127.0.0.1:8201" 1032 + tls_disable = true 1033 + } 1034 + 1035 + # Enable API proxy with auto-auth token 1036 + api_proxy { 1037 + use_auto_auth_token = true 1038 + } 1039 + 1040 + # Enable response caching 1041 + cache { 1042 + use_auto_auth_token = true 1043 + } 1044 + 1045 + # Logging 1046 + log_level = "info" 1047 + ``` 1048 + 1049 + #### Start the proxy 1050 + 1051 + Start OpenBao Proxy: 1052 + 1053 + ```bash 1054 + bao proxy -config=/tmp/openbao/proxy.hcl 1055 + ``` 1056 + 1057 + The proxy will authenticate with OpenBao and start listening on 1058 + `127.0.0.1:8201`. 1059 + 1060 + #### Configure spindle 1061 + 1062 + Set these environment variables for spindle: 1063 + 1064 + ```bash 1065 + export SPINDLE_SERVER_SECRETS_PROVIDER=openbao 1066 + export SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=http://127.0.0.1:8201 1067 + export SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=spindle 1068 + ``` 1069 + 1070 + On startup, spindle will now connect to the local proxy, 1071 + which handles all authentication automatically. 1072 + 1073 + ### Production setup for proxy 1074 + 1075 + For production, you'll want to run the proxy as a service: 1076 + 1077 + Place your production configuration in 1078 + `/etc/openbao/proxy.hcl` with proper TLS settings for the 1079 + vault connection. 1080 + 1081 + ### Verifying setup 1082 + 1083 + Test the proxy directly: 1084 + 1085 + ```bash 1086 + # Check proxy health 1087 + curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/sys/health 1088 + 1089 + # Test token lookup through proxy 1090 + curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/auth/token/lookup-self 1091 + ``` 1092 + 1093 + Test OpenBao operations through the server: 1094 + 1095 + ```bash 1096 + # List all secrets 1097 + bao kv list spindle/ 1098 + 1099 + # Add a test secret via the spindle API, then check it exists 1100 + bao kv list spindle/repos/ 1101 + 1102 + # Get a specific secret 1103 + bao kv get spindle/repos/your_repo_path/SECRET_NAME 1104 + ``` 1105 + 1106 + ### How it works 1107 + 1108 + - Spindle connects to OpenBao Proxy on localhost (typically 1109 + port 8200 or 8201) 1110 + - The proxy authenticates with OpenBao using AppRole 1111 + credentials 1112 + - All spindle requests go through the proxy, which injects 1113 + authentication tokens 1114 + - Secrets are stored at 1115 + `spindle/repos/{sanitized_repo_path}/{secret_key}` 1116 + - Repository paths like `did:plc:alice/myrepo` become 1117 + `did_plc_alice_myrepo` 1118 + - The proxy handles all token renewal automatically 1119 + - Spindle no longer manages tokens or authentication 1120 + directly 1121 + 1122 + ### Troubleshooting 1123 + 1124 + **Connection refused**: Check that the OpenBao Proxy is 1125 + running and listening on the configured address. 1126 + 1127 + **403 errors**: Verify the AppRole credentials are correct 1128 + and the policy has the necessary permissions. 1129 + 1130 + **404 route errors**: The spindle KV mount probably doesn't 1131 + existโ€”run the mount creation step again. 1132 + 1133 + **Proxy authentication failures**: Check the proxy logs and 1134 + verify the role-id and secret-id files are readable and 1135 + contain valid credentials. 1136 + 1137 + **Secret not found after writing**: This can indicate policy 1138 + permission issues. Verify the policy includes both 1139 + `spindle/data/*` and `spindle/metadata/*` paths with 1140 + appropriate capabilities. 1141 + 1142 + Check proxy logs: 1143 + 1144 + ```bash 1145 + # If running as systemd service 1146 + journalctl -u openbao-proxy -f 1147 + 1148 + # If running directly, check the console output 1149 + ``` 1150 + 1151 + Test AppRole authentication manually: 1152 + 1153 + ```bash 1154 + bao write auth/approle/login \ 1155 + role_id="$(cat /tmp/openbao/role-id)" \ 1156 + secret_id="$(cat /tmp/openbao/secret-id)" 1157 + ``` 1158 + 1159 + # Migrating knots and spindles 1160 + 1161 + Sometimes, non-backwards compatible changes are made to the 1162 + knot/spindle XRPC APIs. If you host a knot or a spindle, you 1163 + will need to follow this guide to upgrade. Typically, this 1164 + only requires you to deploy the newest version. 1165 + 1166 + This document is laid out in reverse-chronological order. 1167 + Newer migration guides are listed first, and older guides 1168 + are further down the page. 1169 + 1170 + ## Upgrading from v1.8.x 1171 + 1172 + After v1.8.2, the HTTP API for knots and spindles has been 1173 + deprecated and replaced with XRPC. Repositories on outdated 1174 + knots will not be viewable from the appview. Upgrading is 1175 + straightforward however. 1176 + 1177 + For knots: 1178 + 1179 + - Upgrade to the latest tag (v1.9.0 or above) 1180 + - Head to the [knot dashboard](https://tangled.org/settings/knots) and 1181 + hit the "retry" button to verify your knot 1182 + 1183 + For spindles: 1184 + 1185 + - Upgrade to the latest tag (v1.9.0 or above) 1186 + - Head to the [spindle 1187 + dashboard](https://tangled.org/settings/spindles) and hit the 1188 + "retry" button to verify your spindle 1189 + 1190 + ## Upgrading from v1.7.x 1191 + 1192 + After v1.7.0, knot secrets have been deprecated. You no 1193 + longer need a secret from the appview to run a knot. All 1194 + authorized commands to knots are managed via [Inter-Service 1195 + Authentication](https://atproto.com/specs/xrpc#inter-service-authentication-jwt). 1196 + Knots will be read-only until upgraded. 1197 + 1198 + Upgrading is quite easy, in essence: 1199 + 1200 + - `KNOT_SERVER_SECRET` is no more, you can remove this 1201 + environment variable entirely 1202 + - `KNOT_SERVER_OWNER` is now required on boot, set this to 1203 + your DID. You can find your DID in the 1204 + [settings](https://tangled.org/settings) page. 1205 + - Restart your knot once you have replaced the environment 1206 + variable 1207 + - Head to the [knot dashboard](https://tangled.org/settings/knots) and 1208 + hit the "retry" button to verify your knot. This simply 1209 + writes a `sh.tangled.knot` record to your PDS. 1210 + 1211 + If you use the nix module, simply bump the flake to the 1212 + latest revision, and change your config block like so: 1213 + 1214 + ```diff 1215 + services.tangled.knot = { 1216 + enable = true; 1217 + server = { 1218 + - secretFile = /path/to/secret; 1219 + + owner = "did:plc:foo"; 1220 + }; 1221 + }; 1222 + ``` 1223 + 1224 + # Hacking on Tangled 1225 + 1226 + We highly recommend [installing 1227 + Nix](https://nixos.org/download/) (the package manager) 1228 + before working on the codebase. The Nix flake provides a lot 1229 + of helpers to get started and most importantly, builds and 1230 + dev shells are entirely deterministic. 1231 + 1232 + To set up your dev environment: 1233 + 1234 + ```bash 1235 + nix develop 1236 + ``` 1237 + 1238 + Non-Nix users can look at the `devShell` attribute in the 1239 + `flake.nix` file to determine necessary dependencies. 1240 + 1241 + ## Running the appview 1242 + 1243 + The Nix flake also exposes a few `app` attributes (run `nix 1244 + flake show` to see a full list of what the flake provides), 1245 + one of the apps runs the appview with the `air` 1246 + live-reloader: 1247 + 1248 + ```bash 1249 + TANGLED_DEV=true nix run .#watch-appview 1250 + 1251 + # TANGLED_DB_PATH might be of interest to point to 1252 + # different sqlite DBs 1253 + 1254 + # in a separate shell, you can live-reload tailwind 1255 + nix run .#watch-tailwind 1256 + ``` 1257 + 1258 + To authenticate with the appview, you will need Redis and 1259 + OAuth JWKs to be set up: 1260 + 1261 + ``` 1262 + # OAuth JWKs should already be set up by the Nix devshell: 1263 + echo $TANGLED_OAUTH_CLIENT_SECRET 1264 + z42ty4RT1ovnTopY8B8ekz9NuziF2CuMkZ7rbRFpAR9jBqMc 1265 + 1266 + echo $TANGLED_OAUTH_CLIENT_KID 1267 + 1761667908 1268 + 1269 + # if not, you can set it up yourself: 1270 + goat key generate -t P-256 1271 + Key Type: P-256 / secp256r1 / ES256 private key 1272 + Secret Key (Multibase Syntax): save this securely (eg, add to password manager) 1273 + z42tuPDKRfM2mz2Kv953ARen2jmrPA8S9LX9tRq4RVcUMwwL 1274 + Public Key (DID Key Syntax): share or publish this (eg, in DID document) 1275 + did:key:zDnaeUBxtG6Xuv3ATJE4GaWeyXM3jyamJsZw3bSPpxx4bNXDR 1276 + 1277 + # the secret key from above 1278 + export TANGLED_OAUTH_CLIENT_SECRET="z42tuP..." 1279 + 1280 + # Run Redis in a new shell to store OAuth sessions 1281 + redis-server 1282 + ``` 1283 + 1284 + ## Running knots and spindles 1285 + 1286 + An end-to-end knot setup requires setting up a machine with 1287 + `sshd`, `AuthorizedKeysCommand`, and a Git user, which is 1288 + quite cumbersome. So the Nix flake provides a 1289 + `nixosConfiguration` to do so. 1290 + 1291 + <details> 1292 + <summary><strong>macOS users will have to set up a Nix Builder first</strong></summary> 1293 + 1294 + In order to build Tangled's dev VM on macOS, you will 1295 + first need to set up a Linux Nix builder. The recommended 1296 + way to do so is to run a [`darwin.linux-builder` 1297 + VM](https://nixos.org/manual/nixpkgs/unstable/#sec-darwin-builder) 1298 + and to register it in `nix.conf` as a builder for Linux 1299 + with the same architecture as your Mac (`linux-aarch64` if 1300 + you are using Apple Silicon). 1301 + 1302 + > IMPORTANT: You must build `darwin.linux-builder` somewhere other than inside 1303 + > the Tangled repo so that it doesn't conflict with the other VM. For example, 1304 + > you can do 1305 + > 1306 + > ```shell 1307 + > cd $(mktemp -d buildervm.XXXXX) && nix run nixpkgs#darwin.linux-builder 1308 + > ``` 1309 + > 1310 + > to store the builder VM in a temporary dir. 1311 + > 1312 + > You should read and follow [all the other intructions][darwin builder vm] to 1313 + > avoid subtle problems. 1314 + 1315 + Alternatively, you can use any other method to set up a 1316 + Linux machine with Nix installed that you can `sudo ssh` 1317 + into (in other words, root user on your Mac has to be able 1318 + to ssh into the Linux machine without entering a password) 1319 + and that has the same architecture as your Mac. See 1320 + [remote builder 1321 + instructions](https://nix.dev/manual/nix/2.28/advanced-topics/distributed-builds.html#requirements) 1322 + for how to register such a builder in `nix.conf`. 1323 + 1324 + > WARNING: If you'd like to use 1325 + > [`nixos-lima`](https://github.com/nixos-lima/nixos-lima) or 1326 + > [Orbstack](https://orbstack.dev/), note that setting them up so that `sudo 1327 + > ssh` works can be tricky. It seems to be [possible with 1328 + > Orbstack](https://github.com/orgs/orbstack/discussions/1669). 1329 + 1330 + </details> 1331 + 1332 + To begin, grab your DID from http://localhost:3000/settings. 1333 + Then, set `TANGLED_VM_KNOT_OWNER` and 1334 + `TANGLED_VM_SPINDLE_OWNER` to your DID. You can now start a 1335 + lightweight NixOS VM like so: 1336 + 1337 + ```bash 1338 + nix run --impure .#vm 1339 + 1340 + # type `poweroff` at the shell to exit the VM 1341 + ``` 1342 + 1343 + This starts a knot on port 6444, a spindle on port 6555 1344 + with `ssh` exposed on port 2222. 1345 + 1346 + Once the services are running, head to 1347 + http://localhost:3000/settings/knots and hit "Verify". It should 1348 + verify the ownership of the services instantly if everything 1349 + went smoothly. 1350 + 1351 + You can push repositories to this VM with this ssh config 1352 + block on your main machine: 1353 + 1354 + ```bash 1355 + Host nixos-shell 1356 + Hostname localhost 1357 + Port 2222 1358 + User git 1359 + IdentityFile ~/.ssh/my_tangled_key 1360 + ``` 1361 + 1362 + Set up a remote called `local-dev` on a git repo: 1363 + 1364 + ```bash 1365 + git remote add local-dev git@nixos-shell:user/repo 1366 + git push local-dev main 1367 + ``` 1368 + 1369 + The above VM should already be running a spindle on 1370 + `localhost:6555`. Head to http://localhost:3000/settings/spindles and 1371 + hit "Verify". You can then configure each repository to use 1372 + this spindle and run CI jobs. 1373 + 1374 + Of interest when debugging spindles: 1375 + 1376 + ``` 1377 + # Service logs from journald: 1378 + journalctl -xeu spindle 1379 + 1380 + # CI job logs from disk: 1381 + ls /var/log/spindle 1382 + 1383 + # Debugging spindle database: 1384 + sqlite3 /var/lib/spindle/spindle.db 1385 + 1386 + # litecli has a nicer REPL interface: 1387 + litecli /var/lib/spindle/spindle.db 1388 + ``` 1389 + 1390 + If for any reason you wish to disable either one of the 1391 + services in the VM, modify [nix/vm.nix](/nix/vm.nix) and set 1392 + `services.tangled.spindle.enable` (or 1393 + `services.tangled.knot.enable`) to `false`. 1394 + 1395 + # Contribution guide 1396 + 1397 + ## Commit guidelines 1398 + 1399 + We follow a commit style similar to the Go project. Please keep commits: 1400 + 1401 + * **atomic**: each commit should represent one logical change 1402 + * **descriptive**: the commit message should clearly describe what the 1403 + change does and why it's needed 1404 + 1405 + ### Message format 1406 + 1407 + ``` 1408 + <service/top-level directory>/<affected package/directory>: <short summary of change> 1409 + 1410 + Optional longer description can go here, if necessary. Explain what the 1411 + change does and why, especially if not obvious. Reference relevant 1412 + issues or PRs when applicable. These can be links for now since we don't 1413 + auto-link issues/PRs yet. 1414 + ``` 1415 + 1416 + Here are some examples: 1417 + 1418 + ``` 1419 + appview/state: fix token expiry check in middleware 1420 + 1421 + The previous check did not account for clock drift, leading to premature 1422 + token invalidation. 1423 + ``` 1424 + 1425 + ``` 1426 + knotserver/git/service: improve error checking in upload-pack 1427 + ``` 1428 + 1429 + 1430 + ### General notes 1431 + 1432 + - PRs get merged "as-is" (fast-forward)โ€”like applying a patch-series 1433 + using `git am`. At present, there is no squashingโ€”so please author 1434 + your commits as they would appear on `master`, following the above 1435 + guidelines. 1436 + - If there is a lot of nesting, for example "appview: 1437 + pages/templates/repo/fragments: ...", these can be truncated down to 1438 + just "appview: repo/fragments: ...". If the change affects a lot of 1439 + subdirectories, you may abbreviate to just the top-level names, e.g. 1440 + "appview: ..." or "knotserver: ...". 1441 + - Keep commits lowercased with no trailing period. 1442 + - Use the imperative mood in the summary line (e.g., "fix bug" not 1443 + "fixed bug" or "fixes bug"). 1444 + - Try to keep the summary line under 72 characters, but we aren't too 1445 + fussed about this. 1446 + - Follow the same formatting for PR titles if filled manually. 1447 + - Don't include unrelated changes in the same commit. 1448 + - Avoid noisy commit messages like "wip" or "final fix"โ€”rewrite history 1449 + before submitting if necessary. 1450 + 1451 + ## Code formatting 1452 + 1453 + We use a variety of tools to format our code, and multiplex them with 1454 + [`treefmt`](https://treefmt.com). All you need to do to format your changes 1455 + is run `nix run .#fmt` (or just `treefmt` if you're in the devshell). 1456 + 1457 + ## Proposals for bigger changes 1458 + 1459 + Small fixes like typos, minor bugs, or trivial refactors can be 1460 + submitted directly as PRs. 1461 + 1462 + For larger changesโ€”especially those introducing new features, significant 1463 + refactoring, or altering system behaviorโ€”please open a proposal first. This 1464 + helps us evaluate the scope, design, and potential impact before implementation. 1465 + 1466 + Create a new issue titled: 1467 + 1468 + ``` 1469 + proposal: <affected scope>: <summary of change> 1470 + ``` 1471 + 1472 + In the description, explain: 1473 + 1474 + - What the change is 1475 + - Why it's needed 1476 + - How you plan to implement it (roughly) 1477 + - Any open questions or tradeoffs 1478 + 1479 + We'll use the issue thread to discuss and refine the idea before moving 1480 + forward. 1481 + 1482 + ## Developer Certificate of Origin (DCO) 1483 + 1484 + We require all contributors to certify that they have the right to 1485 + submit the code they're contributing. To do this, we follow the 1486 + [Developer Certificate of Origin 1487 + (DCO)](https://developercertificate.org/). 1488 + 1489 + By signing your commits, you're stating that the contribution is your 1490 + own work, or that you have the right to submit it under the project's 1491 + license. This helps us keep things clean and legally sound. 1492 + 1493 + To sign your commit, just add the `-s` flag when committing: 1494 + 1495 + ```sh 1496 + git commit -s -m "your commit message" 1497 + ``` 1498 + 1499 + This appends a line like: 1500 + 1501 + ``` 1502 + Signed-off-by: Your Name <your.email@example.com> 1503 + ``` 1504 + 1505 + We won't merge commits if they aren't signed off. If you forget, you can 1506 + amend the last commit like this: 1507 + 1508 + ```sh 1509 + git commit --amend -s 1510 + ``` 1511 + 1512 + If you're submitting a PR with multiple commits, make sure each one is 1513 + signed. 1514 + 1515 + For [jj](https://jj-vcs.github.io/jj/latest/) users, you can run the following command 1516 + to make it sign off commits in the tangled repo: 1517 + 1518 + ```shell 1519 + # Safety check, should say "No matching config key..." 1520 + jj config list templates.commit_trailers 1521 + # The command below may need to be adjusted if the command above returned something. 1522 + jj config set --repo templates.commit_trailers "format_signed_off_by_trailer(self)" 1523 + ``` 1524 + 1525 + Refer to the [jujutsu 1526 + documentation](https://jj-vcs.github.io/jj/latest/config/#commit-trailers) 1527 + for more information.
-136
docs/contributing.md
··· 1 - # tangled contributing guide 2 - 3 - ## commit guidelines 4 - 5 - We follow a commit style similar to the Go project. Please keep commits: 6 - 7 - * **atomic**: each commit should represent one logical change 8 - * **descriptive**: the commit message should clearly describe what the 9 - change does and why it's needed 10 - 11 - ### message format 12 - 13 - ``` 14 - <service/top-level directory>/<affected package/directory>: <short summary of change> 15 - 16 - 17 - Optional longer description can go here, if necessary. Explain what the 18 - change does and why, especially if not obvious. Reference relevant 19 - issues or PRs when applicable. These can be links for now since we don't 20 - auto-link issues/PRs yet. 21 - ``` 22 - 23 - Here are some examples: 24 - 25 - ``` 26 - appview/state: fix token expiry check in middleware 27 - 28 - The previous check did not account for clock drift, leading to premature 29 - token invalidation. 30 - ``` 31 - 32 - ``` 33 - knotserver/git/service: improve error checking in upload-pack 34 - ``` 35 - 36 - 37 - ### general notes 38 - 39 - - PRs get merged "as-is" (fast-forward) -- like applying a patch-series 40 - using `git am`. At present, there is no squashing -- so please author 41 - your commits as they would appear on `master`, following the above 42 - guidelines. 43 - - If there is a lot of nesting, for example "appview: 44 - pages/templates/repo/fragments: ...", these can be truncated down to 45 - just "appview: repo/fragments: ...". If the change affects a lot of 46 - subdirectories, you may abbreviate to just the top-level names, e.g. 47 - "appview: ..." or "knotserver: ...". 48 - - Keep commits lowercased with no trailing period. 49 - - Use the imperative mood in the summary line (e.g., "fix bug" not 50 - "fixed bug" or "fixes bug"). 51 - - Try to keep the summary line under 72 characters, but we aren't too 52 - fussed about this. 53 - - Follow the same formatting for PR titles if filled manually. 54 - - Don't include unrelated changes in the same commit. 55 - - Avoid noisy commit messages like "wip" or "final fix"โ€”rewrite history 56 - before submitting if necessary. 57 - 58 - ## code formatting 59 - 60 - We use a variety of tools to format our code, and multiplex them with 61 - [`treefmt`](https://treefmt.com): all you need to do to format your changes 62 - is run `nix run .#fmt` (or just `treefmt` if you're in the devshell). 63 - 64 - ## proposals for bigger changes 65 - 66 - Small fixes like typos, minor bugs, or trivial refactors can be 67 - submitted directly as PRs. 68 - 69 - For larger changesโ€”especially those introducing new features, significant 70 - refactoring, or altering system behaviorโ€”please open a proposal first. This 71 - helps us evaluate the scope, design, and potential impact before implementation. 72 - 73 - ### proposal format 74 - 75 - Create a new issue titled: 76 - 77 - ``` 78 - proposal: <affected scope>: <summary of change> 79 - ``` 80 - 81 - In the description, explain: 82 - 83 - - What the change is 84 - - Why it's needed 85 - - How you plan to implement it (roughly) 86 - - Any open questions or tradeoffs 87 - 88 - We'll use the issue thread to discuss and refine the idea before moving 89 - forward. 90 - 91 - ## developer certificate of origin (DCO) 92 - 93 - We require all contributors to certify that they have the right to 94 - submit the code they're contributing. To do this, we follow the 95 - [Developer Certificate of Origin 96 - (DCO)](https://developercertificate.org/). 97 - 98 - By signing your commits, you're stating that the contribution is your 99 - own work, or that you have the right to submit it under the project's 100 - license. This helps us keep things clean and legally sound. 101 - 102 - To sign your commit, just add the `-s` flag when committing: 103 - 104 - ```sh 105 - git commit -s -m "your commit message" 106 - ``` 107 - 108 - This appends a line like: 109 - 110 - ``` 111 - Signed-off-by: Your Name <your.email@example.com> 112 - ``` 113 - 114 - We won't merge commits if they aren't signed off. If you forget, you can 115 - amend the last commit like this: 116 - 117 - ```sh 118 - git commit --amend -s 119 - ``` 120 - 121 - If you're submitting a PR with multiple commits, make sure each one is 122 - signed. 123 - 124 - For [jj](https://jj-vcs.github.io/jj/latest/) users, you can run the following command 125 - to make it sign off commits in the tangled repo: 126 - 127 - ```shell 128 - # Safety check, should say "No matching config key..." 129 - jj config list templates.commit_trailers 130 - # The command below may need to be adjusted if the command above returned something. 131 - jj config set --repo templates.commit_trailers "format_signed_off_by_trailer(self)" 132 - ``` 133 - 134 - Refer to the [jj 135 - documentation](https://jj-vcs.github.io/jj/latest/config/#commit-trailers) 136 - for more information.
···
-172
docs/hacking.md
··· 1 - # hacking on tangled 2 - 3 - We highly recommend [installing 4 - nix](https://nixos.org/download/) (the package manager) 5 - before working on the codebase. The nix flake provides a lot 6 - of helpers to get started and most importantly, builds and 7 - dev shells are entirely deterministic. 8 - 9 - To set up your dev environment: 10 - 11 - ```bash 12 - nix develop 13 - ``` 14 - 15 - Non-nix users can look at the `devShell` attribute in the 16 - `flake.nix` file to determine necessary dependencies. 17 - 18 - ## running the appview 19 - 20 - The nix flake also exposes a few `app` attributes (run `nix 21 - flake show` to see a full list of what the flake provides), 22 - one of the apps runs the appview with the `air` 23 - live-reloader: 24 - 25 - ```bash 26 - TANGLED_DEV=true nix run .#watch-appview 27 - 28 - # TANGLED_DB_PATH might be of interest to point to 29 - # different sqlite DBs 30 - 31 - # in a separate shell, you can live-reload tailwind 32 - nix run .#watch-tailwind 33 - ``` 34 - 35 - To authenticate with the appview, you will need redis and 36 - OAUTH JWKs to be setup: 37 - 38 - ``` 39 - # oauth jwks should already be setup by the nix devshell: 40 - echo $TANGLED_OAUTH_CLIENT_SECRET 41 - z42ty4RT1ovnTopY8B8ekz9NuziF2CuMkZ7rbRFpAR9jBqMc 42 - 43 - echo $TANGLED_OAUTH_CLIENT_KID 44 - 1761667908 45 - 46 - # if not, you can set it up yourself: 47 - goat key generate -t P-256 48 - Key Type: P-256 / secp256r1 / ES256 private key 49 - Secret Key (Multibase Syntax): save this securely (eg, add to password manager) 50 - z42tuPDKRfM2mz2Kv953ARen2jmrPA8S9LX9tRq4RVcUMwwL 51 - Public Key (DID Key Syntax): share or publish this (eg, in DID document) 52 - did:key:zDnaeUBxtG6Xuv3ATJE4GaWeyXM3jyamJsZw3bSPpxx4bNXDR 53 - 54 - # the secret key from above 55 - export TANGLED_OAUTH_CLIENT_SECRET="z42tuP..." 56 - 57 - # run redis in at a new shell to store oauth sessions 58 - redis-server 59 - ``` 60 - 61 - ## running knots and spindles 62 - 63 - An end-to-end knot setup requires setting up a machine with 64 - `sshd`, `AuthorizedKeysCommand`, and git user, which is 65 - quite cumbersome. So the nix flake provides a 66 - `nixosConfiguration` to do so. 67 - 68 - <details> 69 - <summary><strong>MacOS users will have to setup a Nix Builder first</strong></summary> 70 - 71 - In order to build Tangled's dev VM on macOS, you will 72 - first need to set up a Linux Nix builder. The recommended 73 - way to do so is to run a [`darwin.linux-builder` 74 - VM](https://nixos.org/manual/nixpkgs/unstable/#sec-darwin-builder) 75 - and to register it in `nix.conf` as a builder for Linux 76 - with the same architecture as your Mac (`linux-aarch64` if 77 - you are using Apple Silicon). 78 - 79 - > IMPORTANT: You must build `darwin.linux-builder` somewhere other than inside 80 - > the tangled repo so that it doesn't conflict with the other VM. For example, 81 - > you can do 82 - > 83 - > ```shell 84 - > cd $(mktemp -d buildervm.XXXXX) && nix run nixpkgs#darwin.linux-builder 85 - > ``` 86 - > 87 - > to store the builder VM in a temporary dir. 88 - > 89 - > You should read and follow [all the other intructions][darwin builder vm] to 90 - > avoid subtle problems. 91 - 92 - Alternatively, you can use any other method to set up a 93 - Linux machine with `nix` installed that you can `sudo ssh` 94 - into (in other words, root user on your Mac has to be able 95 - to ssh into the Linux machine without entering a password) 96 - and that has the same architecture as your Mac. See 97 - [remote builder 98 - instructions](https://nix.dev/manual/nix/2.28/advanced-topics/distributed-builds.html#requirements) 99 - for how to register such a builder in `nix.conf`. 100 - 101 - > WARNING: If you'd like to use 102 - > [`nixos-lima`](https://github.com/nixos-lima/nixos-lima) or 103 - > [Orbstack](https://orbstack.dev/), note that setting them up so that `sudo 104 - > ssh` works can be tricky. It seems to be [possible with 105 - > Orbstack](https://github.com/orgs/orbstack/discussions/1669). 106 - 107 - </details> 108 - 109 - To begin, grab your DID from http://localhost:3000/settings. 110 - Then, set `TANGLED_VM_KNOT_OWNER` and 111 - `TANGLED_VM_SPINDLE_OWNER` to your DID. You can now start a 112 - lightweight NixOS VM like so: 113 - 114 - ```bash 115 - nix run --impure .#vm 116 - 117 - # type `poweroff` at the shell to exit the VM 118 - ``` 119 - 120 - This starts a knot on port 6000, a spindle on port 6555 121 - with `ssh` exposed on port 2222. 122 - 123 - Once the services are running, head to 124 - http://localhost:3000/knots and hit verify. It should 125 - verify the ownership of the services instantly if everything 126 - went smoothly. 127 - 128 - You can push repositories to this VM with this ssh config 129 - block on your main machine: 130 - 131 - ```bash 132 - Host nixos-shell 133 - Hostname localhost 134 - Port 2222 135 - User git 136 - IdentityFile ~/.ssh/my_tangled_key 137 - ``` 138 - 139 - Set up a remote called `local-dev` on a git repo: 140 - 141 - ```bash 142 - git remote add local-dev git@nixos-shell:user/repo 143 - git push local-dev main 144 - ``` 145 - 146 - ### running a spindle 147 - 148 - The above VM should already be running a spindle on 149 - `localhost:6555`. Head to http://localhost:3000/spindles and 150 - hit verify. You can then configure each repository to use 151 - this spindle and run CI jobs. 152 - 153 - Of interest when debugging spindles: 154 - 155 - ``` 156 - # service logs from journald: 157 - journalctl -xeu spindle 158 - 159 - # CI job logs from disk: 160 - ls /var/log/spindle 161 - 162 - # debugging spindle db: 163 - sqlite3 /var/lib/spindle/spindle.db 164 - 165 - # litecli has a nicer REPL interface: 166 - litecli /var/lib/spindle/spindle.db 167 - ``` 168 - 169 - If for any reason you wish to disable either one of the 170 - services in the VM, modify [nix/vm.nix](/nix/vm.nix) and set 171 - `services.tangled.spindle.enable` (or 172 - `services.tangled.knot.enable`) to `false`.
···
+93
docs/highlight.theme
···
··· 1 + { 2 + "text-color": null, 3 + "background-color": null, 4 + "line-number-color": null, 5 + "line-number-background-color": null, 6 + "text-styles": { 7 + "Annotation": { 8 + "text-color": null, 9 + "background-color": null, 10 + "bold": false, 11 + "italic": true, 12 + "underline": false 13 + }, 14 + "ControlFlow": { 15 + "text-color": null, 16 + "background-color": null, 17 + "bold": true, 18 + "italic": false, 19 + "underline": false 20 + }, 21 + "Error": { 22 + "text-color": null, 23 + "background-color": null, 24 + "bold": true, 25 + "italic": false, 26 + "underline": false 27 + }, 28 + "Alert": { 29 + "text-color": null, 30 + "background-color": null, 31 + "bold": true, 32 + "italic": false, 33 + "underline": false 34 + }, 35 + "Preprocessor": { 36 + "text-color": null, 37 + "background-color": null, 38 + "bold": true, 39 + "italic": false, 40 + "underline": false 41 + }, 42 + "Information": { 43 + "text-color": null, 44 + "background-color": null, 45 + "bold": false, 46 + "italic": true, 47 + "underline": false 48 + }, 49 + "Warning": { 50 + "text-color": null, 51 + "background-color": null, 52 + "bold": false, 53 + "italic": true, 54 + "underline": false 55 + }, 56 + "Documentation": { 57 + "text-color": null, 58 + "background-color": null, 59 + "bold": false, 60 + "italic": true, 61 + "underline": false 62 + }, 63 + "DataType": { 64 + "text-color": "#8f4e8b", 65 + "background-color": null, 66 + "bold": false, 67 + "italic": false, 68 + "underline": false 69 + }, 70 + "Comment": { 71 + "text-color": null, 72 + "background-color": null, 73 + "bold": false, 74 + "italic": true, 75 + "underline": false 76 + }, 77 + "CommentVar": { 78 + "text-color": null, 79 + "background-color": null, 80 + "bold": false, 81 + "italic": true, 82 + "underline": false 83 + }, 84 + "Keyword": { 85 + "text-color": null, 86 + "background-color": null, 87 + "bold": true, 88 + "italic": false, 89 + "underline": false 90 + } 91 + } 92 + } 93 +
-214
docs/knot-hosting.md
··· 1 - # knot self-hosting guide 2 - 3 - So you want to run your own knot server? Great! Here are a few prerequisites: 4 - 5 - 1. A server of some kind (a VPS, a Raspberry Pi, etc.). Preferably running a Linux distribution of some kind. 6 - 2. A (sub)domain name. People generally use `knot.example.com`. 7 - 3. A valid SSL certificate for your domain. 8 - 9 - There's a couple of ways to get started: 10 - * NixOS: refer to 11 - [flake.nix](https://tangled.sh/@tangled.sh/core/blob/master/flake.nix) 12 - * Docker: Documented at 13 - [@tangled.sh/knot-docker](https://tangled.sh/@tangled.sh/knot-docker) 14 - (community maintained: support is not guaranteed!) 15 - * Manual: Documented below. 16 - 17 - ## manual setup 18 - 19 - First, clone this repository: 20 - 21 - ``` 22 - git clone https://tangled.org/@tangled.org/core 23 - ``` 24 - 25 - Then, build the `knot` CLI. This is the knot administration and operation tool. 26 - For the purpose of this guide, we're only concerned with these subcommands: 27 - 28 - * `knot server`: the main knot server process, typically run as a 29 - supervised service 30 - * `knot guard`: handles role-based access control for git over SSH 31 - (you'll never have to run this yourself) 32 - * `knot keys`: fetches SSH keys associated with your knot; we'll use 33 - this to generate the SSH `AuthorizedKeysCommand` 34 - 35 - ``` 36 - cd core 37 - export CGO_ENABLED=1 38 - go build -o knot ./cmd/knot 39 - ``` 40 - 41 - Next, move the `knot` binary to a location owned by `root` -- 42 - `/usr/local/bin/` is a good choice. Make sure the binary itself is also owned by `root`: 43 - 44 - ``` 45 - sudo mv knot /usr/local/bin/knot 46 - sudo chown root:root /usr/local/bin/knot 47 - ``` 48 - 49 - This is necessary because SSH `AuthorizedKeysCommand` requires [really 50 - specific permissions](https://stackoverflow.com/a/27638306). The 51 - `AuthorizedKeysCommand` specifies a command that is run by `sshd` to 52 - retrieve a user's public SSH keys dynamically for authentication. Let's 53 - set that up. 54 - 55 - ``` 56 - sudo tee /etc/ssh/sshd_config.d/authorized_keys_command.conf <<EOF 57 - Match User git 58 - AuthorizedKeysCommand /usr/local/bin/knot keys -o authorized-keys 59 - AuthorizedKeysCommandUser nobody 60 - EOF 61 - ``` 62 - 63 - Then, reload `sshd`: 64 - 65 - ``` 66 - sudo systemctl reload ssh 67 - ``` 68 - 69 - Next, create the `git` user. We'll use the `git` user's home directory 70 - to store repositories: 71 - 72 - ``` 73 - sudo adduser git 74 - ``` 75 - 76 - Create `/home/git/.knot.env` with the following, updating the values as 77 - necessary. The `KNOT_SERVER_OWNER` should be set to your 78 - DID, you can find your DID in the [Settings](https://tangled.sh/settings) page. 79 - 80 - ``` 81 - KNOT_REPO_SCAN_PATH=/home/git 82 - KNOT_SERVER_HOSTNAME=knot.example.com 83 - APPVIEW_ENDPOINT=https://tangled.sh 84 - KNOT_SERVER_OWNER=did:plc:foobar 85 - KNOT_SERVER_INTERNAL_LISTEN_ADDR=127.0.0.1:5444 86 - KNOT_SERVER_LISTEN_ADDR=127.0.0.1:5555 87 - ``` 88 - 89 - If you run a Linux distribution that uses systemd, you can use the provided 90 - service file to run the server. Copy 91 - [`knotserver.service`](/systemd/knotserver.service) 92 - to `/etc/systemd/system/`. Then, run: 93 - 94 - ``` 95 - systemctl enable knotserver 96 - systemctl start knotserver 97 - ``` 98 - 99 - The last step is to configure a reverse proxy like Nginx or Caddy to front your 100 - knot. Here's an example configuration for Nginx: 101 - 102 - ``` 103 - server { 104 - listen 80; 105 - listen [::]:80; 106 - server_name knot.example.com; 107 - 108 - location / { 109 - proxy_pass http://localhost:5555; 110 - proxy_set_header Host $host; 111 - proxy_set_header X-Real-IP $remote_addr; 112 - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 113 - proxy_set_header X-Forwarded-Proto $scheme; 114 - } 115 - 116 - # wss endpoint for git events 117 - location /events { 118 - proxy_set_header X-Forwarded-For $remote_addr; 119 - proxy_set_header Host $http_host; 120 - proxy_set_header Upgrade websocket; 121 - proxy_set_header Connection Upgrade; 122 - proxy_pass http://localhost:5555; 123 - } 124 - # additional config for SSL/TLS go here. 125 - } 126 - 127 - ``` 128 - 129 - Remember to use Let's Encrypt or similar to procure a certificate for your 130 - knot domain. 131 - 132 - You should now have a running knot server! You can finalize 133 - your registration by hitting the `verify` button on the 134 - [/knots](https://tangled.org/knots) page. This simply creates 135 - a record on your PDS to announce the existence of the knot. 136 - 137 - ### custom paths 138 - 139 - (This section applies to manual setup only. Docker users should edit the mounts 140 - in `docker-compose.yml` instead.) 141 - 142 - Right now, the database and repositories of your knot lives in `/home/git`. You 143 - can move these paths if you'd like to store them in another folder. Be careful 144 - when adjusting these paths: 145 - 146 - * Stop your knot when moving data (e.g. `systemctl stop knotserver`) to prevent 147 - any possible side effects. Remember to restart it once you're done. 148 - * Make backups before moving in case something goes wrong. 149 - * Make sure the `git` user can read and write from the new paths. 150 - 151 - #### database 152 - 153 - As an example, let's say the current database is at `/home/git/knotserver.db`, 154 - and we want to move it to `/home/git/database/knotserver.db`. 155 - 156 - Copy the current database to the new location. Make sure to copy the `.db-shm` 157 - and `.db-wal` files if they exist. 158 - 159 - ``` 160 - mkdir /home/git/database 161 - cp /home/git/knotserver.db* /home/git/database 162 - ``` 163 - 164 - In the environment (e.g. `/home/git/.knot.env`), set `KNOT_SERVER_DB_PATH` to 165 - the new file path (_not_ the directory): 166 - 167 - ``` 168 - KNOT_SERVER_DB_PATH=/home/git/database/knotserver.db 169 - ``` 170 - 171 - #### repositories 172 - 173 - As an example, let's say the repositories are currently in `/home/git`, and we 174 - want to move them into `/home/git/repositories`. 175 - 176 - Create the new folder, then move the existing repositories (if there are any): 177 - 178 - ``` 179 - mkdir /home/git/repositories 180 - # move all DIDs into the new folder; these will vary for you! 181 - mv /home/git/did:plc:wshs7t2adsemcrrd4snkeqli /home/git/repositories 182 - ``` 183 - 184 - In the environment (e.g. `/home/git/.knot.env`), update `KNOT_REPO_SCAN_PATH` 185 - to the new directory: 186 - 187 - ``` 188 - KNOT_REPO_SCAN_PATH=/home/git/repositories 189 - ``` 190 - 191 - Similarly, update your `sshd` `AuthorizedKeysCommand` to use the updated 192 - repository path: 193 - 194 - ``` 195 - sudo tee /etc/ssh/sshd_config.d/authorized_keys_command.conf <<EOF 196 - Match User git 197 - AuthorizedKeysCommand /usr/local/bin/knot keys -o authorized-keys -git-dir /home/git/repositories 198 - AuthorizedKeysCommandUser nobody 199 - EOF 200 - ``` 201 - 202 - Make sure to restart your SSH server! 203 - 204 - #### MOTD (message of the day) 205 - 206 - To configure the MOTD used ("Welcome to this knot!" by default), edit the 207 - `/home/git/motd` file: 208 - 209 - ``` 210 - printf "Hi from this knot!\n" > /home/git/motd 211 - ``` 212 - 213 - Note that you should add a newline at the end if setting a non-empty message 214 - since the knot won't do this for you.
···
-59
docs/migrations.md
··· 1 - # Migrations 2 - 3 - This document is laid out in reverse-chronological order. 4 - Newer migration guides are listed first, and older guides 5 - are further down the page. 6 - 7 - ## Upgrading from v1.8.x 8 - 9 - After v1.8.2, the HTTP API for knot and spindles have been 10 - deprecated and replaced with XRPC. Repositories on outdated 11 - knots will not be viewable from the appview. Upgrading is 12 - straightforward however. 13 - 14 - For knots: 15 - 16 - - Upgrade to latest tag (v1.9.0 or above) 17 - - Head to the [knot dashboard](https://tangled.org/knots) and 18 - hit the "retry" button to verify your knot 19 - 20 - For spindles: 21 - 22 - - Upgrade to latest tag (v1.9.0 or above) 23 - - Head to the [spindle 24 - dashboard](https://tangled.org/spindles) and hit the 25 - "retry" button to verify your spindle 26 - 27 - ## Upgrading from v1.7.x 28 - 29 - After v1.7.0, knot secrets have been deprecated. You no 30 - longer need a secret from the appview to run a knot. All 31 - authorized commands to knots are managed via [Inter-Service 32 - Authentication](https://atproto.com/specs/xrpc#inter-service-authentication-jwt). 33 - Knots will be read-only until upgraded. 34 - 35 - Upgrading is quite easy, in essence: 36 - 37 - - `KNOT_SERVER_SECRET` is no more, you can remove this 38 - environment variable entirely 39 - - `KNOT_SERVER_OWNER` is now required on boot, set this to 40 - your DID. You can find your DID in the 41 - [settings](https://tangled.org/settings) page. 42 - - Restart your knot once you have replaced the environment 43 - variable 44 - - Head to the [knot dashboard](https://tangled.org/knots) and 45 - hit the "retry" button to verify your knot. This simply 46 - writes a `sh.tangled.knot` record to your PDS. 47 - 48 - If you use the nix module, simply bump the flake to the 49 - latest revision, and change your config block like so: 50 - 51 - ```diff 52 - services.tangled.knot = { 53 - enable = true; 54 - server = { 55 - - secretFile = /path/to/secret; 56 - + owner = "did:plc:foo"; 57 - }; 58 - }; 59 - ```
···
+3
docs/mode.html
···
··· 1 + <a class="px-4 py-2 mt-8 block text-center w-full rounded-sm shadow-sm border border-gray-200 dark:border-gray-700 no-underline hover:no-underline" href="$if(single-page)$/$else$/single-page.html$endif$"> 2 + $if(single-page)$View as multi-page$else$View as single-page$endif$ 3 + </a>
+7
docs/search.html
···
··· 1 + <form action="https://google.com/search" role="search" aria-label="Sitewide" class="w-full"> 2 + <input type="hidden" name="q" value="+[inurl:https://docs.tangled.org]"> 3 + <label> 4 + <span style="display:none;">Search</span> 5 + <input type="text" name="q" placeholder="Search docs ..." class="w-full font-normal"> 6 + </label> 7 + </form>
-25
docs/spindle/architecture.md
··· 1 - # spindle architecture 2 - 3 - Spindle is a small CI runner service. Here's a high level overview of how it operates: 4 - 5 - * listens for [`sh.tangled.spindle.member`](/lexicons/spindle/member.json) and 6 - [`sh.tangled.repo`](/lexicons/repo.json) records on the Jetstream. 7 - * when a new repo record comes through (typically when you add a spindle to a 8 - repo from the settings), spindle then resolves the underlying knot and 9 - subscribes to repo events (see: 10 - [`sh.tangled.pipeline`](/lexicons/pipeline.json)). 11 - * the spindle engine then handles execution of the pipeline, with results and 12 - logs beamed on the spindle event stream over wss 13 - 14 - ### the engine 15 - 16 - At present, the only supported backend is Docker (and Podman, if Docker 17 - compatibility is enabled, so that `/run/docker.sock` is created). Spindle 18 - executes each step in the pipeline in a fresh container, with state persisted 19 - across steps within the `/tangled/workspace` directory. 20 - 21 - The base image for the container is constructed on the fly using 22 - [Nixery](https://nixery.dev), which is handy for caching layers for frequently 23 - used packages. 24 - 25 - The pipeline manifest is [specified here](/docs/spindle/pipeline.md).
···
-52
docs/spindle/hosting.md
··· 1 - # spindle self-hosting guide 2 - 3 - ## prerequisites 4 - 5 - * Go 6 - * Docker (the only supported backend currently) 7 - 8 - ## configuration 9 - 10 - Spindle is configured using environment variables. The following environment variables are available: 11 - 12 - * `SPINDLE_SERVER_LISTEN_ADDR`: The address the server listens on (default: `"0.0.0.0:6555"`). 13 - * `SPINDLE_SERVER_DB_PATH`: The path to the SQLite database file (default: `"spindle.db"`). 14 - * `SPINDLE_SERVER_HOSTNAME`: The hostname of the server (required). 15 - * `SPINDLE_SERVER_JETSTREAM_ENDPOINT`: The endpoint of the Jetstream server (default: `"wss://jetstream1.us-west.bsky.network/subscribe"`). 16 - * `SPINDLE_SERVER_DEV`: A boolean indicating whether the server is running in development mode (default: `false`). 17 - * `SPINDLE_SERVER_OWNER`: The DID of the owner (required). 18 - * `SPINDLE_PIPELINES_NIXERY`: The Nixery URL (default: `"nixery.tangled.sh"`). 19 - * `SPINDLE_PIPELINES_WORKFLOW_TIMEOUT`: The default workflow timeout (default: `"5m"`). 20 - * `SPINDLE_PIPELINES_LOG_DIR`: The directory to store workflow logs (default: `"/var/log/spindle"`). 21 - 22 - ## running spindle 23 - 24 - 1. **Set the environment variables.** For example: 25 - 26 - ```shell 27 - export SPINDLE_SERVER_HOSTNAME="your-hostname" 28 - export SPINDLE_SERVER_OWNER="your-did" 29 - ``` 30 - 31 - 2. **Build the Spindle binary.** 32 - 33 - ```shell 34 - cd core 35 - go mod download 36 - go build -o cmd/spindle/spindle cmd/spindle/main.go 37 - ``` 38 - 39 - 3. **Create the log directory.** 40 - 41 - ```shell 42 - sudo mkdir -p /var/log/spindle 43 - sudo chown $USER:$USER -R /var/log/spindle 44 - ``` 45 - 46 - 4. **Run the Spindle binary.** 47 - 48 - ```shell 49 - ./cmd/spindle/spindle 50 - ``` 51 - 52 - Spindle will now start, connect to the Jetstream server, and begin processing pipelines.
···
-285
docs/spindle/openbao.md
··· 1 - # spindle secrets with openbao 2 - 3 - This document covers setting up Spindle to use OpenBao for secrets 4 - management via OpenBao Proxy instead of the default SQLite backend. 5 - 6 - ## overview 7 - 8 - Spindle now uses OpenBao Proxy for secrets management. The proxy handles 9 - authentication automatically using AppRole credentials, while Spindle 10 - connects to the local proxy instead of directly to the OpenBao server. 11 - 12 - This approach provides better security, automatic token renewal, and 13 - simplified application code. 14 - 15 - ## installation 16 - 17 - Install OpenBao from nixpkgs: 18 - 19 - ```bash 20 - nix shell nixpkgs#openbao # for a local server 21 - ``` 22 - 23 - ## setup 24 - 25 - The setup process can is documented for both local development and production. 26 - 27 - ### local development 28 - 29 - Start OpenBao in dev mode: 30 - 31 - ```bash 32 - bao server -dev -dev-root-token-id="root" -dev-listen-address=127.0.0.1:8201 33 - ``` 34 - 35 - This starts OpenBao on `http://localhost:8201` with a root token. 36 - 37 - Set up environment for bao CLI: 38 - 39 - ```bash 40 - export BAO_ADDR=http://localhost:8200 41 - export BAO_TOKEN=root 42 - ``` 43 - 44 - ### production 45 - 46 - You would typically use a systemd service with a configuration file. Refer to 47 - [@tangled.org/infra](https://tangled.org/@tangled.org/infra) for how this can be 48 - achieved using Nix. 49 - 50 - Then, initialize the bao server: 51 - ```bash 52 - bao operator init -key-shares=1 -key-threshold=1 53 - ``` 54 - 55 - This will print out an unseal key and a root key. Save them somewhere (like a password manager). Then unseal the vault to begin setting it up: 56 - ```bash 57 - bao operator unseal <unseal_key> 58 - ``` 59 - 60 - All steps below remain the same across both dev and production setups. 61 - 62 - ### configure openbao server 63 - 64 - Create the spindle KV mount: 65 - 66 - ```bash 67 - bao secrets enable -path=spindle -version=2 kv 68 - ``` 69 - 70 - Set up AppRole authentication and policy: 71 - 72 - Create a policy file `spindle-policy.hcl`: 73 - 74 - ```hcl 75 - # Full access to spindle KV v2 data 76 - path "spindle/data/*" { 77 - capabilities = ["create", "read", "update", "delete"] 78 - } 79 - 80 - # Access to metadata for listing and management 81 - path "spindle/metadata/*" { 82 - capabilities = ["list", "read", "delete", "update"] 83 - } 84 - 85 - # Allow listing at root level 86 - path "spindle/" { 87 - capabilities = ["list"] 88 - } 89 - 90 - # Required for connection testing and health checks 91 - path "auth/token/lookup-self" { 92 - capabilities = ["read"] 93 - } 94 - ``` 95 - 96 - Apply the policy and create an AppRole: 97 - 98 - ```bash 99 - bao policy write spindle-policy spindle-policy.hcl 100 - bao auth enable approle 101 - bao write auth/approle/role/spindle \ 102 - token_policies="spindle-policy" \ 103 - token_ttl=1h \ 104 - token_max_ttl=4h \ 105 - bind_secret_id=true \ 106 - secret_id_ttl=0 \ 107 - secret_id_num_uses=0 108 - ``` 109 - 110 - Get the credentials: 111 - 112 - ```bash 113 - # Get role ID (static) 114 - ROLE_ID=$(bao read -field=role_id auth/approle/role/spindle/role-id) 115 - 116 - # Generate secret ID 117 - SECRET_ID=$(bao write -f -field=secret_id auth/approle/role/spindle/secret-id) 118 - 119 - echo "Role ID: $ROLE_ID" 120 - echo "Secret ID: $SECRET_ID" 121 - ``` 122 - 123 - ### create proxy configuration 124 - 125 - Create the credential files: 126 - 127 - ```bash 128 - # Create directory for OpenBao files 129 - mkdir -p /tmp/openbao 130 - 131 - # Save credentials 132 - echo "$ROLE_ID" > /tmp/openbao/role-id 133 - echo "$SECRET_ID" > /tmp/openbao/secret-id 134 - chmod 600 /tmp/openbao/role-id /tmp/openbao/secret-id 135 - ``` 136 - 137 - Create a proxy configuration file `/tmp/openbao/proxy.hcl`: 138 - 139 - ```hcl 140 - # OpenBao server connection 141 - vault { 142 - address = "http://localhost:8200" 143 - } 144 - 145 - # Auto-Auth using AppRole 146 - auto_auth { 147 - method "approle" { 148 - mount_path = "auth/approle" 149 - config = { 150 - role_id_file_path = "/tmp/openbao/role-id" 151 - secret_id_file_path = "/tmp/openbao/secret-id" 152 - } 153 - } 154 - 155 - # Optional: write token to file for debugging 156 - sink "file" { 157 - config = { 158 - path = "/tmp/openbao/token" 159 - mode = 0640 160 - } 161 - } 162 - } 163 - 164 - # Proxy listener for Spindle 165 - listener "tcp" { 166 - address = "127.0.0.1:8201" 167 - tls_disable = true 168 - } 169 - 170 - # Enable API proxy with auto-auth token 171 - api_proxy { 172 - use_auto_auth_token = true 173 - } 174 - 175 - # Enable response caching 176 - cache { 177 - use_auto_auth_token = true 178 - } 179 - 180 - # Logging 181 - log_level = "info" 182 - ``` 183 - 184 - ### start the proxy 185 - 186 - Start OpenBao Proxy: 187 - 188 - ```bash 189 - bao proxy -config=/tmp/openbao/proxy.hcl 190 - ``` 191 - 192 - The proxy will authenticate with OpenBao and start listening on 193 - `127.0.0.1:8201`. 194 - 195 - ### configure spindle 196 - 197 - Set these environment variables for Spindle: 198 - 199 - ```bash 200 - export SPINDLE_SERVER_SECRETS_PROVIDER=openbao 201 - export SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=http://127.0.0.1:8201 202 - export SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=spindle 203 - ``` 204 - 205 - Start Spindle: 206 - 207 - Spindle will now connect to the local proxy, which handles all 208 - authentication automatically. 209 - 210 - ## production setup for proxy 211 - 212 - For production, you'll want to run the proxy as a service: 213 - 214 - Place your production configuration in `/etc/openbao/proxy.hcl` with 215 - proper TLS settings for the vault connection. 216 - 217 - ## verifying setup 218 - 219 - Test the proxy directly: 220 - 221 - ```bash 222 - # Check proxy health 223 - curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/sys/health 224 - 225 - # Test token lookup through proxy 226 - curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/auth/token/lookup-self 227 - ``` 228 - 229 - Test OpenBao operations through the server: 230 - 231 - ```bash 232 - # List all secrets 233 - bao kv list spindle/ 234 - 235 - # Add a test secret via Spindle API, then check it exists 236 - bao kv list spindle/repos/ 237 - 238 - # Get a specific secret 239 - bao kv get spindle/repos/your_repo_path/SECRET_NAME 240 - ``` 241 - 242 - ## how it works 243 - 244 - - Spindle connects to OpenBao Proxy on localhost (typically port 8200 or 8201) 245 - - The proxy authenticates with OpenBao using AppRole credentials 246 - - All Spindle requests go through the proxy, which injects authentication tokens 247 - - Secrets are stored at `spindle/repos/{sanitized_repo_path}/{secret_key}` 248 - - Repository paths like `did:plc:alice/myrepo` become `did_plc_alice_myrepo` 249 - - The proxy handles all token renewal automatically 250 - - Spindle no longer manages tokens or authentication directly 251 - 252 - ## troubleshooting 253 - 254 - **Connection refused**: Check that the OpenBao Proxy is running and 255 - listening on the configured address. 256 - 257 - **403 errors**: Verify the AppRole credentials are correct and the policy 258 - has the necessary permissions. 259 - 260 - **404 route errors**: The spindle KV mount probably doesn't exist - run 261 - the mount creation step again. 262 - 263 - **Proxy authentication failures**: Check the proxy logs and verify the 264 - role-id and secret-id files are readable and contain valid credentials. 265 - 266 - **Secret not found after writing**: This can indicate policy permission 267 - issues. Verify the policy includes both `spindle/data/*` and 268 - `spindle/metadata/*` paths with appropriate capabilities. 269 - 270 - Check proxy logs: 271 - 272 - ```bash 273 - # If running as systemd service 274 - journalctl -u openbao-proxy -f 275 - 276 - # If running directly, check the console output 277 - ``` 278 - 279 - Test AppRole authentication manually: 280 - 281 - ```bash 282 - bao write auth/approle/login \ 283 - role_id="$(cat /tmp/openbao/role-id)" \ 284 - secret_id="$(cat /tmp/openbao/secret-id)" 285 - ```
···
-183
docs/spindle/pipeline.md
··· 1 - # spindle pipelines 2 - 3 - Spindle workflows allow you to write CI/CD pipelines in a simple format. They're located in the `.tangled/workflows` directory at the root of your repository, and are defined using YAML. 4 - 5 - The fields are: 6 - 7 - - [Trigger](#trigger): A **required** field that defines when a workflow should be triggered. 8 - - [Engine](#engine): A **required** field that defines which engine a workflow should run on. 9 - - [Clone options](#clone-options): An **optional** field that defines how the repository should be cloned. 10 - - [Dependencies](#dependencies): An **optional** field that allows you to list dependencies you may need. 11 - - [Environment](#environment): An **optional** field that allows you to define environment variables. 12 - - [Steps](#steps): An **optional** field that allows you to define what steps should run in the workflow. 13 - 14 - ## Trigger 15 - 16 - The first thing to add to a workflow is the trigger, which defines when a workflow runs. This is defined using a `when` field, which takes in a list of conditions. Each condition has the following fields: 17 - 18 - - `event`: This is a **required** field that defines when your workflow should run. It's a list that can take one or more of the following values: 19 - - `push`: The workflow should run every time a commit is pushed to the repository. 20 - - `pull_request`: The workflow should run every time a pull request is made or updated. 21 - - `manual`: The workflow can be triggered manually. 22 - - `branch`: Defines which branches the workflow should run for. If used with the `push` event, commits to the branch(es) listed here will trigger the workflow. If used with the `pull_request` event, updates to pull requests targeting the branch(es) listed here will trigger the workflow. This field has no effect with the `manual` event. Supports glob patterns using `*` and `**` (e.g., `main`, `develop`, `release-*`). Either `branch` or `tag` (or both) must be specified for `push` events. 23 - - `tag`: Defines which tags the workflow should run for. Only used with the `push` event - when tags matching the pattern(s) listed here are pushed, the workflow will trigger. This field has no effect with `pull_request` or `manual` events. Supports glob patterns using `*` and `**` (e.g., `v*`, `v1.*`, `release-**`). Either `branch` or `tag` (or both) must be specified for `push` events. 24 - 25 - For example, if you'd like to define a workflow that runs when commits are pushed to the `main` and `develop` branches, or when pull requests that target the `main` branch are updated, or manually, you can do so with: 26 - 27 - ```yaml 28 - when: 29 - - event: ["push", "manual"] 30 - branch: ["main", "develop"] 31 - - event: ["pull_request"] 32 - branch: ["main"] 33 - ``` 34 - 35 - You can also trigger workflows on tag pushes. For instance, to run a deployment workflow when tags matching `v*` are pushed: 36 - 37 - ```yaml 38 - when: 39 - - event: ["push"] 40 - tag: ["v*"] 41 - ``` 42 - 43 - You can even combine branch and tag patterns in a single constraint (the workflow triggers if either matches): 44 - 45 - ```yaml 46 - when: 47 - - event: ["push"] 48 - branch: ["main", "release-*"] 49 - tag: ["v*", "stable"] 50 - ``` 51 - 52 - ## Engine 53 - 54 - Next is the engine on which the workflow should run, defined using the **required** `engine` field. The currently supported engines are: 55 - 56 - - `nixery`: This uses an instance of [Nixery](https://nixery.dev) to run steps, which allows you to add [dependencies](#dependencies) from [Nixpkgs](https://github.com/NixOS/nixpkgs). You can search for packages on https://search.nixos.org, and there's a pretty good chance the package(s) you're looking for will be there. 57 - 58 - Example: 59 - 60 - ```yaml 61 - engine: "nixery" 62 - ``` 63 - 64 - ## Clone options 65 - 66 - When a workflow starts, the first step is to clone the repository. You can customize this behavior using the **optional** `clone` field. It has the following fields: 67 - 68 - - `skip`: Setting this to `true` will skip cloning the repository. This can be useful if your workflow is doing something that doesn't require anything from the repository itself. This is `false` by default. 69 - - `depth`: This sets the number of commits, or the "clone depth", to fetch from the repository. For example, if you set this to 2, the last 2 commits will be fetched. By default, the depth is set to 1, meaning only the most recent commit will be fetched, which is the commit that triggered the workflow. 70 - - `submodules`: If you use [git submodules](https://git-scm.com/book/en/v2/Git-Tools-Submodules) in your repository, setting this field to `true` will recursively fetch all submodules. This is `false` by default. 71 - 72 - The default settings are: 73 - 74 - ```yaml 75 - clone: 76 - skip: false 77 - depth: 1 78 - submodules: false 79 - ``` 80 - 81 - ## Dependencies 82 - 83 - Usually when you're running a workflow, you'll need additional dependencies. The `dependencies` field lets you define which dependencies to get, and from where. It's a key-value map, with the key being the registry to fetch dependencies from, and the value being the list of dependencies to fetch. 84 - 85 - Say you want to fetch Node.js and Go from `nixpkgs`, and a package called `my_pkg` you've made from your own registry at your repository at `https://tangled.sh/@example.com/my_pkg`. You can define those dependencies like so: 86 - 87 - ```yaml 88 - dependencies: 89 - # nixpkgs 90 - nixpkgs: 91 - - nodejs 92 - - go 93 - # custom registry 94 - git+https://tangled.org/@example.com/my_pkg: 95 - - my_pkg 96 - ``` 97 - 98 - Now these dependencies are available to use in your workflow! 99 - 100 - ## Environment 101 - 102 - The `environment` field allows you define environment variables that will be available throughout the entire workflow. **Do not put secrets here, these environment variables are visible to anyone viewing the repository. You can add secrets for pipelines in your repository's settings.** 103 - 104 - Example: 105 - 106 - ```yaml 107 - environment: 108 - GOOS: "linux" 109 - GOARCH: "arm64" 110 - NODE_ENV: "production" 111 - MY_ENV_VAR: "MY_ENV_VALUE" 112 - ``` 113 - 114 - ## Steps 115 - 116 - The `steps` field allows you to define what steps should run in the workflow. It's a list of step objects, each with the following fields: 117 - 118 - - `name`: This field allows you to give your step a name. This name is visible in your workflow runs, and is used to describe what the step is doing. 119 - - `command`: This field allows you to define a command to run in that step. The step is run in a Bash shell, and the logs from the command will be visible in the pipelines page on the Tangled website. The [dependencies](#dependencies) you added will be available to use here. 120 - - `environment`: Similar to the global [environment](#environment) config, this **optional** field is a key-value map that allows you to set environment variables for the step. **Do not put secrets here, these environment variables are visible to anyone viewing the repository. You can add secrets for pipelines in your repository's settings.** 121 - 122 - Example: 123 - 124 - ```yaml 125 - steps: 126 - - name: "Build backend" 127 - command: "go build" 128 - environment: 129 - GOOS: "darwin" 130 - GOARCH: "arm64" 131 - - name: "Build frontend" 132 - command: "npm run build" 133 - environment: 134 - NODE_ENV: "production" 135 - ``` 136 - 137 - ## Complete workflow 138 - 139 - ```yaml 140 - # .tangled/workflows/build.yml 141 - 142 - when: 143 - - event: ["push", "manual"] 144 - branch: ["main", "develop"] 145 - - event: ["pull_request"] 146 - branch: ["main"] 147 - 148 - engine: "nixery" 149 - 150 - # using the default values 151 - clone: 152 - skip: false 153 - depth: 1 154 - submodules: false 155 - 156 - dependencies: 157 - # nixpkgs 158 - nixpkgs: 159 - - nodejs 160 - - go 161 - # custom registry 162 - git+https://tangled.org/@example.com/my_pkg: 163 - - my_pkg 164 - 165 - environment: 166 - GOOS: "linux" 167 - GOARCH: "arm64" 168 - NODE_ENV: "production" 169 - MY_ENV_VAR: "MY_ENV_VALUE" 170 - 171 - steps: 172 - - name: "Build backend" 173 - command: "go build" 174 - environment: 175 - GOOS: "darwin" 176 - GOARCH: "arm64" 177 - - name: "Build frontend" 178 - command: "npm run build" 179 - environment: 180 - NODE_ENV: "production" 181 - ``` 182 - 183 - If you want another example of a workflow, you can look at the one [Tangled uses to build the project](https://tangled.sh/@tangled.sh/core/blob/master/.tangled/workflows/build.yml).
···
+101
docs/styles.css
···
··· 1 + svg { 2 + width: 16px; 3 + height: 16px; 4 + } 5 + 6 + :root { 7 + --syntax-alert: #d20f39; 8 + --syntax-annotation: #fe640b; 9 + --syntax-attribute: #df8e1d; 10 + --syntax-basen: #40a02b; 11 + --syntax-builtin: #1e66f5; 12 + --syntax-controlflow: #8839ef; 13 + --syntax-char: #04a5e5; 14 + --syntax-constant: #fe640b; 15 + --syntax-comment: #9ca0b0; 16 + --syntax-commentvar: #7c7f93; 17 + --syntax-documentation: #9ca0b0; 18 + --syntax-datatype: #df8e1d; 19 + --syntax-decval: #40a02b; 20 + --syntax-error: #d20f39; 21 + --syntax-extension: #4c4f69; 22 + --syntax-float: #40a02b; 23 + --syntax-function: #1e66f5; 24 + --syntax-import: #40a02b; 25 + --syntax-information: #04a5e5; 26 + --syntax-keyword: #8839ef; 27 + --syntax-operator: #179299; 28 + --syntax-other: #8839ef; 29 + --syntax-preprocessor: #ea76cb; 30 + --syntax-specialchar: #04a5e5; 31 + --syntax-specialstring: #ea76cb; 32 + --syntax-string: #40a02b; 33 + --syntax-variable: #8839ef; 34 + --syntax-verbatimstring: #40a02b; 35 + --syntax-warning: #df8e1d; 36 + } 37 + 38 + @media (prefers-color-scheme: dark) { 39 + :root { 40 + --syntax-alert: #f38ba8; 41 + --syntax-annotation: #fab387; 42 + --syntax-attribute: #f9e2af; 43 + --syntax-basen: #a6e3a1; 44 + --syntax-builtin: #89b4fa; 45 + --syntax-controlflow: #cba6f7; 46 + --syntax-char: #89dceb; 47 + --syntax-constant: #fab387; 48 + --syntax-comment: #6c7086; 49 + --syntax-commentvar: #585b70; 50 + --syntax-documentation: #6c7086; 51 + --syntax-datatype: #f9e2af; 52 + --syntax-decval: #a6e3a1; 53 + --syntax-error: #f38ba8; 54 + --syntax-extension: #cdd6f4; 55 + --syntax-float: #a6e3a1; 56 + --syntax-function: #89b4fa; 57 + --syntax-import: #a6e3a1; 58 + --syntax-information: #89dceb; 59 + --syntax-keyword: #cba6f7; 60 + --syntax-operator: #94e2d5; 61 + --syntax-other: #cba6f7; 62 + --syntax-preprocessor: #f5c2e7; 63 + --syntax-specialchar: #89dceb; 64 + --syntax-specialstring: #f5c2e7; 65 + --syntax-string: #a6e3a1; 66 + --syntax-variable: #cba6f7; 67 + --syntax-verbatimstring: #a6e3a1; 68 + --syntax-warning: #f9e2af; 69 + } 70 + } 71 + 72 + /* pandoc syntax highlighting classes */ 73 + code span.al { color: var(--syntax-alert); font-weight: bold; } /* alert */ 74 + code span.an { color: var(--syntax-annotation); font-weight: bold; font-style: italic; } /* annotation */ 75 + code span.at { color: var(--syntax-attribute); } /* attribute */ 76 + code span.bn { color: var(--syntax-basen); } /* basen */ 77 + code span.bu { color: var(--syntax-builtin); } /* builtin */ 78 + code span.cf { color: var(--syntax-controlflow); font-weight: bold; } /* controlflow */ 79 + code span.ch { color: var(--syntax-char); } /* char */ 80 + code span.cn { color: var(--syntax-constant); } /* constant */ 81 + code span.co { color: var(--syntax-comment); font-style: italic; } /* comment */ 82 + code span.cv { color: var(--syntax-commentvar); font-weight: bold; font-style: italic; } /* commentvar */ 83 + code span.do { color: var(--syntax-documentation); font-style: italic; } /* documentation */ 84 + code span.dt { color: var(--syntax-datatype); } /* datatype */ 85 + code span.dv { color: var(--syntax-decval); } /* decval */ 86 + code span.er { color: var(--syntax-error); font-weight: bold; } /* error */ 87 + code span.ex { color: var(--syntax-extension); } /* extension */ 88 + code span.fl { color: var(--syntax-float); } /* float */ 89 + code span.fu { color: var(--syntax-function); } /* function */ 90 + code span.im { color: var(--syntax-import); font-weight: bold; } /* import */ 91 + code span.in { color: var(--syntax-information); font-weight: bold; font-style: italic; } /* information */ 92 + code span.kw { color: var(--syntax-keyword); font-weight: bold; } /* keyword */ 93 + code span.op { color: var(--syntax-operator); } /* operator */ 94 + code span.ot { color: var(--syntax-other); } /* other */ 95 + code span.pp { color: var(--syntax-preprocessor); } /* preprocessor */ 96 + code span.sc { color: var(--syntax-specialchar); } /* specialchar */ 97 + code span.ss { color: var(--syntax-specialstring); } /* specialstring */ 98 + code span.st { color: var(--syntax-string); } /* string */ 99 + code span.va { color: var(--syntax-variable); } /* variable */ 100 + code span.vs { color: var(--syntax-verbatimstring); } /* verbatimstring */ 101 + code span.wa { color: var(--syntax-warning); font-weight: bold; font-style: italic; } /* warning */
+156
docs/template.html
···
··· 1 + <!DOCTYPE html> 2 + <html xmlns="http://www.w3.org/1999/xhtml" lang="$lang$" xml:lang="$lang$"$if(dir)$ dir="$dir$"$endif$> 3 + <head> 4 + <meta charset="utf-8" /> 5 + <meta name="generator" content="pandoc" /> 6 + <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes" /> 7 + $for(author-meta)$ 8 + <meta name="author" content="$author-meta$" /> 9 + $endfor$ 10 + 11 + $if(date-meta)$ 12 + <meta name="dcterms.date" content="$date-meta$" /> 13 + $endif$ 14 + 15 + $if(keywords)$ 16 + <meta name="keywords" content="$for(keywords)$$keywords$$sep$, $endfor$" /> 17 + $endif$ 18 + 19 + $if(description-meta)$ 20 + <meta name="description" content="$description-meta$" /> 21 + $endif$ 22 + 23 + <title>$pagetitle$</title> 24 + 25 + <style> 26 + $styles.css()$ 27 + </style> 28 + 29 + $for(css)$ 30 + <link rel="stylesheet" href="$css$" /> 31 + $endfor$ 32 + 33 + $for(header-includes)$ 34 + $header-includes$ 35 + $endfor$ 36 + 37 + <link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin /> 38 + 39 + </head> 40 + <body class="bg-white dark:bg-gray-900 flex flex-col min-h-svh"> 41 + $for(include-before)$ 42 + $include-before$ 43 + $endfor$ 44 + 45 + $if(toc)$ 46 + <!-- mobile TOC trigger --> 47 + <div class="md:hidden px-6 py-4 border-b border-gray-200 dark:border-gray-700"> 48 + <button 49 + type="button" 50 + popovertarget="mobile-toc-popover" 51 + popovertargetaction="toggle" 52 + class="w-full flex gap-2 items-center text-sm font-semibold dark:text-white" 53 + > 54 + ${ menu.svg() } 55 + $if(toc-title)$$toc-title$$else$Table of Contents$endif$ 56 + </button> 57 + </div> 58 + 59 + <div 60 + id="mobile-toc-popover" 61 + popover 62 + class="mobile-toc-popover 63 + bg-gray-50 dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 64 + h-full overflow-y-auto shadow-sm 65 + px-6 py-4 fixed inset-x-0 top-0 w-fit max-w-4/5 m-0" 66 + > 67 + <div class="flex flex-col min-h-full"> 68 + <div class="flex-1 space-y-4"> 69 + <button 70 + type="button" 71 + popovertarget="mobile-toc-popover" 72 + popovertargetaction="toggle" 73 + class="w-full flex gap-2 items-center text-sm font-semibold dark:text-white mb-4"> 74 + ${ x.svg() } 75 + $if(toc-title)$$toc-title$$else$Table of Contents$endif$ 76 + </button> 77 + ${ search.html() } 78 + ${ table-of-contents:toc.html() } 79 + </div> 80 + ${ single-page:mode.html() } 81 + </div> 82 + </div> 83 + 84 + <!-- desktop sidebar toc --> 85 + <nav 86 + id="$idprefix$TOC" 87 + role="doc-toc" 88 + class="hidden md:flex md:flex-col gap-4 fixed left-0 top-0 w-80 h-screen 89 + bg-gray-50 dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 90 + p-4 z-50 overflow-y-auto"> 91 + ${ search.html() } 92 + <div class="flex-1"> 93 + $if(toc-title)$ 94 + <h2 id="$idprefix$toc-title" class="text-lg font-semibold mb-4 text-gray-900">$toc-title$</h2> 95 + $endif$ 96 + ${ table-of-contents:toc.html() } 97 + </div> 98 + ${ single-page:mode.html() } 99 + </nav> 100 + $endif$ 101 + 102 + <div class="$if(toc)$md:ml-80$endif$ flex-1 flex flex-col"> 103 + <main class="max-w-4xl w-full mx-auto p-6 flex-1"> 104 + $if(top)$ 105 + $-- only print title block if this is NOT the top page 106 + $else$ 107 + $if(title)$ 108 + <header id="title-block-header" class="mb-8 pb-8 border-b border-gray-200 dark:border-gray-700"> 109 + <h1 class="text-4xl font-bold mb-2 text-black dark:text-white">$title$</h1> 110 + $if(subtitle)$ 111 + <p class="text-xl text-gray-500 dark:text-gray-400 mb-2">$subtitle$</p> 112 + $endif$ 113 + $for(author)$ 114 + <p class="text-sm text-gray-500 dark:text-gray-400">$author$</p> 115 + $endfor$ 116 + $if(date)$ 117 + <p class="text-sm text-gray-500 dark:text-gray-400">Updated on $date$</p> 118 + $endif$ 119 + $endif$ 120 + </header> 121 + $endif$ 122 + 123 + $if(abstract)$ 124 + <article class="prose dark:prose-invert max-w-none"> 125 + $abstract$ 126 + </article> 127 + $endif$ 128 + 129 + <article class="prose dark:prose-invert max-w-none"> 130 + $body$ 131 + </article> 132 + </main> 133 + <nav id="sitenav" class="border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800"> 134 + <div class="max-w-4xl mx-auto px-8 py-4"> 135 + <div class="flex justify-between gap-4"> 136 + <span class="flex-1"> 137 + $if(previous.url)$ 138 + <span class="text-xs text-gray-500 dark:text-gray-400 uppercase block mb-1">Previous</span> 139 + <a href="$previous.url$" accesskey="p" rel="previous">$previous.title$</a> 140 + $endif$ 141 + </span> 142 + <span class="flex-1 text-right"> 143 + $if(next.url)$ 144 + <span class="text-xs text-gray-500 dark:text-gray-400 uppercase block mb-1">Next</span> 145 + <a href="$next.url$" accesskey="n" rel="next">$next.title$</a> 146 + $endif$ 147 + </span> 148 + </div> 149 + </div> 150 + </nav> 151 + </div> 152 + $for(include-after)$ 153 + $include-after$ 154 + $endfor$ 155 + </body> 156 + </html>
+4
docs/toc.html
···
··· 1 + <div class="[&_ul]:space-y-6 [&_ul]:pl-0 [&_ul]:font-bold [&_ul_ul]:pl-4 [&_ul_ul]:font-normal [&_ul_ul]:space-y-2 [&_li]:space-y-2"> 2 + $table-of-contents$ 3 + </div> 4 +
+9 -9
flake.lock
··· 35 "systems": "systems" 36 }, 37 "locked": { 38 - "lastModified": 1694529238, 39 - "narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=", 40 "owner": "numtide", 41 "repo": "flake-utils", 42 - "rev": "ff7b65b44d01cf9ba6a71320833626af21126384", 43 "type": "github" 44 }, 45 "original": { ··· 56 ] 57 }, 58 "locked": { 59 - "lastModified": 1754078208, 60 - "narHash": "sha256-YVoIFDCDpYuU3riaDEJ3xiGdPOtsx4sR5eTzHTytPV8=", 61 "owner": "nix-community", 62 "repo": "gomod2nix", 63 - "rev": "7f963246a71626c7fc70b431a315c4388a0c95cf", 64 "type": "github" 65 }, 66 "original": { ··· 150 }, 151 "nixpkgs": { 152 "locked": { 153 - "lastModified": 1751984180, 154 - "narHash": "sha256-LwWRsENAZJKUdD3SpLluwDmdXY9F45ZEgCb0X+xgOL0=", 155 "owner": "nixos", 156 "repo": "nixpkgs", 157 - "rev": "9807714d6944a957c2e036f84b0ff8caf9930bc0", 158 "type": "github" 159 }, 160 "original": {
··· 35 "systems": "systems" 36 }, 37 "locked": { 38 + "lastModified": 1731533236, 39 + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 40 "owner": "numtide", 41 "repo": "flake-utils", 42 + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 43 "type": "github" 44 }, 45 "original": { ··· 56 ] 57 }, 58 "locked": { 59 + "lastModified": 1763982521, 60 + "narHash": "sha256-ur4QIAHwgFc0vXiaxn5No/FuZicxBr2p0gmT54xZkUQ=", 61 "owner": "nix-community", 62 "repo": "gomod2nix", 63 + "rev": "02e63a239d6eabd595db56852535992c898eba72", 64 "type": "github" 65 }, 66 "original": { ··· 150 }, 151 "nixpkgs": { 152 "locked": { 153 + "lastModified": 1766070988, 154 + "narHash": "sha256-G/WVghka6c4bAzMhTwT2vjLccg/awmHkdKSd2JrycLc=", 155 "owner": "nixos", 156 "repo": "nixpkgs", 157 + "rev": "c6245e83d836d0433170a16eb185cefe0572f8b8", 158 "type": "github" 159 }, 160 "original": {
+21 -5
flake.nix
··· 76 }; 77 buildGoApplication = 78 (self.callPackage "${gomod2nix}/builder" { 79 - gomod2nix = gomod2nix.legacyPackages.${pkgs.system}.gomod2nix; 80 }).buildGoApplication; 81 modules = ./nix/gomod2nix.toml; 82 sqlite-lib = self.callPackage ./nix/pkgs/sqlite-lib.nix { 83 - inherit (pkgs) gcc; 84 inherit sqlite-lib-src; 85 }; 86 lexgen = self.callPackage ./nix/pkgs/lexgen.nix {inherit indigo;}; ··· 89 inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src actor-typeahead-src; 90 }; 91 appview = self.callPackage ./nix/pkgs/appview.nix {}; 92 spindle = self.callPackage ./nix/pkgs/spindle.nix {}; 93 knot-unwrapped = self.callPackage ./nix/pkgs/knot-unwrapped.nix {}; 94 knot = self.callPackage ./nix/pkgs/knot.nix {}; 95 }); 96 in { 97 overlays.default = final: prev: { 98 - inherit (mkPackageSet final) lexgen goat sqlite-lib spindle knot-unwrapped knot appview; 99 }; 100 101 packages = forAllSystems (system: let ··· 104 staticPackages = mkPackageSet pkgs.pkgsStatic; 105 crossPackages = mkPackageSet pkgs.pkgsCross.gnu64.pkgsStatic; 106 in { 107 - inherit (packages) appview appview-static-files lexgen goat spindle knot knot-unwrapped sqlite-lib; 108 109 pkgsStatic-appview = staticPackages.appview; 110 pkgsStatic-knot = staticPackages.knot; 111 pkgsStatic-knot-unwrapped = staticPackages.knot-unwrapped; 112 pkgsStatic-spindle = staticPackages.spindle; 113 pkgsStatic-sqlite-lib = staticPackages.sqlite-lib; 114 115 pkgsCross-gnu64-pkgsStatic-appview = crossPackages.appview; 116 pkgsCross-gnu64-pkgsStatic-knot = crossPackages.knot; 117 pkgsCross-gnu64-pkgsStatic-knot-unwrapped = crossPackages.knot-unwrapped; 118 pkgsCross-gnu64-pkgsStatic-spindle = crossPackages.spindle; 119 120 treefmt-wrapper = pkgs.treefmt.withConfig { 121 settings.formatter = { ··· 156 nativeBuildInputs = [ 157 pkgs.go 158 pkgs.air 159 - pkgs.tilt 160 pkgs.gopls 161 pkgs.httpie 162 pkgs.litecli
··· 76 }; 77 buildGoApplication = 78 (self.callPackage "${gomod2nix}/builder" { 79 + gomod2nix = gomod2nix.legacyPackages.${pkgs.stdenv.hostPlatform.system}.gomod2nix; 80 }).buildGoApplication; 81 modules = ./nix/gomod2nix.toml; 82 sqlite-lib = self.callPackage ./nix/pkgs/sqlite-lib.nix { 83 inherit sqlite-lib-src; 84 }; 85 lexgen = self.callPackage ./nix/pkgs/lexgen.nix {inherit indigo;}; ··· 88 inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src actor-typeahead-src; 89 }; 90 appview = self.callPackage ./nix/pkgs/appview.nix {}; 91 + docs = self.callPackage ./nix/pkgs/docs.nix { 92 + inherit inter-fonts-src ibm-plex-mono-src lucide-src; 93 + }; 94 spindle = self.callPackage ./nix/pkgs/spindle.nix {}; 95 knot-unwrapped = self.callPackage ./nix/pkgs/knot-unwrapped.nix {}; 96 knot = self.callPackage ./nix/pkgs/knot.nix {}; 97 + dolly = self.callPackage ./nix/pkgs/dolly.nix {}; 98 }); 99 in { 100 overlays.default = final: prev: { 101 + inherit (mkPackageSet final) lexgen goat sqlite-lib spindle knot-unwrapped knot appview docs dolly; 102 }; 103 104 packages = forAllSystems (system: let ··· 107 staticPackages = mkPackageSet pkgs.pkgsStatic; 108 crossPackages = mkPackageSet pkgs.pkgsCross.gnu64.pkgsStatic; 109 in { 110 + inherit 111 + (packages) 112 + appview 113 + appview-static-files 114 + lexgen 115 + goat 116 + spindle 117 + knot 118 + knot-unwrapped 119 + sqlite-lib 120 + docs 121 + dolly 122 + ; 123 124 pkgsStatic-appview = staticPackages.appview; 125 pkgsStatic-knot = staticPackages.knot; 126 pkgsStatic-knot-unwrapped = staticPackages.knot-unwrapped; 127 pkgsStatic-spindle = staticPackages.spindle; 128 pkgsStatic-sqlite-lib = staticPackages.sqlite-lib; 129 + pkgsStatic-dolly = staticPackages.dolly; 130 131 pkgsCross-gnu64-pkgsStatic-appview = crossPackages.appview; 132 pkgsCross-gnu64-pkgsStatic-knot = crossPackages.knot; 133 pkgsCross-gnu64-pkgsStatic-knot-unwrapped = crossPackages.knot-unwrapped; 134 pkgsCross-gnu64-pkgsStatic-spindle = crossPackages.spindle; 135 + pkgsCross-gnu64-pkgsStatic-dolly = crossPackages.dolly; 136 137 treefmt-wrapper = pkgs.treefmt.withConfig { 138 settings.formatter = { ··· 173 nativeBuildInputs = [ 174 pkgs.go 175 pkgs.air 176 pkgs.gopls 177 pkgs.httpie 178 pkgs.litecli
+3 -4
go.mod
··· 1 module tangled.org/core 2 3 - go 1.24.4 4 5 require ( 6 github.com/Blank-Xu/sql-adapter v1.1.1 ··· 44 github.com/stretchr/testify v1.10.0 45 github.com/urfave/cli/v3 v3.3.3 46 github.com/whyrusleeping/cbor-gen v0.3.1 47 - github.com/wyatt915/goldmark-treeblood v0.0.1 48 github.com/yuin/goldmark v1.7.13 49 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc 50 gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab 51 golang.org/x/crypto v0.40.0 52 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b 53 golang.org/x/image v0.31.0 54 golang.org/x/net v0.42.0 55 - golang.org/x/sync v0.17.0 56 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da 57 gopkg.in/yaml.v3 v3.0.1 58 ) ··· 190 github.com/vmihailenco/go-tinylfu v0.2.2 // indirect 191 github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect 192 github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect 193 - github.com/wyatt915/treeblood v0.1.16 // indirect 194 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 195 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 196 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect ··· 205 go.uber.org/atomic v1.11.0 // indirect 206 go.uber.org/multierr v1.11.0 // indirect 207 go.uber.org/zap v1.27.0 // indirect 208 golang.org/x/sys v0.34.0 // indirect 209 golang.org/x/text v0.29.0 // indirect 210 golang.org/x/time v0.12.0 // indirect
··· 1 module tangled.org/core 2 3 + go 1.25.0 4 5 require ( 6 github.com/Blank-Xu/sql-adapter v1.1.1 ··· 44 github.com/stretchr/testify v1.10.0 45 github.com/urfave/cli/v3 v3.3.3 46 github.com/whyrusleeping/cbor-gen v0.3.1 47 github.com/yuin/goldmark v1.7.13 48 + github.com/yuin/goldmark-emoji v1.0.6 49 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc 50 gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab 51 golang.org/x/crypto v0.40.0 52 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b 53 golang.org/x/image v0.31.0 54 golang.org/x/net v0.42.0 55 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da 56 gopkg.in/yaml.v3 v3.0.1 57 ) ··· 189 github.com/vmihailenco/go-tinylfu v0.2.2 // indirect 190 github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect 191 github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect 192 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 193 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 194 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect ··· 203 go.uber.org/atomic v1.11.0 // indirect 204 go.uber.org/multierr v1.11.0 // indirect 205 go.uber.org/zap v1.27.0 // indirect 206 + golang.org/x/sync v0.17.0 // indirect 207 golang.org/x/sys v0.34.0 // indirect 208 golang.org/x/text v0.29.0 // indirect 209 golang.org/x/time v0.12.0 // indirect
+2 -4
go.sum
··· 495 github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw= 496 github.com/whyrusleeping/cbor-gen v0.3.1 h1:82ioxmhEYut7LBVGhGq8xoRkXPLElVuh5mV67AFfdv0= 497 github.com/whyrusleeping/cbor-gen v0.3.1/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= 498 - github.com/wyatt915/goldmark-treeblood v0.0.1 h1:6vLJcjFrHgE4ASu2ga4hqIQmbvQLU37v53jlHZ3pqDs= 499 - github.com/wyatt915/goldmark-treeblood v0.0.1/go.mod h1:SmcJp5EBaV17rroNlgNQFydYwy0+fv85CUr/ZaCz208= 500 - github.com/wyatt915/treeblood v0.1.16 h1:byxNbWZhnPDxdTp7W5kQhCeaY8RBVmojTFz1tEHgg8Y= 501 - github.com/wyatt915/treeblood v0.1.16/go.mod h1:i7+yhhmzdDP17/97pIsOSffw74EK/xk+qJ0029cSXUY= 502 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 503 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 504 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= ··· 509 github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 510 github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= 511 github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 512 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ= 513 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I= 514 gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab h1:gK9tS6QJw5F0SIhYJnGG2P83kuabOdmWBbSmZhJkz2A=
··· 495 github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw= 496 github.com/whyrusleeping/cbor-gen v0.3.1 h1:82ioxmhEYut7LBVGhGq8xoRkXPLElVuh5mV67AFfdv0= 497 github.com/whyrusleeping/cbor-gen v0.3.1/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= 498 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 499 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 500 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= ··· 505 github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 506 github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= 507 github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 508 + github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= 509 + github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= 510 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ= 511 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I= 512 gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab h1:gK9tS6QJw5F0SIhYJnGG2P83kuabOdmWBbSmZhJkz2A=
+4 -4
hook/hook.go
··· 48 }, 49 Commands: []*cli.Command{ 50 { 51 - Name: "post-recieve", 52 - Usage: "sends a post-recieve hook to the knot (waits for stdin)", 53 - Action: postRecieve, 54 }, 55 }, 56 } 57 } 58 59 - func postRecieve(ctx context.Context, cmd *cli.Command) error { 60 gitDir := cmd.String("git-dir") 61 userDid := cmd.String("user-did") 62 userHandle := cmd.String("user-handle")
··· 48 }, 49 Commands: []*cli.Command{ 50 { 51 + Name: "post-receive", 52 + Usage: "sends a post-receive hook to the knot (waits for stdin)", 53 + Action: postReceive, 54 }, 55 }, 56 } 57 } 58 59 + func postReceive(ctx context.Context, cmd *cli.Command) error { 60 gitDir := cmd.String("git-dir") 61 userDid := cmd.String("user-did") 62 userHandle := cmd.String("user-handle")
+1 -1
hook/setup.go
··· 138 option_var="GIT_PUSH_OPTION_$i" 139 push_options+=(-push-option "${!option_var}") 140 done 141 - %s hook -git-dir "$GIT_DIR" -user-did "$GIT_USER_DID" -user-handle "$GIT_USER_HANDLE" -internal-api "%s" "${push_options[@]}" post-recieve 142 `, executablePath, config.internalApi) 143 144 return os.WriteFile(hookPath, []byte(hookContent), 0755)
··· 138 option_var="GIT_PUSH_OPTION_$i" 139 push_options+=(-push-option "${!option_var}") 140 done 141 + %s hook -git-dir "$GIT_DIR" -user-did "$GIT_USER_DID" -user-handle "$GIT_USER_HANDLE" -internal-api "%s" "${push_options[@]}" post-receive 142 `, executablePath, config.internalApi) 143 144 return os.WriteFile(hookPath, []byte(hookContent), 0755)
+88
ico/ico.go
···
··· 1 + package ico 2 + 3 + import ( 4 + "bytes" 5 + "encoding/binary" 6 + "fmt" 7 + "image" 8 + "image/png" 9 + ) 10 + 11 + type IconDir struct { 12 + Reserved uint16 // must be 0 13 + Type uint16 // 1 for ICO, 2 for CUR 14 + Count uint16 // number of images 15 + } 16 + 17 + type IconDirEntry struct { 18 + Width uint8 // 0 means 256 19 + Height uint8 // 0 means 256 20 + ColorCount uint8 21 + Reserved uint8 // must be 0 22 + ColorPlanes uint16 // 0 or 1 23 + BitsPerPixel uint16 24 + SizeInBytes uint32 25 + Offset uint32 26 + } 27 + 28 + func ImageToIco(img image.Image) ([]byte, error) { 29 + // encode image as png 30 + var pngBuf bytes.Buffer 31 + if err := png.Encode(&pngBuf, img); err != nil { 32 + return nil, fmt.Errorf("failed to encode PNG: %w", err) 33 + } 34 + pngData := pngBuf.Bytes() 35 + 36 + // get image dimensions 37 + bounds := img.Bounds() 38 + width := bounds.Dx() 39 + height := bounds.Dy() 40 + 41 + // prepare output buffer 42 + var icoBuf bytes.Buffer 43 + 44 + iconDir := IconDir{ 45 + Reserved: 0, 46 + Type: 1, // ICO format 47 + Count: 1, // One image 48 + } 49 + 50 + w := uint8(width) 51 + h := uint8(height) 52 + 53 + // width/height of 256 should be stored as 0 54 + if width == 256 { 55 + w = 0 56 + } 57 + if height == 256 { 58 + h = 0 59 + } 60 + 61 + iconDirEntry := IconDirEntry{ 62 + Width: w, 63 + Height: h, 64 + ColorCount: 0, // 0 for PNG (32-bit) 65 + Reserved: 0, 66 + ColorPlanes: 1, 67 + BitsPerPixel: 32, // PNG with alpha 68 + SizeInBytes: uint32(len(pngData)), 69 + Offset: 6 + 16, // Size of ICONDIR + ICONDIRENTRY 70 + } 71 + 72 + // write IconDir 73 + if err := binary.Write(&icoBuf, binary.LittleEndian, iconDir); err != nil { 74 + return nil, fmt.Errorf("failed to write ICONDIR: %w", err) 75 + } 76 + 77 + // write IconDirEntry 78 + if err := binary.Write(&icoBuf, binary.LittleEndian, iconDirEntry); err != nil { 79 + return nil, fmt.Errorf("failed to write ICONDIRENTRY: %w", err) 80 + } 81 + 82 + // write PNG data directly 83 + if _, err := icoBuf.Write(pngData); err != nil { 84 + return nil, fmt.Errorf("failed to write PNG data: %w", err) 85 + } 86 + 87 + return icoBuf.Bytes(), nil 88 + }
+2 -1
input.css
··· 162 } 163 164 .prose a.mention { 165 - @apply no-underline hover:underline; 166 } 167 168 .prose li { ··· 255 @apply py-1 text-gray-900 dark:text-gray-100; 256 } 257 } 258 } 259 260 /* Background */
··· 162 } 163 164 .prose a.mention { 165 + @apply no-underline hover:underline font-bold; 166 } 167 168 .prose li { ··· 255 @apply py-1 text-gray-900 dark:text-gray-100; 256 } 257 } 258 + 259 } 260 261 /* Background */
+15 -4
jetstream/jetstream.go
··· 72 // existing instances of the closure when j.WantedDids is mutated 73 return func(ctx context.Context, evt *models.Event) error { 74 75 // empty filter => all dids allowed 76 - if len(j.wantedDids) == 0 { 77 - return processFunc(ctx, evt) 78 } 79 80 - if _, ok := j.wantedDids[evt.Did]; ok { 81 return processFunc(ctx, evt) 82 } else { 83 return nil ··· 122 123 go func() { 124 if j.waitForDid { 125 - for len(j.wantedDids) == 0 { 126 time.Sleep(time.Second) 127 } 128 }
··· 72 // existing instances of the closure when j.WantedDids is mutated 73 return func(ctx context.Context, evt *models.Event) error { 74 75 + j.mu.RLock() 76 // empty filter => all dids allowed 77 + matches := len(j.wantedDids) == 0 78 + if !matches { 79 + if _, ok := j.wantedDids[evt.Did]; ok { 80 + matches = true 81 + } 82 } 83 + j.mu.RUnlock() 84 85 + if matches { 86 return processFunc(ctx, evt) 87 } else { 88 return nil ··· 127 128 go func() { 129 if j.waitForDid { 130 + for { 131 + j.mu.RLock() 132 + hasDid := len(j.wantedDids) != 0 133 + j.mu.RUnlock() 134 + if hasDid { 135 + break 136 + } 137 time.Sleep(time.Second) 138 } 139 }
+81
knotserver/db/db.go
···
··· 1 + package db 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + "log/slog" 7 + "strings" 8 + 9 + _ "github.com/mattn/go-sqlite3" 10 + "tangled.org/core/log" 11 + ) 12 + 13 + type DB struct { 14 + db *sql.DB 15 + logger *slog.Logger 16 + } 17 + 18 + func Setup(ctx context.Context, dbPath string) (*DB, error) { 19 + // https://github.com/mattn/go-sqlite3#connection-string 20 + opts := []string{ 21 + "_foreign_keys=1", 22 + "_journal_mode=WAL", 23 + "_synchronous=NORMAL", 24 + "_auto_vacuum=incremental", 25 + } 26 + 27 + logger := log.FromContext(ctx) 28 + logger = log.SubLogger(logger, "db") 29 + 30 + db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&")) 31 + if err != nil { 32 + return nil, err 33 + } 34 + 35 + conn, err := db.Conn(ctx) 36 + if err != nil { 37 + return nil, err 38 + } 39 + defer conn.Close() 40 + 41 + _, err = conn.ExecContext(ctx, ` 42 + create table if not exists known_dids ( 43 + did text primary key 44 + ); 45 + 46 + create table if not exists public_keys ( 47 + id integer primary key autoincrement, 48 + did text not null, 49 + key text not null, 50 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 51 + unique(did, key), 52 + foreign key (did) references known_dids(did) on delete cascade 53 + ); 54 + 55 + create table if not exists _jetstream ( 56 + id integer primary key autoincrement, 57 + last_time_us integer not null 58 + ); 59 + 60 + create table if not exists events ( 61 + rkey text not null, 62 + nsid text not null, 63 + event text not null, -- json 64 + created integer not null default (strftime('%s', 'now')), 65 + primary key (rkey, nsid) 66 + ); 67 + 68 + create table if not exists migrations ( 69 + id integer primary key autoincrement, 70 + name text unique 71 + ); 72 + `) 73 + if err != nil { 74 + return nil, err 75 + } 76 + 77 + return &DB{ 78 + db: db, 79 + logger: logger, 80 + }, nil 81 + }
-64
knotserver/db/init.go
··· 1 - package db 2 - 3 - import ( 4 - "database/sql" 5 - "strings" 6 - 7 - _ "github.com/mattn/go-sqlite3" 8 - ) 9 - 10 - type DB struct { 11 - db *sql.DB 12 - } 13 - 14 - func Setup(dbPath string) (*DB, error) { 15 - // https://github.com/mattn/go-sqlite3#connection-string 16 - opts := []string{ 17 - "_foreign_keys=1", 18 - "_journal_mode=WAL", 19 - "_synchronous=NORMAL", 20 - "_auto_vacuum=incremental", 21 - } 22 - 23 - db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&")) 24 - if err != nil { 25 - return nil, err 26 - } 27 - 28 - // NOTE: If any other migration is added here, you MUST 29 - // copy the pattern in appview: use a single sql.Conn 30 - // for every migration. 31 - 32 - _, err = db.Exec(` 33 - create table if not exists known_dids ( 34 - did text primary key 35 - ); 36 - 37 - create table if not exists public_keys ( 38 - id integer primary key autoincrement, 39 - did text not null, 40 - key text not null, 41 - created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 42 - unique(did, key), 43 - foreign key (did) references known_dids(did) on delete cascade 44 - ); 45 - 46 - create table if not exists _jetstream ( 47 - id integer primary key autoincrement, 48 - last_time_us integer not null 49 - ); 50 - 51 - create table if not exists events ( 52 - rkey text not null, 53 - nsid text not null, 54 - event text not null, -- json 55 - created integer not null default (strftime('%s', 'now')), 56 - primary key (rkey, nsid) 57 - ); 58 - `) 59 - if err != nil { 60 - return nil, err 61 - } 62 - 63 - return &DB{db: db}, nil 64 - }
···
+1 -17
knotserver/git/diff.go
··· 77 nd.Diff = append(nd.Diff, ndiff) 78 } 79 80 - nd.Stat.FilesChanged = len(diffs) 81 - nd.Commit.This = c.Hash.String() 82 - nd.Commit.PGPSignature = c.PGPSignature 83 - nd.Commit.Committer = c.Committer 84 - nd.Commit.Tree = c.TreeHash.String() 85 - 86 - if parent.Hash.IsZero() { 87 - nd.Commit.Parent = "" 88 - } else { 89 - nd.Commit.Parent = parent.Hash.String() 90 - } 91 - nd.Commit.Author = c.Author 92 - nd.Commit.Message = c.Message 93 - 94 - if v, ok := c.ExtraHeaders["change-id"]; ok { 95 - nd.Commit.ChangedId = string(v) 96 - } 97 98 return &nd, nil 99 }
··· 77 nd.Diff = append(nd.Diff, ndiff) 78 } 79 80 + nd.Commit.FromGoGitCommit(c) 81 82 return &nd, nil 83 }
+38 -2
knotserver/git/fork.go
··· 3 import ( 4 "errors" 5 "fmt" 6 "os/exec" 7 8 "github.com/go-git/go-git/v5" 9 "github.com/go-git/go-git/v5/config" 10 ) 11 12 - func Fork(repoPath, source string) error { 13 - cloneCmd := exec.Command("git", "clone", "--bare", source, repoPath) 14 if err := cloneCmd.Run(); err != nil { 15 return fmt.Errorf("failed to bare clone repository: %w", err) 16 } ··· 21 } 22 23 return nil 24 } 25 26 func (g *GitRepo) Sync() error {
··· 3 import ( 4 "errors" 5 "fmt" 6 + "log/slog" 7 + "net/url" 8 "os/exec" 9 + "path/filepath" 10 11 "github.com/go-git/go-git/v5" 12 "github.com/go-git/go-git/v5/config" 13 + knotconfig "tangled.org/core/knotserver/config" 14 ) 15 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) 27 if err := cloneCmd.Run(); err != nil { 28 return fmt.Errorf("failed to bare clone repository: %w", err) 29 } ··· 34 } 35 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 60 } 61 62 func (g *GitRepo) Sync() error {
+13 -1
knotserver/git/service/service.go
··· 95 return c.RunService(cmd) 96 } 97 98 func (c *ServiceCommand) UploadPack() error { 99 cmd := exec.Command("git", []string{ 100 - "-c", "uploadpack.allowFilter=true", 101 "upload-pack", 102 "--stateless-rpc", 103 ".",
··· 95 return c.RunService(cmd) 96 } 97 98 + func (c *ServiceCommand) UploadArchive() error { 99 + cmd := exec.Command("git", []string{ 100 + "upload-archive", 101 + ".", 102 + }...) 103 + 104 + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} 105 + cmd.Env = append(cmd.Env, fmt.Sprintf("GIT_PROTOCOL=%s", c.GitProtocol)) 106 + cmd.Dir = c.Dir 107 + 108 + return c.RunService(cmd) 109 + } 110 + 111 func (c *ServiceCommand) UploadPack() error { 112 cmd := exec.Command("git", []string{ 113 "upload-pack", 114 "--stateless-rpc", 115 ".",
+47
knotserver/git.go
··· 56 } 57 } 58 59 func (h *Knot) UploadPack(w http.ResponseWriter, r *http.Request) { 60 did := chi.URLParam(r, "did") 61 name := chi.URLParam(r, "name")
··· 56 } 57 } 58 59 + func (h *Knot) UploadArchive(w http.ResponseWriter, r *http.Request) { 60 + did := chi.URLParam(r, "did") 61 + name := chi.URLParam(r, "name") 62 + repo, err := securejoin.SecureJoin(h.c.Repo.ScanPath, filepath.Join(did, name)) 63 + if err != nil { 64 + gitError(w, err.Error(), http.StatusInternalServerError) 65 + h.l.Error("git: failed to secure join repo path", "handler", "UploadPack", "error", err) 66 + return 67 + } 68 + 69 + const expectedContentType = "application/x-git-upload-archive-request" 70 + contentType := r.Header.Get("Content-Type") 71 + if contentType != expectedContentType { 72 + gitError(w, fmt.Sprintf("Expected Content-Type: '%s', but received '%s'.", expectedContentType, contentType), http.StatusUnsupportedMediaType) 73 + } 74 + 75 + var bodyReader io.ReadCloser = r.Body 76 + if r.Header.Get("Content-Encoding") == "gzip" { 77 + gzipReader, err := gzip.NewReader(r.Body) 78 + if err != nil { 79 + gitError(w, err.Error(), http.StatusInternalServerError) 80 + h.l.Error("git: failed to create gzip reader", "handler", "UploadArchive", "error", err) 81 + return 82 + } 83 + defer gzipReader.Close() 84 + bodyReader = gzipReader 85 + } 86 + 87 + w.Header().Set("Content-Type", "application/x-git-upload-archive-result") 88 + 89 + h.l.Info("git: executing git-upload-archive", "handler", "UploadArchive", "repo", repo) 90 + 91 + cmd := service.ServiceCommand{ 92 + GitProtocol: r.Header.Get("Git-Protocol"), 93 + Dir: repo, 94 + Stdout: w, 95 + Stdin: bodyReader, 96 + } 97 + 98 + w.WriteHeader(http.StatusOK) 99 + 100 + if err := cmd.UploadArchive(); err != nil { 101 + h.l.Error("git: failed to execute git-upload-pack", "handler", "UploadPack", "error", err) 102 + return 103 + } 104 + } 105 + 106 func (h *Knot) UploadPack(w http.ResponseWriter, r *http.Request) { 107 did := chi.URLParam(r, "did") 108 name := chi.URLParam(r, "name")
+1 -25
knotserver/router.go
··· 5 "fmt" 6 "log/slog" 7 "net/http" 8 - "strings" 9 10 "github.com/go-chi/chi/v5" 11 "tangled.org/core/idresolver" ··· 80 }) 81 82 r.Route("/{did}", func(r chi.Router) { 83 - r.Use(h.resolveDidRedirect) 84 r.Route("/{name}", func(r chi.Router) { 85 // routes for git operations 86 r.Get("/info/refs", h.InfoRefs) 87 r.Post("/git-upload-pack", h.UploadPack) 88 r.Post("/git-receive-pack", h.ReceivePack) 89 }) ··· 115 } 116 117 return xrpc.Router() 118 - } 119 - 120 - func (h *Knot) resolveDidRedirect(next http.Handler) http.Handler { 121 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 122 - didOrHandle := chi.URLParam(r, "did") 123 - if strings.HasPrefix(didOrHandle, "did:") { 124 - next.ServeHTTP(w, r) 125 - return 126 - } 127 - 128 - trimmed := strings.TrimPrefix(didOrHandle, "@") 129 - id, err := h.resolver.ResolveIdent(r.Context(), trimmed) 130 - if err != nil { 131 - // invalid did or handle 132 - h.l.Error("failed to resolve did/handle", "handle", trimmed, "err", err) 133 - http.Error(w, fmt.Sprintf("failed to resolve did/handle: %s", trimmed), http.StatusInternalServerError) 134 - return 135 - } 136 - 137 - suffix := strings.TrimPrefix(r.URL.Path, "/"+didOrHandle) 138 - newPath := fmt.Sprintf("/%s/%s?%s", id.DID.String(), suffix, r.URL.RawQuery) 139 - http.Redirect(w, r, newPath, http.StatusTemporaryRedirect) 140 - }) 141 } 142 143 func (h *Knot) configureOwner() error {
··· 5 "fmt" 6 "log/slog" 7 "net/http" 8 9 "github.com/go-chi/chi/v5" 10 "tangled.org/core/idresolver" ··· 79 }) 80 81 r.Route("/{did}", func(r chi.Router) { 82 r.Route("/{name}", func(r chi.Router) { 83 // routes for git operations 84 r.Get("/info/refs", h.InfoRefs) 85 + r.Post("/git-upload-archive", h.UploadArchive) 86 r.Post("/git-upload-pack", h.UploadPack) 87 r.Post("/git-receive-pack", h.ReceivePack) 88 }) ··· 114 } 115 116 return xrpc.Router() 117 } 118 119 func (h *Knot) configureOwner() error {
+1 -1
knotserver/server.go
··· 64 logger.Info("running in dev mode, signature verification is disabled") 65 } 66 67 - db, err := db.Setup(c.Server.DBPath) 68 if err != nil { 69 return fmt.Errorf("failed to load db: %w", err) 70 }
··· 64 logger.Info("running in dev mode, signature verification is disabled") 65 } 66 67 + db, err := db.Setup(ctx, c.Server.DBPath) 68 if err != nil { 69 return fmt.Errorf("failed to load db: %w", err) 70 }
+1 -1
knotserver/xrpc/create_repo.go
··· 84 repoPath, _ := securejoin.SecureJoin(h.Config.Repo.ScanPath, relativeRepoPath) 85 86 if data.Source != nil && *data.Source != "" { 87 - err = git.Fork(repoPath, *data.Source) 88 if err != nil { 89 l.Error("forking repo", "error", err.Error()) 90 writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError)
··· 84 repoPath, _ := securejoin.SecureJoin(h.Config.Repo.ScanPath, relativeRepoPath) 85 86 if data.Source != nil && *data.Source != "" { 87 + err = git.Fork(repoPath, *data.Source, h.Config) 88 if err != nil { 89 l.Error("forking repo", "error", err.Error()) 90 writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError)
+6 -1
knotserver/xrpc/repo_log.go
··· 62 return 63 } 64 65 // Create response using existing types.RepoLogResponse 66 response := types.RepoLogResponse{ 67 - Commits: commits, 68 Ref: ref, 69 Page: (offset / limit) + 1, 70 PerPage: limit,
··· 62 return 63 } 64 65 + tcommits := make([]types.Commit, len(commits)) 66 + for i, c := range commits { 67 + tcommits[i].FromGoGitCommit(c) 68 + } 69 + 70 // Create response using existing types.RepoLogResponse 71 response := types.RepoLogResponse{ 72 + Commits: tcommits, 73 Ref: ref, 74 Page: (offset / limit) + 1, 75 PerPage: limit,
+14
lexicons/issue/comment.json
··· 29 "replyTo": { 30 "type": "string", 31 "format": "at-uri" 32 } 33 } 34 }
··· 29 "replyTo": { 30 "type": "string", 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 + } 46 } 47 } 48 }
+14
lexicons/issue/issue.json
··· 24 "createdAt": { 25 "type": "string", 26 "format": "datetime" 27 } 28 } 29 }
··· 24 "createdAt": { 25 "type": "string", 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 + } 41 } 42 } 43 }
+14
lexicons/pulls/comment.json
··· 25 "createdAt": { 26 "type": "string", 27 "format": "datetime" 28 } 29 } 30 }
··· 25 "createdAt": { 26 "type": "string", 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 + } 42 } 43 } 44 }
+24 -2
lexicons/pulls/pull.json
··· 12 "required": [ 13 "target", 14 "title", 15 - "patch", 16 "createdAt" 17 ], 18 "properties": { ··· 27 "type": "string" 28 }, 29 "patch": { 30 - "type": "string" 31 }, 32 "source": { 33 "type": "ref", ··· 36 "createdAt": { 37 "type": "string", 38 "format": "datetime" 39 } 40 } 41 }
··· 12 "required": [ 13 "target", 14 "title", 15 + "patchBlob", 16 "createdAt" 17 ], 18 "properties": { ··· 27 "type": "string" 28 }, 29 "patch": { 30 + "type": "string", 31 + "description": "(deprecated) use patchBlob instead" 32 + }, 33 + "patchBlob": { 34 + "type": "blob", 35 + "accept": [ 36 + "text/x-patch" 37 + ], 38 + "description": "patch content" 39 }, 40 "source": { 41 "type": "ref", ··· 44 "createdAt": { 45 "type": "string", 46 "format": "datetime" 47 + }, 48 + "mentions": { 49 + "type": "array", 50 + "items": { 51 + "type": "string", 52 + "format": "did" 53 + } 54 + }, 55 + "references": { 56 + "type": "array", 57 + "items": { 58 + "type": "string", 59 + "format": "at-uri" 60 + } 61 } 62 } 63 }
+3 -30
nix/gomod2nix.toml
··· 165 [mod."github.com/davecgh/go-spew"] 166 version = "v1.1.2-0.20180830191138-d8f796af33cc" 167 hash = "sha256-fV9oI51xjHdOmEx6+dlq7Ku2Ag+m/bmbzPo6A4Y74qc=" 168 - [mod."github.com/decred/dcrd/dcrec/secp256k1/v4"] 169 - version = "v4.4.0" 170 - hash = "sha256-qrhEIwhDll3cxoVpMbm1NQ9/HTI42S7ms8Buzlo5HCg=" 171 [mod."github.com/dgraph-io/ristretto"] 172 version = "v0.2.0" 173 hash = "sha256-bnpxX+oO/Qf7IJevA0gsbloVoqRx+5bh7RQ9d9eLNYw=" ··· 373 [mod."github.com/klauspost/cpuid/v2"] 374 version = "v2.3.0" 375 hash = "sha256-50JhbQyT67BK38HIdJihPtjV7orYp96HknI2VP7A9Yc=" 376 - [mod."github.com/lestrrat-go/blackmagic"] 377 - version = "v1.0.4" 378 - hash = "sha256-HmWOpwoPDNMwLdOi7onNn3Sb+ZsAa3Ai3gVBbXmQ0e8=" 379 - [mod."github.com/lestrrat-go/httpcc"] 380 - version = "v1.0.1" 381 - hash = "sha256-SMRSwJpqDIs/xL0l2e8vP0W65qtCHX2wigcOeqPJmos=" 382 - [mod."github.com/lestrrat-go/httprc"] 383 - version = "v1.0.6" 384 - hash = "sha256-mfZzePEhrmyyu/avEBd2MsDXyto8dq5+fyu5lA8GUWM=" 385 - [mod."github.com/lestrrat-go/iter"] 386 - version = "v1.0.2" 387 - hash = "sha256-30tErRf7Qu/NOAt1YURXY/XJSA6sCr6hYQfO8QqHrtw=" 388 - [mod."github.com/lestrrat-go/jwx/v2"] 389 - version = "v2.1.6" 390 - hash = "sha256-0LszXRZIba+X8AOrs3T4uanAUafBdlVB8/MpUNEFpbc=" 391 - [mod."github.com/lestrrat-go/option"] 392 - version = "v1.0.1" 393 - hash = "sha256-jVcIYYVsxElIS/l2akEw32vdEPR8+anR6oeT1FoYULI=" 394 [mod."github.com/lucasb-eyer/go-colorful"] 395 version = "v1.2.0" 396 hash = "sha256-Gg9dDJFCTaHrKHRR1SrJgZ8fWieJkybljybkI9x0gyE=" ··· 511 [mod."github.com/ryanuber/go-glob"] 512 version = "v1.0.0" 513 hash = "sha256-YkMl1utwUhi3E0sHK23ISpAsPyj4+KeXyXKoFYGXGVY=" 514 - [mod."github.com/segmentio/asm"] 515 - version = "v1.2.0" 516 - hash = "sha256-zbNuKxNrUDUc6IlmRQNuJQzVe5Ol/mqp7srDg9IMMqs=" 517 [mod."github.com/sergi/go-diff"] 518 version = "v1.1.0" 519 hash = "sha256-8NJMabldpf40uwQN20T6QXx5KORDibCBJL02KD661xY=" ··· 548 [mod."github.com/whyrusleeping/cbor-gen"] 549 version = "v0.3.1" 550 hash = "sha256-PAd8M2Z8t6rVRBII+Rg8Bz+QaJIwbW64bfyqsv31kgc=" 551 - [mod."github.com/wyatt915/goldmark-treeblood"] 552 - version = "v0.0.1" 553 - hash = "sha256-hAVFaktO02MiiqZFffr8ZlvFEfwxw4Y84OZ2t7e5G7g=" 554 - [mod."github.com/wyatt915/treeblood"] 555 - version = "v0.1.16" 556 - hash = "sha256-T68sa+iVx0qY7dDjXEAJvRWQEGXYIpUsf9tcWwO1tIw=" 557 [mod."github.com/xo/terminfo"] 558 version = "v0.0.0-20220910002029-abceb7e1c41e" 559 hash = "sha256-GyCDxxMQhXA3Pi/TsWXpA8cX5akEoZV7CFx4RO3rARU=" 560 [mod."github.com/yuin/goldmark"] 561 version = "v1.7.13" 562 hash = "sha256-vBCxZrPYPc8x/nvAAv3Au59dCCyfS80Vw3/a9EXK7TE=" 563 [mod."github.com/yuin/goldmark-highlighting/v2"] 564 version = "v2.0.0-20230729083705-37449abec8cc" 565 hash = "sha256-HpiwU7jIeDUAg2zOpTIiviQir8dpRPuXYh2nqFFccpg="
··· 165 [mod."github.com/davecgh/go-spew"] 166 version = "v1.1.2-0.20180830191138-d8f796af33cc" 167 hash = "sha256-fV9oI51xjHdOmEx6+dlq7Ku2Ag+m/bmbzPo6A4Y74qc=" 168 [mod."github.com/dgraph-io/ristretto"] 169 version = "v0.2.0" 170 hash = "sha256-bnpxX+oO/Qf7IJevA0gsbloVoqRx+5bh7RQ9d9eLNYw=" ··· 370 [mod."github.com/klauspost/cpuid/v2"] 371 version = "v2.3.0" 372 hash = "sha256-50JhbQyT67BK38HIdJihPtjV7orYp96HknI2VP7A9Yc=" 373 [mod."github.com/lucasb-eyer/go-colorful"] 374 version = "v1.2.0" 375 hash = "sha256-Gg9dDJFCTaHrKHRR1SrJgZ8fWieJkybljybkI9x0gyE=" ··· 490 [mod."github.com/ryanuber/go-glob"] 491 version = "v1.0.0" 492 hash = "sha256-YkMl1utwUhi3E0sHK23ISpAsPyj4+KeXyXKoFYGXGVY=" 493 [mod."github.com/sergi/go-diff"] 494 version = "v1.1.0" 495 hash = "sha256-8NJMabldpf40uwQN20T6QXx5KORDibCBJL02KD661xY=" ··· 524 [mod."github.com/whyrusleeping/cbor-gen"] 525 version = "v0.3.1" 526 hash = "sha256-PAd8M2Z8t6rVRBII+Rg8Bz+QaJIwbW64bfyqsv31kgc=" 527 [mod."github.com/xo/terminfo"] 528 version = "v0.0.0-20220910002029-abceb7e1c41e" 529 hash = "sha256-GyCDxxMQhXA3Pi/TsWXpA8cX5akEoZV7CFx4RO3rARU=" 530 [mod."github.com/yuin/goldmark"] 531 version = "v1.7.13" 532 hash = "sha256-vBCxZrPYPc8x/nvAAv3Au59dCCyfS80Vw3/a9EXK7TE=" 533 + [mod."github.com/yuin/goldmark-emoji"] 534 + version = "v1.0.6" 535 + hash = "sha256-+d6bZzOPE+JSFsZbQNZMCWE+n3jgcQnkPETVk47mxSY=" 536 [mod."github.com/yuin/goldmark-highlighting/v2"] 537 version = "v2.0.0-20230729083705-37449abec8cc" 538 hash = "sha256-HpiwU7jIeDUAg2zOpTIiviQir8dpRPuXYh2nqFFccpg="
+2
nix/modules/knot.nix
··· 195 Match User ${cfg.gitUser} 196 AuthorizedKeysCommand /etc/ssh/keyfetch_wrapper 197 AuthorizedKeysCommandUser nobody 198 ''; 199 }; 200
··· 195 Match User ${cfg.gitUser} 196 AuthorizedKeysCommand /etc/ssh/keyfetch_wrapper 197 AuthorizedKeysCommandUser nobody 198 + ChallengeResponseAuthentication no 199 + PasswordAuthentication no 200 ''; 201 }; 202
+6 -1
nix/pkgs/appview-static-files.nix
··· 8 actor-typeahead-src, 9 sqlite-lib, 10 tailwindcss, 11 src, 12 }: 13 runCommandLocal "appview-static-files" { ··· 17 (allow file-read* (subpath "/System/Library/OpenSSL")) 18 ''; 19 } '' 20 - mkdir -p $out/{fonts,icons} && cd $out 21 cp -f ${htmx-src} htmx.min.js 22 cp -f ${htmx-ws-src} htmx-ext-ws.min.js 23 cp -rf ${lucide-src}/*.svg icons/ ··· 26 cp -f ${inter-fonts-src}/InterVariable*.ttf fonts/ 27 cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono*.woff2 fonts/ 28 cp -f ${actor-typeahead-src}/actor-typeahead.js . 29 # tailwindcss -c $src/tailwind.config.js -i $src/input.css -o tw.css won't work 30 # for whatever reason (produces broken css), so we are doing this instead 31 cd ${src} && ${tailwindcss}/bin/tailwindcss -i input.css -o $out/tw.css
··· 8 actor-typeahead-src, 9 sqlite-lib, 10 tailwindcss, 11 + dolly, 12 src, 13 }: 14 runCommandLocal "appview-static-files" { ··· 18 (allow file-read* (subpath "/System/Library/OpenSSL")) 19 ''; 20 } '' 21 + mkdir -p $out/{fonts,icons,logos} && cd $out 22 cp -f ${htmx-src} htmx.min.js 23 cp -f ${htmx-ws-src} htmx-ext-ws.min.js 24 cp -rf ${lucide-src}/*.svg icons/ ··· 27 cp -f ${inter-fonts-src}/InterVariable*.ttf fonts/ 28 cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono*.woff2 fonts/ 29 cp -f ${actor-typeahead-src}/actor-typeahead.js . 30 + 31 + ${dolly}/bin/dolly -output logos/dolly.png -size 180x180 32 + ${dolly}/bin/dolly -output logos/dolly.ico -size 48x48 33 + ${dolly}/bin/dolly -output logos/dolly.svg 34 # tailwindcss -c $src/tailwind.config.js -i $src/input.css -o tw.css won't work 35 # for whatever reason (produces broken css), so we are doing this instead 36 cd ${src} && ${tailwindcss}/bin/tailwindcss -i input.css -o $out/tw.css
+53
nix/pkgs/docs.nix
···
··· 1 + { 2 + pandoc, 3 + tailwindcss, 4 + runCommandLocal, 5 + inter-fonts-src, 6 + ibm-plex-mono-src, 7 + lucide-src, 8 + src, 9 + }: 10 + runCommandLocal "docs" {} '' 11 + mkdir -p working 12 + 13 + # copy templates, themes, styles, filters to working directory 14 + cp ${src}/docs/*.html working/ 15 + cp ${src}/docs/*.theme working/ 16 + cp ${src}/docs/*.css working/ 17 + 18 + # icons 19 + cp -rf ${lucide-src}/*.svg working/ 20 + 21 + # content - chunked 22 + ${pandoc}/bin/pandoc ${src}/docs/DOCS.md \ 23 + -o $out/ \ 24 + -t chunkedhtml \ 25 + --variable toc \ 26 + --variable-json single-page=false \ 27 + --toc-depth=2 \ 28 + --css=stylesheet.css \ 29 + --chunk-template="%i.html" \ 30 + --highlight-style=working/highlight.theme \ 31 + --template=working/template.html 32 + 33 + # content - single page 34 + ${pandoc}/bin/pandoc ${src}/docs/DOCS.md \ 35 + -o $out/single-page.html \ 36 + --toc \ 37 + --variable toc \ 38 + --variable single-page \ 39 + --toc-depth=2 \ 40 + --css=stylesheet.css \ 41 + --highlight-style=working/highlight.theme \ 42 + --template=working/template.html 43 + 44 + # fonts 45 + mkdir -p $out/static/fonts 46 + cp -f ${inter-fonts-src}/web/InterVariable*.woff2 $out/static/fonts/ 47 + cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 $out/static/fonts/ 48 + cp -f ${inter-fonts-src}/InterVariable*.ttf $out/static/fonts/ 49 + cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono*.woff2 $out/static/fonts/ 50 + 51 + # styles 52 + cd ${src} && ${tailwindcss}/bin/tailwindcss -i input.css -o $out/stylesheet.css 53 + ''
+21
nix/pkgs/dolly.nix
···
··· 1 + { 2 + buildGoApplication, 3 + modules, 4 + src, 5 + }: 6 + buildGoApplication { 7 + pname = "dolly"; 8 + version = "0.1.0"; 9 + inherit src modules; 10 + 11 + # patch the static dir 12 + postUnpack = '' 13 + pushd source 14 + mkdir -p appview/pages/static 15 + touch appview/pages/static/x 16 + popd 17 + ''; 18 + 19 + doCheck = false; 20 + subPackages = ["cmd/dolly"]; 21 + }
+7 -5
nix/pkgs/sqlite-lib.nix
··· 1 { 2 - gcc, 3 stdenv, 4 sqlite-lib-src, 5 }: 6 stdenv.mkDerivation { 7 name = "sqlite-lib"; 8 src = sqlite-lib-src; 9 - nativeBuildInputs = [gcc]; 10 buildPhase = '' 11 - gcc -c sqlite3.c 12 - ar rcs libsqlite3.a sqlite3.o 13 - ranlib libsqlite3.a 14 mkdir -p $out/include $out/lib 15 cp *.h $out/include 16 cp libsqlite3.a $out/lib
··· 1 { 2 stdenv, 3 sqlite-lib-src, 4 }: 5 stdenv.mkDerivation { 6 name = "sqlite-lib"; 7 src = sqlite-lib-src; 8 + 9 buildPhase = '' 10 + $CC -c sqlite3.c 11 + $AR rcs libsqlite3.a sqlite3.o 12 + $RANLIB libsqlite3.a 13 + ''; 14 + 15 + installPhase = '' 16 mkdir -p $out/include $out/lib 17 cp *.h $out/include 18 cp libsqlite3.a $out/lib
+5 -5
nix/vm.nix
··· 8 var = builtins.getEnv name; 9 in 10 if var == "" 11 - then throw "\$${name} must be defined, see docs/hacking.md for more details" 12 else var; 13 envVarOr = name: default: let 14 var = builtins.getEnv name; ··· 48 # knot 49 { 50 from = "host"; 51 - host.port = 6000; 52 - guest.port = 6000; 53 } 54 # spindle 55 { ··· 87 motd = "Welcome to the development knot!\n"; 88 server = { 89 owner = envVar "TANGLED_VM_KNOT_OWNER"; 90 - hostname = envVarOr "TANGLED_VM_KNOT_HOST" "localhost:6000"; 91 plcUrl = plcUrl; 92 jetstreamEndpoint = jetstream; 93 - listenAddr = "0.0.0.0:6000"; 94 }; 95 }; 96 services.tangled.spindle = {
··· 8 var = builtins.getEnv name; 9 in 10 if var == "" 11 + then throw "\$${name} must be defined, see https://docs.tangled.org/hacking-on-tangled.html#hacking-on-tangled for more details" 12 else var; 13 envVarOr = name: default: let 14 var = builtins.getEnv name; ··· 48 # knot 49 { 50 from = "host"; 51 + host.port = 6444; 52 + guest.port = 6444; 53 } 54 # spindle 55 { ··· 87 motd = "Welcome to the development knot!\n"; 88 server = { 89 owner = envVar "TANGLED_VM_KNOT_OWNER"; 90 + hostname = envVarOr "TANGLED_VM_KNOT_HOST" "localhost:6444"; 91 plcUrl = plcUrl; 92 jetstreamEndpoint = jetstream; 93 + listenAddr = "0.0.0.0:6444"; 94 }; 95 }; 96 services.tangled.spindle = {
+122
orm/orm.go
···
··· 1 + package orm 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + "fmt" 7 + "log/slog" 8 + "reflect" 9 + "strings" 10 + ) 11 + 12 + type migrationFn = func(*sql.Tx) error 13 + 14 + func RunMigration(c *sql.Conn, logger *slog.Logger, name string, migrationFn migrationFn) error { 15 + logger = logger.With("migration", name) 16 + 17 + tx, err := c.BeginTx(context.Background(), nil) 18 + if err != nil { 19 + return err 20 + } 21 + defer tx.Rollback() 22 + 23 + var exists bool 24 + err = tx.QueryRow("select exists (select 1 from migrations where name = ?)", name).Scan(&exists) 25 + if err != nil { 26 + return err 27 + } 28 + 29 + if !exists { 30 + // run migration 31 + err = migrationFn(tx) 32 + if err != nil { 33 + logger.Error("failed to run migration", "err", err) 34 + return err 35 + } 36 + 37 + // mark migration as complete 38 + _, err = tx.Exec("insert into migrations (name) values (?)", name) 39 + if err != nil { 40 + logger.Error("failed to mark migration as complete", "err", err) 41 + return err 42 + } 43 + 44 + // commit the transaction 45 + if err := tx.Commit(); err != nil { 46 + return err 47 + } 48 + 49 + logger.Info("migration applied successfully") 50 + } else { 51 + logger.Warn("skipped migration, already applied") 52 + } 53 + 54 + return nil 55 + } 56 + 57 + type Filter struct { 58 + Key string 59 + arg any 60 + Cmp string 61 + } 62 + 63 + func newFilter(key, cmp string, arg any) Filter { 64 + return Filter{ 65 + Key: key, 66 + arg: arg, 67 + Cmp: cmp, 68 + } 69 + } 70 + 71 + func FilterEq(key string, arg any) Filter { return newFilter(key, "=", arg) } 72 + func FilterNotEq(key string, arg any) Filter { return newFilter(key, "<>", arg) } 73 + func FilterGte(key string, arg any) Filter { return newFilter(key, ">=", arg) } 74 + func FilterLte(key string, arg any) Filter { return newFilter(key, "<=", arg) } 75 + func FilterIs(key string, arg any) Filter { return newFilter(key, "is", arg) } 76 + func FilterIsNot(key string, arg any) Filter { return newFilter(key, "is not", arg) } 77 + func FilterIn(key string, arg any) Filter { return newFilter(key, "in", arg) } 78 + func FilterLike(key string, arg any) Filter { return newFilter(key, "like", arg) } 79 + func FilterNotLike(key string, arg any) Filter { return newFilter(key, "not like", arg) } 80 + func FilterContains(key string, arg any) Filter { 81 + return newFilter(key, "like", fmt.Sprintf("%%%v%%", arg)) 82 + } 83 + 84 + func (f Filter) Condition() string { 85 + rv := reflect.ValueOf(f.arg) 86 + kind := rv.Kind() 87 + 88 + // if we have `FilterIn(k, [1, 2, 3])`, compile it down to `k in (?, ?, ?)` 89 + if (kind == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8) || kind == reflect.Array { 90 + if rv.Len() == 0 { 91 + // always false 92 + return "1 = 0" 93 + } 94 + 95 + placeholders := make([]string, rv.Len()) 96 + for i := range placeholders { 97 + placeholders[i] = "?" 98 + } 99 + 100 + return fmt.Sprintf("%s %s (%s)", f.Key, f.Cmp, strings.Join(placeholders, ", ")) 101 + } 102 + 103 + return fmt.Sprintf("%s %s ?", f.Key, f.Cmp) 104 + } 105 + 106 + func (f Filter) Arg() []any { 107 + rv := reflect.ValueOf(f.arg) 108 + kind := rv.Kind() 109 + if (kind == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8) || kind == reflect.Array { 110 + if rv.Len() == 0 { 111 + return nil 112 + } 113 + 114 + out := make([]any, rv.Len()) 115 + for i := range rv.Len() { 116 + out[i] = rv.Index(i).Interface() 117 + } 118 + return out 119 + } 120 + 121 + return []any{f.arg} 122 + }
-1
patchutil/patchutil.go
··· 296 } 297 298 nd := types.NiceDiff{} 299 - nd.Commit.Parent = targetBranch 300 301 for _, d := range diffs { 302 ndiff := types.Diff{}
··· 296 } 297 298 nd := types.NiceDiff{} 299 300 for _, d := range diffs { 301 ndiff := types.Diff{}
+8
rbac/rbac.go
··· 285 return e.E.Enforce(user, domain, repo, "repo:delete") 286 } 287 288 func (e *Enforcer) IsPushAllowed(user, domain, repo string) (bool, error) { 289 return e.E.Enforce(user, domain, repo, "repo:push") 290 }
··· 285 return e.E.Enforce(user, domain, repo, "repo:delete") 286 } 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 + 296 func (e *Enforcer) IsPushAllowed(user, domain, repo string) (bool, error) { 297 return e.E.Enforce(user, domain, repo, "repo:push") 298 }
+3 -3
readme.md
··· 10 11 ## docs 12 13 - * [knot hosting guide](/docs/knot-hosting.md) 14 - * [contributing guide](/docs/contributing.md) **please read before opening a PR!** 15 - * [hacking on tangled](/docs/hacking.md) 16 17 ## security 18
··· 10 11 ## docs 12 13 + - [knot hosting guide](https://docs.tangled.org/knot-self-hosting-guide.html#knot-self-hosting-guide) 14 + - [contributing guide](https://docs.tangled.org/contribution-guide.html#contribution-guide) **please read before opening a PR!** 15 + - [hacking on tangled](https://docs.tangled.org/hacking-on-tangled.html#hacking-on-tangled) 16 17 ## security 18
+31
sets/gen.go
···
··· 1 + package sets 2 + 3 + import ( 4 + "math/rand" 5 + "reflect" 6 + "testing/quick" 7 + ) 8 + 9 + func (_ Set[T]) Generate(rand *rand.Rand, size int) reflect.Value { 10 + s := New[T]() 11 + 12 + var zero T 13 + itemType := reflect.TypeOf(zero) 14 + 15 + for { 16 + if s.Len() >= size { 17 + break 18 + } 19 + 20 + item, ok := quick.Value(itemType, rand) 21 + if !ok { 22 + continue 23 + } 24 + 25 + if val, ok := item.Interface().(T); ok { 26 + s.Insert(val) 27 + } 28 + } 29 + 30 + return reflect.ValueOf(s) 31 + }
+35
sets/readme.txt
···
··· 1 + sets 2 + ---- 3 + set datastructure for go with generics and iterators. the 4 + api is supposed to mimic rust's std::collections::HashSet api. 5 + 6 + s1 := sets.Collect(slices.Values([]int{1, 2, 3, 4})) 7 + s2 := sets.Collect(slices.Values([]int{1, 2, 3, 4, 5, 6})) 8 + 9 + union := sets.Collect(s1.Union(s2)) 10 + intersect := sets.Collect(s1.Intersection(s2)) 11 + diff := sets.Collect(s1.Difference(s2)) 12 + symdiff := sets.Collect(s1.SymmetricDifference(s2)) 13 + 14 + s1.Len() // 4 15 + s1.Contains(1) // true 16 + s1.IsEmpty() // false 17 + s1.IsSubset(s2) // true 18 + s1.IsSuperset(s2) // false 19 + s1.IsDisjoint(s2) // false 20 + 21 + if exists := s1.Insert(1); exists { 22 + // already existed in set 23 + } 24 + 25 + if existed := s1.Remove(1); existed { 26 + // existed in set, now removed 27 + } 28 + 29 + 30 + testing 31 + ------- 32 + includes property-based tests using the wonderful 33 + testing/quick module! 34 + 35 + go test -v
+174
sets/set.go
···
··· 1 + package sets 2 + 3 + import ( 4 + "iter" 5 + "maps" 6 + ) 7 + 8 + type Set[T comparable] struct { 9 + data map[T]struct{} 10 + } 11 + 12 + func New[T comparable]() Set[T] { 13 + return Set[T]{ 14 + data: make(map[T]struct{}), 15 + } 16 + } 17 + 18 + func (s *Set[T]) Insert(item T) bool { 19 + _, exists := s.data[item] 20 + s.data[item] = struct{}{} 21 + return !exists 22 + } 23 + 24 + func Singleton[T comparable](item T) Set[T] { 25 + n := New[T]() 26 + _ = n.Insert(item) 27 + return n 28 + } 29 + 30 + func (s *Set[T]) Remove(item T) bool { 31 + _, exists := s.data[item] 32 + if exists { 33 + delete(s.data, item) 34 + } 35 + return exists 36 + } 37 + 38 + func (s Set[T]) Contains(item T) bool { 39 + _, exists := s.data[item] 40 + return exists 41 + } 42 + 43 + func (s Set[T]) Len() int { 44 + return len(s.data) 45 + } 46 + 47 + func (s Set[T]) IsEmpty() bool { 48 + return len(s.data) == 0 49 + } 50 + 51 + func (s *Set[T]) Clear() { 52 + s.data = make(map[T]struct{}) 53 + } 54 + 55 + func (s Set[T]) All() iter.Seq[T] { 56 + return func(yield func(T) bool) { 57 + for item := range s.data { 58 + if !yield(item) { 59 + return 60 + } 61 + } 62 + } 63 + } 64 + 65 + func (s Set[T]) Clone() Set[T] { 66 + return Set[T]{ 67 + data: maps.Clone(s.data), 68 + } 69 + } 70 + 71 + func (s Set[T]) Union(other Set[T]) iter.Seq[T] { 72 + if s.Len() >= other.Len() { 73 + return chain(s.All(), other.Difference(s)) 74 + } else { 75 + return chain(other.All(), s.Difference(other)) 76 + } 77 + } 78 + 79 + func chain[T any](seqs ...iter.Seq[T]) iter.Seq[T] { 80 + return func(yield func(T) bool) { 81 + for _, seq := range seqs { 82 + for item := range seq { 83 + if !yield(item) { 84 + return 85 + } 86 + } 87 + } 88 + } 89 + } 90 + 91 + func (s Set[T]) Intersection(other Set[T]) iter.Seq[T] { 92 + return func(yield func(T) bool) { 93 + for item := range s.data { 94 + if other.Contains(item) { 95 + if !yield(item) { 96 + return 97 + } 98 + } 99 + } 100 + } 101 + } 102 + 103 + func (s Set[T]) Difference(other Set[T]) iter.Seq[T] { 104 + return func(yield func(T) bool) { 105 + for item := range s.data { 106 + if !other.Contains(item) { 107 + if !yield(item) { 108 + return 109 + } 110 + } 111 + } 112 + } 113 + } 114 + 115 + func (s Set[T]) SymmetricDifference(other Set[T]) iter.Seq[T] { 116 + return func(yield func(T) bool) { 117 + for item := range s.data { 118 + if !other.Contains(item) { 119 + if !yield(item) { 120 + return 121 + } 122 + } 123 + } 124 + for item := range other.data { 125 + if !s.Contains(item) { 126 + if !yield(item) { 127 + return 128 + } 129 + } 130 + } 131 + } 132 + } 133 + 134 + func (s Set[T]) IsSubset(other Set[T]) bool { 135 + for item := range s.data { 136 + if !other.Contains(item) { 137 + return false 138 + } 139 + } 140 + return true 141 + } 142 + 143 + func (s Set[T]) IsSuperset(other Set[T]) bool { 144 + return other.IsSubset(s) 145 + } 146 + 147 + func (s Set[T]) IsDisjoint(other Set[T]) bool { 148 + for item := range s.data { 149 + if other.Contains(item) { 150 + return false 151 + } 152 + } 153 + return true 154 + } 155 + 156 + func (s Set[T]) Equal(other Set[T]) bool { 157 + if s.Len() != other.Len() { 158 + return false 159 + } 160 + for item := range s.data { 161 + if !other.Contains(item) { 162 + return false 163 + } 164 + } 165 + return true 166 + } 167 + 168 + func Collect[T comparable](seq iter.Seq[T]) Set[T] { 169 + result := New[T]() 170 + for item := range seq { 171 + result.Insert(item) 172 + } 173 + return result 174 + }
+411
sets/set_test.go
···
··· 1 + package sets 2 + 3 + import ( 4 + "slices" 5 + "testing" 6 + "testing/quick" 7 + ) 8 + 9 + func TestNew(t *testing.T) { 10 + s := New[int]() 11 + if s.Len() != 0 { 12 + t.Errorf("New set should be empty, got length %d", s.Len()) 13 + } 14 + if !s.IsEmpty() { 15 + t.Error("New set should be empty") 16 + } 17 + } 18 + 19 + func TestFromSlice(t *testing.T) { 20 + s := Collect(slices.Values([]int{1, 2, 3, 2, 1})) 21 + if s.Len() != 3 { 22 + t.Errorf("Expected length 3, got %d", s.Len()) 23 + } 24 + if !s.Contains(1) || !s.Contains(2) || !s.Contains(3) { 25 + t.Error("Set should contain all unique elements from slice") 26 + } 27 + } 28 + 29 + func TestInsert(t *testing.T) { 30 + s := New[string]() 31 + 32 + if !s.Insert("hello") { 33 + t.Error("First insert should return true") 34 + } 35 + if s.Insert("hello") { 36 + t.Error("Duplicate insert should return false") 37 + } 38 + if s.Len() != 1 { 39 + t.Errorf("Expected length 1, got %d", s.Len()) 40 + } 41 + } 42 + 43 + func TestRemove(t *testing.T) { 44 + s := Collect(slices.Values([]int{1, 2, 3})) 45 + 46 + if !s.Remove(2) { 47 + t.Error("Remove existing element should return true") 48 + } 49 + if s.Remove(2) { 50 + t.Error("Remove non-existing element should return false") 51 + } 52 + if s.Contains(2) { 53 + t.Error("Element should be removed") 54 + } 55 + if s.Len() != 2 { 56 + t.Errorf("Expected length 2, got %d", s.Len()) 57 + } 58 + } 59 + 60 + func TestContains(t *testing.T) { 61 + s := Collect(slices.Values([]int{1, 2, 3})) 62 + 63 + if !s.Contains(1) { 64 + t.Error("Should contain 1") 65 + } 66 + if s.Contains(4) { 67 + t.Error("Should not contain 4") 68 + } 69 + } 70 + 71 + func TestClear(t *testing.T) { 72 + s := Collect(slices.Values([]int{1, 2, 3})) 73 + s.Clear() 74 + 75 + if !s.IsEmpty() { 76 + t.Error("Set should be empty after clear") 77 + } 78 + if s.Len() != 0 { 79 + t.Errorf("Expected length 0, got %d", s.Len()) 80 + } 81 + } 82 + 83 + func TestIterator(t *testing.T) { 84 + s := Collect(slices.Values([]int{1, 2, 3})) 85 + var items []int 86 + 87 + for item := range s.All() { 88 + items = append(items, item) 89 + } 90 + 91 + slices.Sort(items) 92 + expected := []int{1, 2, 3} 93 + if !slices.Equal(items, expected) { 94 + t.Errorf("Expected %v, got %v", expected, items) 95 + } 96 + } 97 + 98 + func TestClone(t *testing.T) { 99 + s1 := Collect(slices.Values([]int{1, 2, 3})) 100 + s2 := s1.Clone() 101 + 102 + if !s1.Equal(s2) { 103 + t.Error("Cloned set should be equal to original") 104 + } 105 + 106 + s2.Insert(4) 107 + if s1.Contains(4) { 108 + t.Error("Modifying clone should not affect original") 109 + } 110 + } 111 + 112 + func TestUnion(t *testing.T) { 113 + s1 := Collect(slices.Values([]int{1, 2})) 114 + s2 := Collect(slices.Values([]int{2, 3})) 115 + 116 + result := Collect(s1.Union(s2)) 117 + expected := Collect(slices.Values([]int{1, 2, 3})) 118 + 119 + if !result.Equal(expected) { 120 + t.Errorf("Expected %v, got %v", expected, result) 121 + } 122 + } 123 + 124 + func TestIntersection(t *testing.T) { 125 + s1 := Collect(slices.Values([]int{1, 2, 3})) 126 + s2 := Collect(slices.Values([]int{2, 3, 4})) 127 + 128 + expected := Collect(slices.Values([]int{2, 3})) 129 + result := Collect(s1.Intersection(s2)) 130 + 131 + if !result.Equal(expected) { 132 + t.Errorf("Expected %v, got %v", expected, result) 133 + } 134 + } 135 + 136 + func TestDifference(t *testing.T) { 137 + s1 := Collect(slices.Values([]int{1, 2, 3})) 138 + s2 := Collect(slices.Values([]int{2, 3, 4})) 139 + 140 + expected := Collect(slices.Values([]int{1})) 141 + result := Collect(s1.Difference(s2)) 142 + 143 + if !result.Equal(expected) { 144 + t.Errorf("Expected %v, got %v", expected, result) 145 + } 146 + } 147 + 148 + func TestSymmetricDifference(t *testing.T) { 149 + s1 := Collect(slices.Values([]int{1, 2, 3})) 150 + s2 := Collect(slices.Values([]int{2, 3, 4})) 151 + 152 + expected := Collect(slices.Values([]int{1, 4})) 153 + result := Collect(s1.SymmetricDifference(s2)) 154 + 155 + if !result.Equal(expected) { 156 + t.Errorf("Expected %v, got %v", expected, result) 157 + } 158 + } 159 + 160 + func TestSymmetricDifferenceCommutativeProperty(t *testing.T) { 161 + s1 := Collect(slices.Values([]int{1, 2, 3})) 162 + s2 := Collect(slices.Values([]int{2, 3, 4})) 163 + 164 + result1 := Collect(s1.SymmetricDifference(s2)) 165 + result2 := Collect(s2.SymmetricDifference(s1)) 166 + 167 + if !result1.Equal(result2) { 168 + t.Errorf("Expected %v, got %v", result1, result2) 169 + } 170 + } 171 + 172 + func TestIsSubset(t *testing.T) { 173 + s1 := Collect(slices.Values([]int{1, 2})) 174 + s2 := Collect(slices.Values([]int{1, 2, 3})) 175 + 176 + if !s1.IsSubset(s2) { 177 + t.Error("s1 should be subset of s2") 178 + } 179 + if s2.IsSubset(s1) { 180 + t.Error("s2 should not be subset of s1") 181 + } 182 + } 183 + 184 + func TestIsSuperset(t *testing.T) { 185 + s1 := Collect(slices.Values([]int{1, 2, 3})) 186 + s2 := Collect(slices.Values([]int{1, 2})) 187 + 188 + if !s1.IsSuperset(s2) { 189 + t.Error("s1 should be superset of s2") 190 + } 191 + if s2.IsSuperset(s1) { 192 + t.Error("s2 should not be superset of s1") 193 + } 194 + } 195 + 196 + func TestIsDisjoint(t *testing.T) { 197 + s1 := Collect(slices.Values([]int{1, 2})) 198 + s2 := Collect(slices.Values([]int{3, 4})) 199 + s3 := Collect(slices.Values([]int{2, 3})) 200 + 201 + if !s1.IsDisjoint(s2) { 202 + t.Error("s1 and s2 should be disjoint") 203 + } 204 + if s1.IsDisjoint(s3) { 205 + t.Error("s1 and s3 should not be disjoint") 206 + } 207 + } 208 + 209 + func TestEqual(t *testing.T) { 210 + s1 := Collect(slices.Values([]int{1, 2, 3})) 211 + s2 := Collect(slices.Values([]int{3, 2, 1})) 212 + s3 := Collect(slices.Values([]int{1, 2})) 213 + 214 + if !s1.Equal(s2) { 215 + t.Error("s1 and s2 should be equal") 216 + } 217 + if s1.Equal(s3) { 218 + t.Error("s1 and s3 should not be equal") 219 + } 220 + } 221 + 222 + func TestCollect(t *testing.T) { 223 + s1 := Collect(slices.Values([]int{1, 2})) 224 + s2 := Collect(slices.Values([]int{2, 3})) 225 + 226 + unionSet := Collect(s1.Union(s2)) 227 + if unionSet.Len() != 3 { 228 + t.Errorf("Expected union set length 3, got %d", unionSet.Len()) 229 + } 230 + if !unionSet.Contains(1) || !unionSet.Contains(2) || !unionSet.Contains(3) { 231 + t.Error("Union set should contain 1, 2, and 3") 232 + } 233 + 234 + diffSet := Collect(s1.Difference(s2)) 235 + if diffSet.Len() != 1 { 236 + t.Errorf("Expected difference set length 1, got %d", diffSet.Len()) 237 + } 238 + if !diffSet.Contains(1) { 239 + t.Error("Difference set should contain 1") 240 + } 241 + } 242 + 243 + func TestPropertySingleonLen(t *testing.T) { 244 + f := func(item int) bool { 245 + single := Singleton(item) 246 + return single.Len() == 1 247 + } 248 + 249 + if err := quick.Check(f, nil); err != nil { 250 + t.Error(err) 251 + } 252 + } 253 + 254 + func TestPropertyInsertIdempotent(t *testing.T) { 255 + f := func(s Set[int], item int) bool { 256 + clone := s.Clone() 257 + 258 + clone.Insert(item) 259 + firstLen := clone.Len() 260 + 261 + clone.Insert(item) 262 + secondLen := clone.Len() 263 + 264 + return firstLen == secondLen 265 + } 266 + 267 + if err := quick.Check(f, nil); err != nil { 268 + t.Error(err) 269 + } 270 + } 271 + 272 + func TestPropertyUnionCommutative(t *testing.T) { 273 + f := func(s1 Set[int], s2 Set[int]) bool { 274 + union1 := Collect(s1.Union(s2)) 275 + union2 := Collect(s2.Union(s1)) 276 + return union1.Equal(union2) 277 + } 278 + 279 + if err := quick.Check(f, nil); err != nil { 280 + t.Error(err) 281 + } 282 + } 283 + 284 + func TestPropertyIntersectionCommutative(t *testing.T) { 285 + f := func(s1 Set[int], s2 Set[int]) bool { 286 + inter1 := Collect(s1.Intersection(s2)) 287 + inter2 := Collect(s2.Intersection(s1)) 288 + return inter1.Equal(inter2) 289 + } 290 + 291 + if err := quick.Check(f, nil); err != nil { 292 + t.Error(err) 293 + } 294 + } 295 + 296 + func TestPropertyCloneEquals(t *testing.T) { 297 + f := func(s Set[int]) bool { 298 + clone := s.Clone() 299 + return s.Equal(clone) 300 + } 301 + 302 + if err := quick.Check(f, nil); err != nil { 303 + t.Error(err) 304 + } 305 + } 306 + 307 + func TestPropertyIntersectionIsSubset(t *testing.T) { 308 + f := func(s1 Set[int], s2 Set[int]) bool { 309 + inter := Collect(s1.Intersection(s2)) 310 + return inter.IsSubset(s1) && inter.IsSubset(s2) 311 + } 312 + 313 + if err := quick.Check(f, nil); err != nil { 314 + t.Error(err) 315 + } 316 + } 317 + 318 + func TestPropertyUnionIsSuperset(t *testing.T) { 319 + f := func(s1 Set[int], s2 Set[int]) bool { 320 + union := Collect(s1.Union(s2)) 321 + return union.IsSuperset(s1) && union.IsSuperset(s2) 322 + } 323 + 324 + if err := quick.Check(f, nil); err != nil { 325 + t.Error(err) 326 + } 327 + } 328 + 329 + func TestPropertyDifferenceDisjoint(t *testing.T) { 330 + f := func(s1 Set[int], s2 Set[int]) bool { 331 + diff := Collect(s1.Difference(s2)) 332 + return diff.IsDisjoint(s2) 333 + } 334 + 335 + if err := quick.Check(f, nil); err != nil { 336 + t.Error(err) 337 + } 338 + } 339 + 340 + func TestPropertySymmetricDifferenceCommutative(t *testing.T) { 341 + f := func(s1 Set[int], s2 Set[int]) bool { 342 + symDiff1 := Collect(s1.SymmetricDifference(s2)) 343 + symDiff2 := Collect(s2.SymmetricDifference(s1)) 344 + return symDiff1.Equal(symDiff2) 345 + } 346 + 347 + if err := quick.Check(f, nil); err != nil { 348 + t.Error(err) 349 + } 350 + } 351 + 352 + func TestPropertyRemoveWorks(t *testing.T) { 353 + f := func(s Set[int], item int) bool { 354 + clone := s.Clone() 355 + clone.Insert(item) 356 + clone.Remove(item) 357 + return !clone.Contains(item) 358 + } 359 + 360 + if err := quick.Check(f, nil); err != nil { 361 + t.Error(err) 362 + } 363 + } 364 + 365 + func TestPropertyClearEmpty(t *testing.T) { 366 + f := func(s Set[int]) bool { 367 + s.Clear() 368 + return s.IsEmpty() && s.Len() == 0 369 + } 370 + 371 + if err := quick.Check(f, nil); err != nil { 372 + t.Error(err) 373 + } 374 + } 375 + 376 + func TestPropertyIsSubsetReflexive(t *testing.T) { 377 + f := func(s Set[int]) bool { 378 + return s.IsSubset(s) 379 + } 380 + 381 + if err := quick.Check(f, nil); err != nil { 382 + t.Error(err) 383 + } 384 + } 385 + 386 + func TestPropertyDeMorganUnion(t *testing.T) { 387 + f := func(s1 Set[int], s2 Set[int], universe Set[int]) bool { 388 + // create a universe that contains both sets 389 + u := universe.Clone() 390 + for item := range s1.All() { 391 + u.Insert(item) 392 + } 393 + for item := range s2.All() { 394 + u.Insert(item) 395 + } 396 + 397 + // (A u B)' = A' n B' 398 + union := Collect(s1.Union(s2)) 399 + complementUnion := Collect(u.Difference(union)) 400 + 401 + complementS1 := Collect(u.Difference(s1)) 402 + complementS2 := Collect(u.Difference(s2)) 403 + intersectionComplements := Collect(complementS1.Intersection(complementS2)) 404 + 405 + return complementUnion.Equal(intersectionComplements) 406 + } 407 + 408 + if err := quick.Check(f, nil); err != nil { 409 + t.Error(err) 410 + } 411 + }
+1
spindle/db/repos.go
··· 16 if err != nil { 17 return nil, err 18 } 19 20 var knots []string 21 for rows.Next() {
··· 16 if err != nil { 17 return nil, err 18 } 19 + defer rows.Close() 20 21 var knots []string 22 for rows.Next() {
+22 -21
spindle/engine/engine.go
··· 3 import ( 4 "context" 5 "errors" 6 - "fmt" 7 "log/slog" 8 9 securejoin "github.com/cyphar/filepath-securejoin" 10 - "golang.org/x/sync/errgroup" 11 "tangled.org/core/notifier" 12 "tangled.org/core/spindle/config" 13 "tangled.org/core/spindle/db" ··· 31 } 32 } 33 34 - eg, ctx := errgroup.WithContext(ctx) 35 for eng, wfs := range pipeline.Workflows { 36 workflowTimeout := eng.WorkflowTimeout() 37 l.Info("using workflow timeout", "timeout", workflowTimeout) 38 39 for _, w := range wfs { 40 - eg.Go(func() error { 41 wid := models.WorkflowId{ 42 PipelineId: pipelineId, 43 Name: w.Name, ··· 45 46 err := db.StatusRunning(wid, n) 47 if err != nil { 48 - return err 49 } 50 51 err = eng.SetupWorkflow(ctx, wid, &w) ··· 61 62 dbErr := db.StatusFailed(wid, err.Error(), -1, n) 63 if dbErr != nil { 64 - return dbErr 65 } 66 - return err 67 } 68 defer eng.DestroyWorkflow(ctx, wid) 69 70 - wfLogger, err := models.NewWorkflowLogger(cfg.Server.LogDir, wid) 71 if err != nil { 72 l.Warn("failed to setup step logger; logs will not be persisted", "error", err) 73 wfLogger = nil ··· 99 if errors.Is(err, ErrTimedOut) { 100 dbErr := db.StatusTimeout(wid, n) 101 if dbErr != nil { 102 - return dbErr 103 } 104 } else { 105 dbErr := db.StatusFailed(wid, err.Error(), -1, n) 106 if dbErr != nil { 107 - return dbErr 108 } 109 } 110 - 111 - return fmt.Errorf("starting steps image: %w", err) 112 } 113 } 114 115 err = db.StatusSuccess(wid, n) 116 if err != nil { 117 - return err 118 } 119 - 120 - return nil 121 - }) 122 } 123 } 124 125 - if err := eg.Wait(); err != nil { 126 - l.Error("failed to run one or more workflows", "err", err) 127 - } else { 128 - l.Info("successfully ran full pipeline") 129 - } 130 }
··· 3 import ( 4 "context" 5 "errors" 6 "log/slog" 7 + "sync" 8 9 securejoin "github.com/cyphar/filepath-securejoin" 10 "tangled.org/core/notifier" 11 "tangled.org/core/spindle/config" 12 "tangled.org/core/spindle/db" ··· 30 } 31 } 32 33 + var wg sync.WaitGroup 34 for eng, wfs := range pipeline.Workflows { 35 workflowTimeout := eng.WorkflowTimeout() 36 l.Info("using workflow timeout", "timeout", workflowTimeout) 37 38 for _, w := range wfs { 39 + wg.Add(1) 40 + go func() { 41 + defer wg.Done() 42 + 43 wid := models.WorkflowId{ 44 PipelineId: pipelineId, 45 Name: w.Name, ··· 47 48 err := db.StatusRunning(wid, n) 49 if err != nil { 50 + l.Error("failed to set workflow status to running", "wid", wid, "err", err) 51 + return 52 } 53 54 err = eng.SetupWorkflow(ctx, wid, &w) ··· 64 65 dbErr := db.StatusFailed(wid, err.Error(), -1, n) 66 if dbErr != nil { 67 + l.Error("failed to set workflow status to failed", "wid", wid, "err", dbErr) 68 } 69 + return 70 } 71 defer eng.DestroyWorkflow(ctx, wid) 72 73 + secretValues := make([]string, len(allSecrets)) 74 + for i, s := range allSecrets { 75 + secretValues[i] = s.Value 76 + } 77 + wfLogger, err := models.NewWorkflowLogger(cfg.Server.LogDir, wid, secretValues) 78 if err != nil { 79 l.Warn("failed to setup step logger; logs will not be persisted", "error", err) 80 wfLogger = nil ··· 106 if errors.Is(err, ErrTimedOut) { 107 dbErr := db.StatusTimeout(wid, n) 108 if dbErr != nil { 109 + l.Error("failed to set workflow status to timeout", "wid", wid, "err", dbErr) 110 } 111 } else { 112 dbErr := db.StatusFailed(wid, err.Error(), -1, n) 113 if dbErr != nil { 114 + l.Error("failed to set workflow status to failed", "wid", wid, "err", dbErr) 115 } 116 } 117 + return 118 } 119 } 120 121 err = db.StatusSuccess(wid, n) 122 if err != nil { 123 + l.Error("failed to set workflow status to success", "wid", wid, "err", err) 124 } 125 + }() 126 } 127 } 128 129 + wg.Wait() 130 + l.Info("all workflows completed") 131 }
+9 -8
spindle/engines/nixery/engine.go
··· 73 type addlFields struct { 74 image string 75 container string 76 - env map[string]string 77 } 78 79 func (e *Engine) InitWorkflow(twf tangled.Pipeline_Workflow, tpl tangled.Pipeline) (*models.Workflow, error) { ··· 103 swf.Steps = append(swf.Steps, sstep) 104 } 105 swf.Name = twf.Name 106 - addl.env = dwf.Environment 107 addl.image = workflowImage(dwf.Dependencies, e.cfg.NixeryPipelines.Nixery) 108 109 setup := &setupSteps{} ··· 288 289 func (e *Engine) RunStep(ctx context.Context, wid models.WorkflowId, w *models.Workflow, idx int, secrets []secrets.UnlockedSecret, wfLogger *models.WorkflowLogger) error { 290 addl := w.Data.(addlFields) 291 - workflowEnvs := ConstructEnvs(addl.env) 292 // TODO(winter): should SetupWorkflow also have secret access? 293 // IMO yes, but probably worth thinking on. 294 for _, s := range secrets { 295 workflowEnvs.AddEnv(s.Key, s.Value) 296 } 297 298 - step := w.Steps[idx].(Step) 299 300 select { 301 case <-ctx.Done(): ··· 304 } 305 306 envs := append(EnvVars(nil), workflowEnvs...) 307 - for k, v := range step.environment { 308 - envs.AddEnv(k, v) 309 } 310 envs.AddEnv("HOME", homeDir) 311 312 mkExecResp, err := e.docker.ContainerExecCreate(ctx, addl.container, container.ExecOptions{ 313 - Cmd: []string{"bash", "-c", step.command}, 314 AttachStdout: true, 315 AttachStderr: true, 316 Env: envs, ··· 333 // Docker doesn't provide an API to kill an exec run 334 // (sure, we could grab the PID and kill it ourselves, 335 // but that's wasted effort) 336 - e.l.Warn("step timed out", "step", step.Name) 337 338 <-tailDone 339
··· 73 type addlFields struct { 74 image string 75 container string 76 } 77 78 func (e *Engine) InitWorkflow(twf tangled.Pipeline_Workflow, tpl tangled.Pipeline) (*models.Workflow, error) { ··· 102 swf.Steps = append(swf.Steps, sstep) 103 } 104 swf.Name = twf.Name 105 + swf.Environment = dwf.Environment 106 addl.image = workflowImage(dwf.Dependencies, e.cfg.NixeryPipelines.Nixery) 107 108 setup := &setupSteps{} ··· 287 288 func (e *Engine) RunStep(ctx context.Context, wid models.WorkflowId, w *models.Workflow, idx int, secrets []secrets.UnlockedSecret, wfLogger *models.WorkflowLogger) error { 289 addl := w.Data.(addlFields) 290 + workflowEnvs := ConstructEnvs(w.Environment) 291 // TODO(winter): should SetupWorkflow also have secret access? 292 // IMO yes, but probably worth thinking on. 293 for _, s := range secrets { 294 workflowEnvs.AddEnv(s.Key, s.Value) 295 } 296 297 + step := w.Steps[idx] 298 299 select { 300 case <-ctx.Done(): ··· 303 } 304 305 envs := append(EnvVars(nil), workflowEnvs...) 306 + if nixStep, ok := step.(Step); ok { 307 + for k, v := range nixStep.environment { 308 + envs.AddEnv(k, v) 309 + } 310 } 311 envs.AddEnv("HOME", homeDir) 312 313 mkExecResp, err := e.docker.ContainerExecCreate(ctx, addl.container, container.ExecOptions{ 314 + Cmd: []string{"bash", "-c", step.Command()}, 315 AttachStdout: true, 316 AttachStderr: true, 317 Env: envs, ··· 334 // Docker doesn't provide an API to kill an exec run 335 // (sure, we could grab the PID and kill it ourselves, 336 // but that's wasted effort) 337 + e.l.Warn("step timed out", "step", step.Name()) 338 339 <-tailDone 340
+6 -7
spindle/models/clone.go
··· 55 } 56 } 57 58 - repoURL := buildRepoURL(tr, dev) 59 60 var cloneOpts tangled.Pipeline_CloneOpts 61 if twf.Clone != nil { ··· 101 } 102 } 103 104 - // buildRepoURL constructs the repository URL from trigger metadata 105 - func buildRepoURL(tr tangled.Pipeline_TriggerMetadata, devMode bool) string { 106 - if tr.Repo == nil { 107 return "" 108 } 109 110 - // Determine protocol 111 scheme := "https://" 112 if devMode { 113 scheme = "http://" 114 } 115 116 // Get host from knot 117 - host := tr.Repo.Knot 118 119 // In dev mode, replace localhost with host.docker.internal for Docker networking 120 if devMode && strings.Contains(host, "localhost") { ··· 122 } 123 124 // Build URL: {scheme}{knot}/{did}/{repo} 125 - return fmt.Sprintf("%s%s/%s/%s", scheme, host, tr.Repo.Did, tr.Repo.Repo) 126 } 127 128 // buildFetchArgs constructs the arguments for git fetch based on clone options
··· 55 } 56 } 57 58 + repoURL := BuildRepoURL(tr.Repo, dev) 59 60 var cloneOpts tangled.Pipeline_CloneOpts 61 if twf.Clone != nil { ··· 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") { ··· 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
+6 -1
spindle/models/logger.go
··· 12 type WorkflowLogger struct { 13 file *os.File 14 encoder *json.Encoder 15 } 16 17 - func NewWorkflowLogger(baseDir string, wid WorkflowId) (*WorkflowLogger, error) { 18 path := LogFilePath(baseDir, wid) 19 20 file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) ··· 25 return &WorkflowLogger{ 26 file: file, 27 encoder: json.NewEncoder(file), 28 }, nil 29 } 30 ··· 62 63 func (w *dataWriter) Write(p []byte) (int, error) { 64 line := strings.TrimRight(string(p), "\r\n") 65 entry := NewDataLogLine(w.idx, line, w.stream) 66 if err := w.logger.encoder.Encode(entry); err != nil { 67 return 0, err
··· 12 type WorkflowLogger struct { 13 file *os.File 14 encoder *json.Encoder 15 + mask *SecretMask 16 } 17 18 + func NewWorkflowLogger(baseDir string, wid WorkflowId, secretValues []string) (*WorkflowLogger, error) { 19 path := LogFilePath(baseDir, wid) 20 21 file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) ··· 26 return &WorkflowLogger{ 27 file: file, 28 encoder: json.NewEncoder(file), 29 + mask: NewSecretMask(secretValues), 30 }, nil 31 } 32 ··· 64 65 func (w *dataWriter) Write(p []byte) (int, error) { 66 line := strings.TrimRight(string(p), "\r\n") 67 + if w.logger.mask != nil { 68 + line = w.logger.mask.Mask(line) 69 + } 70 entry := NewDataLogLine(w.idx, line, w.stream) 71 if err := w.logger.encoder.Encode(entry); err != nil { 72 return 0, err
+4 -3
spindle/models/pipeline.go
··· 22 ) 23 24 type Workflow struct { 25 - Steps []Step 26 - Name string 27 - Data any 28 }
··· 22 ) 23 24 type Workflow struct { 25 + Steps []Step 26 + Name string 27 + Data any 28 + Environment map[string]string 29 }
+77
spindle/models/pipeline_env.go
···
··· 1 + package models 2 + 3 + import ( 4 + "strings" 5 + 6 + "github.com/go-git/go-git/v5/plumbing" 7 + "tangled.org/core/api/tangled" 8 + "tangled.org/core/workflow" 9 + ) 10 + 11 + // PipelineEnvVars extracts environment variables from pipeline trigger metadata. 12 + // These are framework-provided variables that are injected into workflow steps. 13 + func PipelineEnvVars(tr *tangled.Pipeline_TriggerMetadata, pipelineId PipelineId, devMode bool) map[string]string { 14 + if tr == nil { 15 + return nil 16 + } 17 + 18 + env := make(map[string]string) 19 + 20 + // Standard CI environment variable 21 + env["CI"] = "true" 22 + 23 + env["TANGLED_PIPELINE_ID"] = pipelineId.Rkey 24 + 25 + // Repo info 26 + if tr.Repo != nil { 27 + env["TANGLED_REPO_KNOT"] = tr.Repo.Knot 28 + env["TANGLED_REPO_DID"] = tr.Repo.Did 29 + env["TANGLED_REPO_NAME"] = tr.Repo.Repo 30 + env["TANGLED_REPO_DEFAULT_BRANCH"] = tr.Repo.DefaultBranch 31 + env["TANGLED_REPO_URL"] = BuildRepoURL(tr.Repo, devMode) 32 + } 33 + 34 + switch workflow.TriggerKind(tr.Kind) { 35 + case workflow.TriggerKindPush: 36 + if tr.Push != nil { 37 + refName := plumbing.ReferenceName(tr.Push.Ref) 38 + refType := "branch" 39 + if refName.IsTag() { 40 + refType = "tag" 41 + } 42 + 43 + env["TANGLED_REF"] = tr.Push.Ref 44 + env["TANGLED_REF_NAME"] = refName.Short() 45 + env["TANGLED_REF_TYPE"] = refType 46 + env["TANGLED_SHA"] = tr.Push.NewSha 47 + env["TANGLED_COMMIT_SHA"] = tr.Push.NewSha 48 + } 49 + 50 + case workflow.TriggerKindPullRequest: 51 + if tr.PullRequest != nil { 52 + // For PRs, the "ref" is the source branch 53 + env["TANGLED_REF"] = "refs/heads/" + tr.PullRequest.SourceBranch 54 + env["TANGLED_REF_NAME"] = tr.PullRequest.SourceBranch 55 + env["TANGLED_REF_TYPE"] = "branch" 56 + env["TANGLED_SHA"] = tr.PullRequest.SourceSha 57 + env["TANGLED_COMMIT_SHA"] = tr.PullRequest.SourceSha 58 + 59 + // PR-specific variables 60 + env["TANGLED_PR_SOURCE_BRANCH"] = tr.PullRequest.SourceBranch 61 + env["TANGLED_PR_TARGET_BRANCH"] = tr.PullRequest.TargetBranch 62 + env["TANGLED_PR_SOURCE_SHA"] = tr.PullRequest.SourceSha 63 + env["TANGLED_PR_ACTION"] = tr.PullRequest.Action 64 + } 65 + 66 + case workflow.TriggerKindManual: 67 + // Manual triggers may not have ref/sha info 68 + // Include any manual inputs if present 69 + if tr.Manual != nil { 70 + for _, pair := range tr.Manual.Inputs { 71 + env["TANGLED_INPUT_"+strings.ToUpper(pair.Key)] = pair.Value 72 + } 73 + } 74 + } 75 + 76 + return env 77 + }
+260
spindle/models/pipeline_env_test.go
···
··· 1 + package models 2 + 3 + import ( 4 + "testing" 5 + 6 + "tangled.org/core/api/tangled" 7 + "tangled.org/core/workflow" 8 + ) 9 + 10 + func TestPipelineEnvVars_PushBranch(t *testing.T) { 11 + tr := &tangled.Pipeline_TriggerMetadata{ 12 + Kind: string(workflow.TriggerKindPush), 13 + Push: &tangled.Pipeline_PushTriggerData{ 14 + NewSha: "abc123def456", 15 + OldSha: "000000000000", 16 + Ref: "refs/heads/main", 17 + }, 18 + Repo: &tangled.Pipeline_TriggerRepo{ 19 + Knot: "example.com", 20 + Did: "did:plc:user123", 21 + Repo: "my-repo", 22 + DefaultBranch: "main", 23 + }, 24 + } 25 + id := PipelineId{ 26 + Knot: "example.com", 27 + Rkey: "123123", 28 + } 29 + env := PipelineEnvVars(tr, id, false) 30 + 31 + // Check standard CI variable 32 + if env["CI"] != "true" { 33 + t.Errorf("Expected CI='true', got '%s'", env["CI"]) 34 + } 35 + 36 + // Check ref variables 37 + if env["TANGLED_REF"] != "refs/heads/main" { 38 + t.Errorf("Expected TANGLED_REF='refs/heads/main', got '%s'", env["TANGLED_REF"]) 39 + } 40 + if env["TANGLED_REF_NAME"] != "main" { 41 + t.Errorf("Expected TANGLED_REF_NAME='main', got '%s'", env["TANGLED_REF_NAME"]) 42 + } 43 + if env["TANGLED_REF_TYPE"] != "branch" { 44 + t.Errorf("Expected TANGLED_REF_TYPE='branch', got '%s'", env["TANGLED_REF_TYPE"]) 45 + } 46 + 47 + // Check SHA variables 48 + if env["TANGLED_SHA"] != "abc123def456" { 49 + t.Errorf("Expected TANGLED_SHA='abc123def456', got '%s'", env["TANGLED_SHA"]) 50 + } 51 + if env["TANGLED_COMMIT_SHA"] != "abc123def456" { 52 + t.Errorf("Expected TANGLED_COMMIT_SHA='abc123def456', got '%s'", env["TANGLED_COMMIT_SHA"]) 53 + } 54 + 55 + // Check repo variables 56 + if env["TANGLED_REPO_KNOT"] != "example.com" { 57 + t.Errorf("Expected TANGLED_REPO_KNOT='example.com', got '%s'", env["TANGLED_REPO_KNOT"]) 58 + } 59 + if env["TANGLED_REPO_DID"] != "did:plc:user123" { 60 + t.Errorf("Expected TANGLED_REPO_DID='did:plc:user123', got '%s'", env["TANGLED_REPO_DID"]) 61 + } 62 + if env["TANGLED_REPO_NAME"] != "my-repo" { 63 + t.Errorf("Expected TANGLED_REPO_NAME='my-repo', got '%s'", env["TANGLED_REPO_NAME"]) 64 + } 65 + if env["TANGLED_REPO_DEFAULT_BRANCH"] != "main" { 66 + t.Errorf("Expected TANGLED_REPO_DEFAULT_BRANCH='main', got '%s'", env["TANGLED_REPO_DEFAULT_BRANCH"]) 67 + } 68 + if env["TANGLED_REPO_URL"] != "https://example.com/did:plc:user123/my-repo" { 69 + t.Errorf("Expected TANGLED_REPO_URL='https://example.com/did:plc:user123/my-repo', got '%s'", env["TANGLED_REPO_URL"]) 70 + } 71 + } 72 + 73 + func TestPipelineEnvVars_PushTag(t *testing.T) { 74 + tr := &tangled.Pipeline_TriggerMetadata{ 75 + Kind: string(workflow.TriggerKindPush), 76 + Push: &tangled.Pipeline_PushTriggerData{ 77 + NewSha: "abc123def456", 78 + OldSha: "000000000000", 79 + Ref: "refs/tags/v1.2.3", 80 + }, 81 + Repo: &tangled.Pipeline_TriggerRepo{ 82 + Knot: "example.com", 83 + Did: "did:plc:user123", 84 + Repo: "my-repo", 85 + }, 86 + } 87 + id := PipelineId{ 88 + Knot: "example.com", 89 + Rkey: "123123", 90 + } 91 + env := PipelineEnvVars(tr, id, false) 92 + 93 + if env["TANGLED_REF"] != "refs/tags/v1.2.3" { 94 + t.Errorf("Expected TANGLED_REF='refs/tags/v1.2.3', got '%s'", env["TANGLED_REF"]) 95 + } 96 + if env["TANGLED_REF_NAME"] != "v1.2.3" { 97 + t.Errorf("Expected TANGLED_REF_NAME='v1.2.3', got '%s'", env["TANGLED_REF_NAME"]) 98 + } 99 + if env["TANGLED_REF_TYPE"] != "tag" { 100 + t.Errorf("Expected TANGLED_REF_TYPE='tag', got '%s'", env["TANGLED_REF_TYPE"]) 101 + } 102 + } 103 + 104 + func TestPipelineEnvVars_PullRequest(t *testing.T) { 105 + tr := &tangled.Pipeline_TriggerMetadata{ 106 + Kind: string(workflow.TriggerKindPullRequest), 107 + PullRequest: &tangled.Pipeline_PullRequestTriggerData{ 108 + SourceBranch: "feature-branch", 109 + TargetBranch: "main", 110 + SourceSha: "pr-sha-789", 111 + Action: "opened", 112 + }, 113 + Repo: &tangled.Pipeline_TriggerRepo{ 114 + Knot: "example.com", 115 + Did: "did:plc:user123", 116 + Repo: "my-repo", 117 + }, 118 + } 119 + id := PipelineId{ 120 + Knot: "example.com", 121 + Rkey: "123123", 122 + } 123 + env := PipelineEnvVars(tr, id, false) 124 + 125 + // Check ref variables for PR 126 + if env["TANGLED_REF"] != "refs/heads/feature-branch" { 127 + t.Errorf("Expected TANGLED_REF='refs/heads/feature-branch', got '%s'", env["TANGLED_REF"]) 128 + } 129 + if env["TANGLED_REF_NAME"] != "feature-branch" { 130 + t.Errorf("Expected TANGLED_REF_NAME='feature-branch', got '%s'", env["TANGLED_REF_NAME"]) 131 + } 132 + if env["TANGLED_REF_TYPE"] != "branch" { 133 + t.Errorf("Expected TANGLED_REF_TYPE='branch', got '%s'", env["TANGLED_REF_TYPE"]) 134 + } 135 + 136 + // Check SHA variables 137 + if env["TANGLED_SHA"] != "pr-sha-789" { 138 + t.Errorf("Expected TANGLED_SHA='pr-sha-789', got '%s'", env["TANGLED_SHA"]) 139 + } 140 + if env["TANGLED_COMMIT_SHA"] != "pr-sha-789" { 141 + t.Errorf("Expected TANGLED_COMMIT_SHA='pr-sha-789', got '%s'", env["TANGLED_COMMIT_SHA"]) 142 + } 143 + 144 + // Check PR-specific variables 145 + if env["TANGLED_PR_SOURCE_BRANCH"] != "feature-branch" { 146 + t.Errorf("Expected TANGLED_PR_SOURCE_BRANCH='feature-branch', got '%s'", env["TANGLED_PR_SOURCE_BRANCH"]) 147 + } 148 + if env["TANGLED_PR_TARGET_BRANCH"] != "main" { 149 + t.Errorf("Expected TANGLED_PR_TARGET_BRANCH='main', got '%s'", env["TANGLED_PR_TARGET_BRANCH"]) 150 + } 151 + if env["TANGLED_PR_SOURCE_SHA"] != "pr-sha-789" { 152 + t.Errorf("Expected TANGLED_PR_SOURCE_SHA='pr-sha-789', got '%s'", env["TANGLED_PR_SOURCE_SHA"]) 153 + } 154 + if env["TANGLED_PR_ACTION"] != "opened" { 155 + t.Errorf("Expected TANGLED_PR_ACTION='opened', got '%s'", env["TANGLED_PR_ACTION"]) 156 + } 157 + } 158 + 159 + func TestPipelineEnvVars_ManualWithInputs(t *testing.T) { 160 + tr := &tangled.Pipeline_TriggerMetadata{ 161 + Kind: string(workflow.TriggerKindManual), 162 + Manual: &tangled.Pipeline_ManualTriggerData{ 163 + Inputs: []*tangled.Pipeline_Pair{ 164 + {Key: "version", Value: "1.0.0"}, 165 + {Key: "environment", Value: "production"}, 166 + }, 167 + }, 168 + Repo: &tangled.Pipeline_TriggerRepo{ 169 + Knot: "example.com", 170 + Did: "did:plc:user123", 171 + Repo: "my-repo", 172 + }, 173 + } 174 + id := PipelineId{ 175 + Knot: "example.com", 176 + Rkey: "123123", 177 + } 178 + env := PipelineEnvVars(tr, id, false) 179 + 180 + // Check manual input variables 181 + if env["TANGLED_INPUT_VERSION"] != "1.0.0" { 182 + t.Errorf("Expected TANGLED_INPUT_VERSION='1.0.0', got '%s'", env["TANGLED_INPUT_VERSION"]) 183 + } 184 + if env["TANGLED_INPUT_ENVIRONMENT"] != "production" { 185 + t.Errorf("Expected TANGLED_INPUT_ENVIRONMENT='production', got '%s'", env["TANGLED_INPUT_ENVIRONMENT"]) 186 + } 187 + 188 + // Manual triggers shouldn't have ref/sha variables 189 + if _, ok := env["TANGLED_REF"]; ok { 190 + t.Error("Manual trigger should not have TANGLED_REF") 191 + } 192 + if _, ok := env["TANGLED_SHA"]; ok { 193 + t.Error("Manual trigger should not have TANGLED_SHA") 194 + } 195 + } 196 + 197 + func TestPipelineEnvVars_DevMode(t *testing.T) { 198 + tr := &tangled.Pipeline_TriggerMetadata{ 199 + Kind: string(workflow.TriggerKindPush), 200 + Push: &tangled.Pipeline_PushTriggerData{ 201 + NewSha: "abc123", 202 + Ref: "refs/heads/main", 203 + }, 204 + Repo: &tangled.Pipeline_TriggerRepo{ 205 + Knot: "localhost:3000", 206 + Did: "did:plc:user123", 207 + Repo: "my-repo", 208 + }, 209 + } 210 + id := PipelineId{ 211 + Knot: "example.com", 212 + Rkey: "123123", 213 + } 214 + env := PipelineEnvVars(tr, id, true) 215 + 216 + // Dev mode should use http:// and replace localhost with host.docker.internal 217 + expectedURL := "http://host.docker.internal:3000/did:plc:user123/my-repo" 218 + if env["TANGLED_REPO_URL"] != expectedURL { 219 + t.Errorf("Expected TANGLED_REPO_URL='%s', got '%s'", expectedURL, env["TANGLED_REPO_URL"]) 220 + } 221 + } 222 + 223 + func TestPipelineEnvVars_NilTrigger(t *testing.T) { 224 + id := PipelineId{ 225 + Knot: "example.com", 226 + Rkey: "123123", 227 + } 228 + env := PipelineEnvVars(nil, id, false) 229 + 230 + if env != nil { 231 + t.Error("Expected nil env for nil trigger") 232 + } 233 + } 234 + 235 + func TestPipelineEnvVars_NilPushData(t *testing.T) { 236 + tr := &tangled.Pipeline_TriggerMetadata{ 237 + Kind: string(workflow.TriggerKindPush), 238 + Push: nil, 239 + Repo: &tangled.Pipeline_TriggerRepo{ 240 + Knot: "example.com", 241 + Did: "did:plc:user123", 242 + Repo: "my-repo", 243 + }, 244 + } 245 + id := PipelineId{ 246 + Knot: "example.com", 247 + Rkey: "123123", 248 + } 249 + env := PipelineEnvVars(tr, id, false) 250 + 251 + // Should still have repo variables 252 + if env["TANGLED_REPO_KNOT"] != "example.com" { 253 + t.Errorf("Expected TANGLED_REPO_KNOT='example.com', got '%s'", env["TANGLED_REPO_KNOT"]) 254 + } 255 + 256 + // Should not have ref/sha variables 257 + if _, ok := env["TANGLED_REF"]; ok { 258 + t.Error("Should not have TANGLED_REF when push data is nil") 259 + } 260 + }
+51
spindle/models/secret_mask.go
···
··· 1 + package models 2 + 3 + import ( 4 + "encoding/base64" 5 + "strings" 6 + ) 7 + 8 + // SecretMask replaces secret values in strings with "***". 9 + type SecretMask struct { 10 + replacer *strings.Replacer 11 + } 12 + 13 + // NewSecretMask creates a mask for the given secret values. 14 + // Also registers base64-encoded variants of each secret. 15 + func NewSecretMask(values []string) *SecretMask { 16 + var pairs []string 17 + 18 + for _, value := range values { 19 + if value == "" { 20 + continue 21 + } 22 + 23 + pairs = append(pairs, value, "***") 24 + 25 + b64 := base64.StdEncoding.EncodeToString([]byte(value)) 26 + if b64 != value { 27 + pairs = append(pairs, b64, "***") 28 + } 29 + 30 + b64NoPad := strings.TrimRight(b64, "=") 31 + if b64NoPad != b64 && b64NoPad != value { 32 + pairs = append(pairs, b64NoPad, "***") 33 + } 34 + } 35 + 36 + if len(pairs) == 0 { 37 + return nil 38 + } 39 + 40 + return &SecretMask{ 41 + replacer: strings.NewReplacer(pairs...), 42 + } 43 + } 44 + 45 + // Mask replaces all registered secret values with "***". 46 + func (m *SecretMask) Mask(input string) string { 47 + if m == nil || m.replacer == nil { 48 + return input 49 + } 50 + return m.replacer.Replace(input) 51 + }
+135
spindle/models/secret_mask_test.go
···
··· 1 + package models 2 + 3 + import ( 4 + "encoding/base64" 5 + "testing" 6 + ) 7 + 8 + func TestSecretMask_BasicMasking(t *testing.T) { 9 + mask := NewSecretMask([]string{"mysecret123"}) 10 + 11 + input := "The password is mysecret123 in this log" 12 + expected := "The password is *** in this log" 13 + 14 + result := mask.Mask(input) 15 + if result != expected { 16 + t.Errorf("expected %q, got %q", expected, result) 17 + } 18 + } 19 + 20 + func TestSecretMask_Base64Encoded(t *testing.T) { 21 + secret := "mysecret123" 22 + mask := NewSecretMask([]string{secret}) 23 + 24 + b64 := base64.StdEncoding.EncodeToString([]byte(secret)) 25 + input := "Encoded: " + b64 26 + expected := "Encoded: ***" 27 + 28 + result := mask.Mask(input) 29 + if result != expected { 30 + t.Errorf("expected %q, got %q", expected, result) 31 + } 32 + } 33 + 34 + func TestSecretMask_Base64NoPadding(t *testing.T) { 35 + // "test" encodes to "dGVzdA==" with padding 36 + secret := "test" 37 + mask := NewSecretMask([]string{secret}) 38 + 39 + b64NoPad := "dGVzdA" // base64 without padding 40 + input := "Token: " + b64NoPad 41 + expected := "Token: ***" 42 + 43 + result := mask.Mask(input) 44 + if result != expected { 45 + t.Errorf("expected %q, got %q", expected, result) 46 + } 47 + } 48 + 49 + func TestSecretMask_MultipleSecrets(t *testing.T) { 50 + mask := NewSecretMask([]string{"password1", "apikey123"}) 51 + 52 + input := "Using password1 and apikey123 for auth" 53 + expected := "Using *** and *** for auth" 54 + 55 + result := mask.Mask(input) 56 + if result != expected { 57 + t.Errorf("expected %q, got %q", expected, result) 58 + } 59 + } 60 + 61 + func TestSecretMask_MultipleOccurrences(t *testing.T) { 62 + mask := NewSecretMask([]string{"secret"}) 63 + 64 + input := "secret appears twice: secret" 65 + expected := "*** appears twice: ***" 66 + 67 + result := mask.Mask(input) 68 + if result != expected { 69 + t.Errorf("expected %q, got %q", expected, result) 70 + } 71 + } 72 + 73 + func TestSecretMask_ShortValues(t *testing.T) { 74 + mask := NewSecretMask([]string{"abc", "xy", ""}) 75 + 76 + if mask == nil { 77 + t.Fatal("expected non-nil mask") 78 + } 79 + 80 + input := "abc xy test" 81 + expected := "*** *** test" 82 + result := mask.Mask(input) 83 + if result != expected { 84 + t.Errorf("expected %q, got %q", expected, result) 85 + } 86 + } 87 + 88 + func TestSecretMask_NilMask(t *testing.T) { 89 + var mask *SecretMask 90 + 91 + input := "some input text" 92 + result := mask.Mask(input) 93 + if result != input { 94 + t.Errorf("expected %q, got %q", input, result) 95 + } 96 + } 97 + 98 + func TestSecretMask_EmptyInput(t *testing.T) { 99 + mask := NewSecretMask([]string{"secret"}) 100 + 101 + result := mask.Mask("") 102 + if result != "" { 103 + t.Errorf("expected empty string, got %q", result) 104 + } 105 + } 106 + 107 + func TestSecretMask_NoMatch(t *testing.T) { 108 + mask := NewSecretMask([]string{"secretvalue"}) 109 + 110 + input := "nothing to mask here" 111 + result := mask.Mask(input) 112 + if result != input { 113 + t.Errorf("expected %q, got %q", input, result) 114 + } 115 + } 116 + 117 + func TestSecretMask_EmptySecretsList(t *testing.T) { 118 + mask := NewSecretMask([]string{}) 119 + 120 + if mask != nil { 121 + t.Error("expected nil mask for empty secrets list") 122 + } 123 + } 124 + 125 + func TestSecretMask_EmptySecretsFiltered(t *testing.T) { 126 + mask := NewSecretMask([]string{"ab", "validpassword", "", "xyz"}) 127 + 128 + input := "Using validpassword here" 129 + expected := "Using *** here" 130 + 131 + result := mask.Mask(input) 132 + if result != expected { 133 + t.Errorf("expected %q, got %q", expected, result) 134 + } 135 + }
+1 -1
spindle/motd
··· 20 ** 21 ******** 22 23 - This is a spindle server. More info at https://tangled.sh/@tangled.sh/core/tree/master/docs/spindle 24 25 Most API routes are under /xrpc/
··· 20 ** 21 ******** 22 23 + This is a spindle server. More info at https://docs.tangled.org/spindles.html#spindles 24 25 Most API routes are under /xrpc/
+42 -13
spindle/server.go
··· 6 "encoding/json" 7 "fmt" 8 "log/slog" 9 "net/http" 10 11 "github.com/go-chi/chi/v5" 12 "tangled.org/core/api/tangled" ··· 29 ) 30 31 //go:embed motd 32 - var motd []byte 33 34 const ( 35 rbacDomain = "thisserver" 36 ) 37 38 type Spindle struct { 39 - jc *jetstream.JetstreamClient 40 - db *db.DB 41 - e *rbac.Enforcer 42 - l *slog.Logger 43 - n *notifier.Notifier 44 - engs map[string]models.Engine 45 - jq *queue.Queue 46 - cfg *config.Config 47 - ks *eventconsumer.Consumer 48 - res *idresolver.Resolver 49 - vault secrets.Manager 50 } 51 52 // New creates a new Spindle server with the provided configuration and engines. ··· 127 cfg: cfg, 128 res: resolver, 129 vault: vault, 130 } 131 132 err = e.AddSpindle(rbacDomain) ··· 200 return s.e 201 } 202 203 // Start starts the Spindle server (blocking). 204 func (s *Spindle) Start(ctx context.Context) error { 205 // starts a job queue runner in the background ··· 245 mux := chi.NewRouter() 246 247 mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 248 - w.Write(motd) 249 }) 250 mux.HandleFunc("/events", s.Events) 251 mux.HandleFunc("/logs/{knot}/{rkey}/{name}", s.Logs) ··· 311 312 workflows := make(map[models.Engine][]models.Workflow) 313 314 for _, w := range tpl.Workflows { 315 if w != nil { 316 if _, ok := s.engs[w.Engine]; !ok { ··· 335 if err != nil { 336 return err 337 } 338 339 workflows[eng] = append(workflows[eng], *ewf) 340
··· 6 "encoding/json" 7 "fmt" 8 "log/slog" 9 + "maps" 10 "net/http" 11 + "sync" 12 13 "github.com/go-chi/chi/v5" 14 "tangled.org/core/api/tangled" ··· 31 ) 32 33 //go:embed motd 34 + var defaultMotd []byte 35 36 const ( 37 rbacDomain = "thisserver" 38 ) 39 40 type Spindle struct { 41 + jc *jetstream.JetstreamClient 42 + db *db.DB 43 + e *rbac.Enforcer 44 + l *slog.Logger 45 + n *notifier.Notifier 46 + engs map[string]models.Engine 47 + jq *queue.Queue 48 + cfg *config.Config 49 + ks *eventconsumer.Consumer 50 + res *idresolver.Resolver 51 + vault secrets.Manager 52 + motd []byte 53 + motdMu sync.RWMutex 54 } 55 56 // New creates a new Spindle server with the provided configuration and engines. ··· 131 cfg: cfg, 132 res: resolver, 133 vault: vault, 134 + motd: defaultMotd, 135 } 136 137 err = e.AddSpindle(rbacDomain) ··· 205 return s.e 206 } 207 208 + // SetMotdContent sets custom MOTD content, replacing the embedded default. 209 + func (s *Spindle) SetMotdContent(content []byte) { 210 + s.motdMu.Lock() 211 + defer s.motdMu.Unlock() 212 + s.motd = content 213 + } 214 + 215 + // GetMotdContent returns the current MOTD content. 216 + func (s *Spindle) GetMotdContent() []byte { 217 + s.motdMu.RLock() 218 + defer s.motdMu.RUnlock() 219 + return s.motd 220 + } 221 + 222 // Start starts the Spindle server (blocking). 223 func (s *Spindle) Start(ctx context.Context) error { 224 // starts a job queue runner in the background ··· 264 mux := chi.NewRouter() 265 266 mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 267 + w.Write(s.GetMotdContent()) 268 }) 269 mux.HandleFunc("/events", s.Events) 270 mux.HandleFunc("/logs/{knot}/{rkey}/{name}", s.Logs) ··· 330 331 workflows := make(map[models.Engine][]models.Workflow) 332 333 + // Build pipeline environment variables once for all workflows 334 + pipelineEnv := models.PipelineEnvVars(tpl.TriggerMetadata, pipelineId, s.cfg.Server.Dev) 335 + 336 for _, w := range tpl.Workflows { 337 if w != nil { 338 if _, ok := s.engs[w.Engine]; !ok { ··· 357 if err != nil { 358 return err 359 } 360 + 361 + // inject TANGLED_* env vars after InitWorkflow 362 + // This prevents user-defined env vars from overriding them 363 + if ewf.Environment == nil { 364 + ewf.Environment = make(map[string]string) 365 + } 366 + maps.Copy(ewf.Environment, pipelineEnv) 367 368 workflows[eng] = append(workflows[eng], *ewf) 369
+1 -1
tailwind.config.js
··· 2 const colors = require("tailwindcss/colors"); 3 4 module.exports = { 5 - content: ["./appview/pages/templates/**/*.html", "./appview/pages/chroma.go"], 6 darkMode: "media", 7 theme: { 8 container: {
··· 2 const colors = require("tailwindcss/colors"); 3 4 module.exports = { 5 + content: ["./appview/pages/templates/**/*.html", "./appview/pages/chroma.go", "./docs/*.html"], 6 darkMode: "media", 7 theme: { 8 container: {
+199
types/commit.go
···
··· 1 + package types 2 + 3 + import ( 4 + "bytes" 5 + "encoding/json" 6 + "fmt" 7 + "maps" 8 + "regexp" 9 + "strings" 10 + 11 + "github.com/go-git/go-git/v5/plumbing" 12 + "github.com/go-git/go-git/v5/plumbing/object" 13 + ) 14 + 15 + type Commit struct { 16 + // hash of the commit object. 17 + Hash plumbing.Hash `json:"hash,omitempty"` 18 + 19 + // author is the original author of the commit. 20 + Author object.Signature `json:"author"` 21 + 22 + // committer is the one performing the commit, might be different from author. 23 + Committer object.Signature `json:"committer"` 24 + 25 + // message is the commit message, contains arbitrary text. 26 + Message string `json:"message"` 27 + 28 + // treehash is the hash of the root tree of the commit. 29 + Tree string `json:"tree"` 30 + 31 + // parents are the hashes of the parent commits of the commit. 32 + ParentHashes []plumbing.Hash `json:"parent_hashes,omitempty"` 33 + 34 + // pgpsignature is the pgp signature of the commit. 35 + PGPSignature string `json:"pgp_signature,omitempty"` 36 + 37 + // mergetag is the embedded tag object when a merge commit is created by 38 + // merging a signed tag. 39 + MergeTag string `json:"merge_tag,omitempty"` 40 + 41 + // changeid is a unique identifier for the change (e.g., gerrit change-id). 42 + ChangeId string `json:"change_id,omitempty"` 43 + 44 + // extraheaders contains additional headers not captured by other fields. 45 + ExtraHeaders map[string][]byte `json:"extra_headers,omitempty"` 46 + 47 + // deprecated: kept for backwards compatibility with old json format. 48 + This string `json:"this,omitempty"` 49 + 50 + // deprecated: kept for backwards compatibility with old json format. 51 + Parent string `json:"parent,omitempty"` 52 + } 53 + 54 + // types.Commit is an unify two commit structs: 55 + // - git.object.Commit from 56 + // - types.NiceDiff.commit 57 + // 58 + // to do this in backwards compatible fashion, we define the base struct 59 + // to use the same fields as NiceDiff.Commit, and then we also unmarshal 60 + // the struct fields from go-git structs, this custom unmarshal makes sense 61 + // of both representations and unifies them to have maximal data in either 62 + // form. 63 + func (c *Commit) UnmarshalJSON(data []byte) error { 64 + type Alias Commit 65 + 66 + aux := &struct { 67 + *object.Commit 68 + *Alias 69 + }{ 70 + Alias: (*Alias)(c), 71 + } 72 + 73 + if err := json.Unmarshal(data, aux); err != nil { 74 + return err 75 + } 76 + 77 + c.FromGoGitCommit(aux.Commit) 78 + 79 + return nil 80 + } 81 + 82 + // fill in as much of Commit as possible from the given go-git commit 83 + func (c *Commit) FromGoGitCommit(gc *object.Commit) { 84 + if gc == nil { 85 + return 86 + } 87 + 88 + if c.Hash.IsZero() { 89 + c.Hash = gc.Hash 90 + } 91 + if c.This == "" { 92 + c.This = gc.Hash.String() 93 + } 94 + if isEmptySignature(c.Author) { 95 + c.Author = gc.Author 96 + } 97 + if isEmptySignature(c.Committer) { 98 + c.Committer = gc.Committer 99 + } 100 + if c.Message == "" { 101 + c.Message = gc.Message 102 + } 103 + if c.Tree == "" { 104 + c.Tree = gc.TreeHash.String() 105 + } 106 + if c.PGPSignature == "" { 107 + c.PGPSignature = gc.PGPSignature 108 + } 109 + if c.MergeTag == "" { 110 + c.MergeTag = gc.MergeTag 111 + } 112 + 113 + if len(c.ParentHashes) == 0 { 114 + c.ParentHashes = gc.ParentHashes 115 + } 116 + if c.Parent == "" && len(gc.ParentHashes) > 0 { 117 + c.Parent = gc.ParentHashes[0].String() 118 + } 119 + 120 + if len(c.ExtraHeaders) == 0 { 121 + c.ExtraHeaders = make(map[string][]byte) 122 + maps.Copy(c.ExtraHeaders, gc.ExtraHeaders) 123 + } 124 + 125 + if c.ChangeId == "" { 126 + if v, ok := gc.ExtraHeaders["change-id"]; ok { 127 + c.ChangeId = string(v) 128 + } 129 + } 130 + } 131 + 132 + func isEmptySignature(s object.Signature) bool { 133 + return s.Email == "" && s.Name == "" && s.When.IsZero() 134 + } 135 + 136 + // produce a verifiable payload from this commit's metadata 137 + func (c *Commit) Payload() string { 138 + author := bytes.NewBuffer([]byte{}) 139 + c.Author.Encode(author) 140 + 141 + committer := bytes.NewBuffer([]byte{}) 142 + c.Committer.Encode(committer) 143 + 144 + payload := strings.Builder{} 145 + 146 + fmt.Fprintf(&payload, "tree %s\n", c.Tree) 147 + 148 + if len(c.ParentHashes) > 0 { 149 + for _, p := range c.ParentHashes { 150 + fmt.Fprintf(&payload, "parent %s\n", p.String()) 151 + } 152 + } else { 153 + // present for backwards compatibility 154 + fmt.Fprintf(&payload, "parent %s\n", c.Parent) 155 + } 156 + 157 + fmt.Fprintf(&payload, "author %s\n", author.String()) 158 + fmt.Fprintf(&payload, "committer %s\n", committer.String()) 159 + 160 + if c.ChangeId != "" { 161 + fmt.Fprintf(&payload, "change-id %s\n", c.ChangeId) 162 + } else if v, ok := c.ExtraHeaders["change-id"]; ok { 163 + fmt.Fprintf(&payload, "change-id %s\n", string(v)) 164 + } 165 + 166 + fmt.Fprintf(&payload, "\n%s", c.Message) 167 + 168 + return payload.String() 169 + } 170 + 171 + var ( 172 + coAuthorRegex = regexp.MustCompile(`(?im)^Co-authored-by:\s*(.+?)\s*<([^>]+)>`) 173 + ) 174 + 175 + func (commit Commit) CoAuthors() []object.Signature { 176 + var coAuthors []object.Signature 177 + seen := make(map[string]bool) 178 + matches := coAuthorRegex.FindAllStringSubmatch(commit.Message, -1) 179 + 180 + for _, match := range matches { 181 + if len(match) >= 3 { 182 + name := strings.TrimSpace(match[1]) 183 + email := strings.TrimSpace(match[2]) 184 + 185 + if seen[email] { 186 + continue 187 + } 188 + seen[email] = true 189 + 190 + coAuthors = append(coAuthors, object.Signature{ 191 + Name: name, 192 + Email: email, 193 + When: commit.Committer.When, 194 + }) 195 + } 196 + } 197 + 198 + return coAuthors 199 + }
+5 -12
types/diff.go
··· 2 3 import ( 4 "github.com/bluekeyes/go-gitdiff/gitdiff" 5 - "github.com/go-git/go-git/v5/plumbing/object" 6 ) 7 8 type DiffOpts struct { ··· 43 44 // A nicer git diff representation. 45 type NiceDiff struct { 46 - Commit struct { 47 - Message string `json:"message"` 48 - Author object.Signature `json:"author"` 49 - This string `json:"this"` 50 - Parent string `json:"parent"` 51 - PGPSignature string `json:"pgp_signature"` 52 - Committer object.Signature `json:"committer"` 53 - Tree string `json:"tree"` 54 - ChangedId string `json:"change_id"` 55 - } `json:"commit"` 56 - Stat struct { 57 FilesChanged int `json:"files_changed"` 58 Insertions int `json:"insertions"` 59 Deletions int `json:"deletions"` ··· 84 85 // used by html elements as a unique ID for hrefs 86 func (d *Diff) Id() string { 87 return d.Name.New 88 } 89
··· 2 3 import ( 4 "github.com/bluekeyes/go-gitdiff/gitdiff" 5 ) 6 7 type DiffOpts struct { ··· 42 43 // A nicer git diff representation. 44 type NiceDiff struct { 45 + Commit Commit `json:"commit"` 46 + Stat struct { 47 FilesChanged int `json:"files_changed"` 48 Insertions int `json:"insertions"` 49 Deletions int `json:"deletions"` ··· 74 75 // used by html elements as a unique ID for hrefs 76 func (d *Diff) Id() string { 77 + if d.IsDelete { 78 + return d.Name.Old 79 + } 80 return d.Name.New 81 } 82
+112
types/diff_test.go
···
··· 1 + package types 2 + 3 + import "testing" 4 + 5 + func TestDiffId(t *testing.T) { 6 + tests := []struct { 7 + name string 8 + diff Diff 9 + expected string 10 + }{ 11 + { 12 + name: "regular file uses new name", 13 + diff: Diff{ 14 + Name: struct { 15 + Old string `json:"old"` 16 + New string `json:"new"` 17 + }{Old: "", New: "src/main.go"}, 18 + }, 19 + expected: "src/main.go", 20 + }, 21 + { 22 + name: "new file uses new name", 23 + diff: Diff{ 24 + Name: struct { 25 + Old string `json:"old"` 26 + New string `json:"new"` 27 + }{Old: "", New: "src/new.go"}, 28 + IsNew: true, 29 + }, 30 + expected: "src/new.go", 31 + }, 32 + { 33 + name: "deleted file uses old name", 34 + diff: Diff{ 35 + Name: struct { 36 + Old string `json:"old"` 37 + New string `json:"new"` 38 + }{Old: "src/deleted.go", New: ""}, 39 + IsDelete: true, 40 + }, 41 + expected: "src/deleted.go", 42 + }, 43 + { 44 + name: "renamed file uses new name", 45 + diff: Diff{ 46 + Name: struct { 47 + Old string `json:"old"` 48 + New string `json:"new"` 49 + }{Old: "src/old.go", New: "src/renamed.go"}, 50 + IsRename: true, 51 + }, 52 + expected: "src/renamed.go", 53 + }, 54 + } 55 + 56 + for _, tt := range tests { 57 + t.Run(tt.name, func(t *testing.T) { 58 + if got := tt.diff.Id(); got != tt.expected { 59 + t.Errorf("Diff.Id() = %q, want %q", got, tt.expected) 60 + } 61 + }) 62 + } 63 + } 64 + 65 + func TestChangedFilesMatchesDiffId(t *testing.T) { 66 + // ChangedFiles() must return values matching each Diff's Id() 67 + // so that sidebar links point to the correct anchors. 68 + // Tests existing, deleted, new, and renamed files. 69 + nd := NiceDiff{ 70 + Diff: []Diff{ 71 + { 72 + Name: struct { 73 + Old string `json:"old"` 74 + New string `json:"new"` 75 + }{Old: "", New: "src/modified.go"}, 76 + }, 77 + { 78 + Name: struct { 79 + Old string `json:"old"` 80 + New string `json:"new"` 81 + }{Old: "src/deleted.go", New: ""}, 82 + IsDelete: true, 83 + }, 84 + { 85 + Name: struct { 86 + Old string `json:"old"` 87 + New string `json:"new"` 88 + }{Old: "", New: "src/new.go"}, 89 + IsNew: true, 90 + }, 91 + { 92 + Name: struct { 93 + Old string `json:"old"` 94 + New string `json:"new"` 95 + }{Old: "src/old.go", New: "src/renamed.go"}, 96 + IsRename: true, 97 + }, 98 + }, 99 + } 100 + 101 + changedFiles := nd.ChangedFiles() 102 + 103 + if len(changedFiles) != len(nd.Diff) { 104 + t.Fatalf("ChangedFiles() returned %d items, want %d", len(changedFiles), len(nd.Diff)) 105 + } 106 + 107 + for i, diff := range nd.Diff { 108 + if changedFiles[i] != diff.Id() { 109 + t.Errorf("ChangedFiles()[%d] = %q, but Diff.Id() = %q", i, changedFiles[i], diff.Id()) 110 + } 111 + } 112 + }
+17 -17
types/repo.go
··· 8 ) 9 10 type RepoIndexResponse struct { 11 - IsEmpty bool `json:"is_empty"` 12 - Ref string `json:"ref,omitempty"` 13 - Readme string `json:"readme,omitempty"` 14 - ReadmeFileName string `json:"readme_file_name,omitempty"` 15 - Commits []*object.Commit `json:"commits,omitempty"` 16 - Description string `json:"description,omitempty"` 17 - Files []NiceTree `json:"files,omitempty"` 18 - Branches []Branch `json:"branches,omitempty"` 19 - Tags []*TagReference `json:"tags,omitempty"` 20 - TotalCommits int `json:"total_commits,omitempty"` 21 } 22 23 type RepoLogResponse struct { 24 - Commits []*object.Commit `json:"commits,omitempty"` 25 - Ref string `json:"ref,omitempty"` 26 - Description string `json:"description,omitempty"` 27 - Log bool `json:"log,omitempty"` 28 - Total int `json:"total,omitempty"` 29 - Page int `json:"page,omitempty"` 30 - PerPage int `json:"per_page,omitempty"` 31 } 32 33 type RepoCommitResponse struct {
··· 8 ) 9 10 type RepoIndexResponse struct { 11 + IsEmpty bool `json:"is_empty"` 12 + Ref string `json:"ref,omitempty"` 13 + Readme string `json:"readme,omitempty"` 14 + ReadmeFileName string `json:"readme_file_name,omitempty"` 15 + Commits []Commit `json:"commits,omitempty"` 16 + Description string `json:"description,omitempty"` 17 + Files []NiceTree `json:"files,omitempty"` 18 + Branches []Branch `json:"branches,omitempty"` 19 + Tags []*TagReference `json:"tags,omitempty"` 20 + TotalCommits int `json:"total_commits,omitempty"` 21 } 22 23 type RepoLogResponse struct { 24 + Commits []Commit `json:"commits,omitempty"` 25 + Ref string `json:"ref,omitempty"` 26 + Description string `json:"description,omitempty"` 27 + Log bool `json:"log,omitempty"` 28 + Total int `json:"total,omitempty"` 29 + Page int `json:"page,omitempty"` 30 + PerPage int `json:"per_page,omitempty"` 31 } 32 33 type RepoCommitResponse struct {