forked from tangled.org/core
Monorepo for Tangled

Compare changes

Choose any two refs to compare.

+15765 -6190
+8 -6
.air/appview.toml
··· 1 - [build] 2 - cmd = "tailwindcss -i input.css -o ./appview/pages/static/tw.css && go build -o .bin/app ./cmd/appview/main.go" 3 - bin = ";set -o allexport && source .env && set +o allexport; .bin/app" 4 1 root = "." 2 + tmp_dir = "out" 5 3 6 - exclude_regex = [".*_templ.go"] 7 - include_ext = ["go", "templ", "html", "css"] 8 - exclude_dir = ["target", "atrium", "nix"] 4 + [build] 5 + cmd = "go build -o out/appview.out cmd/appview/main.go" 6 + bin = "out/appview.out" 7 + 8 + include_ext = ["go"] 9 + exclude_dir = ["avatar", "camo", "indexes", "nix", "tmp"] 10 + stop_on_error = true
+11
.air/knot.toml
··· 1 + root = "." 2 + tmp_dir = "out" 3 + 4 + [build] 5 + cmd = 'go build -ldflags "-X tangled.org/core/knotserver.version=$(git describe --tags --long)" -o out/knot.out cmd/knot/main.go' 6 + bin = "out/knot.out" 7 + args_bin = ["server"] 8 + 9 + include_ext = ["go"] 10 + exclude_dir = ["avatar", "camo", "indexes", "nix", "tmp"] 11 + stop_on_error = true
-7
.air/knotserver.toml
··· 1 - [build] 2 - cmd = 'go build -ldflags "-X tangled.org/core/knotserver.version=$(git describe --tags --long)" -o .bin/knot ./cmd/knot/' 3 - bin = ".bin/knot server" 4 - root = "." 5 - 6 - exclude_regex = [""] 7 - include_ext = ["go", "templ"]
+10
.air/spindle.toml
··· 1 + root = "." 2 + tmp_dir = "out" 3 + 4 + [build] 5 + cmd = "go build -o out/spindle.out cmd/spindle/main.go" 6 + bin = "out/spindle.out" 7 + 8 + include_ext = ["go"] 9 + exclude_dir = ["avatar", "camo", "indexes", "nix", "tmp"] 10 + stop_on_error = true
+13
.editorconfig
··· 1 + root = true 2 + 3 + [*.html] 4 + indent_size = 2 5 + 6 + [*.json] 7 + indent_size = 2 8 + 9 + [*.nix] 10 + indent_size = 2 11 + 12 + [*.yml] 13 + indent_size = 2
+1
.gitignore
··· 15 15 .env 16 16 *.rdb 17 17 .envrc 18 + **/*.bleve 18 19 # Created if following hacking.md 19 20 genjwks.out 20 21 /nix/vm-data
+1 -1
.tangled/workflows/build.yml
··· 1 1 when: 2 2 - event: ["push", "pull_request"] 3 - branch: ["master"] 3 + branch: master 4 4 5 5 engine: nixery 6 6
+1 -1
.tangled/workflows/fmt.yml
··· 1 1 when: 2 2 - event: ["push", "pull_request"] 3 - branch: ["master"] 3 + branch: master 4 4 5 5 engine: nixery 6 6
+1 -1
.tangled/workflows/test.yml
··· 1 1 when: 2 2 - event: ["push", "pull_request"] 3 - branch: ["master"] 3 + branch: master 4 4 5 5 engine: nixery 6 6
+3 -1
api/tangled/actorprofile.go
··· 27 27 Location *string `json:"location,omitempty" cborgen:"location,omitempty"` 28 28 // pinnedRepositories: Any ATURI, it is up to appviews to validate these fields. 29 29 PinnedRepositories []string `json:"pinnedRepositories,omitempty" cborgen:"pinnedRepositories,omitempty"` 30 - Stats []string `json:"stats,omitempty" cborgen:"stats,omitempty"` 30 + // pronouns: Preferred gender pronouns. 31 + Pronouns *string `json:"pronouns,omitempty" cborgen:"pronouns,omitempty"` 32 + Stats []string `json:"stats,omitempty" cborgen:"stats,omitempty"` 31 33 }
+845 -10
api/tangled/cbor_gen.go
··· 26 26 } 27 27 28 28 cw := cbg.NewCborWriter(w) 29 - fieldCount := 7 29 + fieldCount := 8 30 30 31 31 if t.Description == nil { 32 32 fieldCount-- ··· 41 41 } 42 42 43 43 if t.PinnedRepositories == nil { 44 + fieldCount-- 45 + } 46 + 47 + if t.Pronouns == nil { 44 48 fieldCount-- 45 49 } 46 50 ··· 186 190 return err 187 191 } 188 192 if _, err := cw.WriteString(string(*t.Location)); err != nil { 193 + return err 194 + } 195 + } 196 + } 197 + 198 + // t.Pronouns (string) (string) 199 + if t.Pronouns != nil { 200 + 201 + if len("pronouns") > 1000000 { 202 + return xerrors.Errorf("Value in field \"pronouns\" was too long") 203 + } 204 + 205 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("pronouns"))); err != nil { 206 + return err 207 + } 208 + if _, err := cw.WriteString(string("pronouns")); err != nil { 209 + return err 210 + } 211 + 212 + if t.Pronouns == nil { 213 + if _, err := cw.Write(cbg.CborNull); err != nil { 214 + return err 215 + } 216 + } else { 217 + if len(*t.Pronouns) > 1000000 { 218 + return xerrors.Errorf("Value in field t.Pronouns was too long") 219 + } 220 + 221 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Pronouns))); err != nil { 222 + return err 223 + } 224 + if _, err := cw.WriteString(string(*t.Pronouns)); err != nil { 189 225 return err 190 226 } 191 227 } ··· 430 466 } 431 467 432 468 t.Location = (*string)(&sval) 469 + } 470 + } 471 + // t.Pronouns (string) (string) 472 + case "pronouns": 473 + 474 + { 475 + b, err := cr.ReadByte() 476 + if err != nil { 477 + return err 478 + } 479 + if b != cbg.CborNull[0] { 480 + if err := cr.UnreadByte(); err != nil { 481 + return err 482 + } 483 + 484 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 485 + if err != nil { 486 + return err 487 + } 488 + 489 + t.Pronouns = (*string)(&sval) 433 490 } 434 491 } 435 492 // t.Description (string) (string) ··· 5806 5863 } 5807 5864 5808 5865 cw := cbg.NewCborWriter(w) 5809 - fieldCount := 8 5866 + fieldCount := 10 5810 5867 5811 5868 if t.Description == nil { 5812 5869 fieldCount-- ··· 5821 5878 } 5822 5879 5823 5880 if t.Spindle == nil { 5881 + fieldCount-- 5882 + } 5883 + 5884 + if t.Topics == nil { 5885 + fieldCount-- 5886 + } 5887 + 5888 + if t.Website == nil { 5824 5889 fieldCount-- 5825 5890 } 5826 5891 ··· 5961 6026 } 5962 6027 } 5963 6028 6029 + // t.Topics ([]string) (slice) 6030 + if t.Topics != nil { 6031 + 6032 + if len("topics") > 1000000 { 6033 + return xerrors.Errorf("Value in field \"topics\" was too long") 6034 + } 6035 + 6036 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("topics"))); err != nil { 6037 + return err 6038 + } 6039 + if _, err := cw.WriteString(string("topics")); err != nil { 6040 + return err 6041 + } 6042 + 6043 + if len(t.Topics) > 8192 { 6044 + return xerrors.Errorf("Slice value in field t.Topics was too long") 6045 + } 6046 + 6047 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Topics))); err != nil { 6048 + return err 6049 + } 6050 + for _, v := range t.Topics { 6051 + if len(v) > 1000000 { 6052 + return xerrors.Errorf("Value in field v was too long") 6053 + } 6054 + 6055 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 6056 + return err 6057 + } 6058 + if _, err := cw.WriteString(string(v)); err != nil { 6059 + return err 6060 + } 6061 + 6062 + } 6063 + } 6064 + 5964 6065 // t.Spindle (string) (string) 5965 6066 if t.Spindle != nil { 5966 6067 ··· 5993 6094 } 5994 6095 } 5995 6096 6097 + // t.Website (string) (string) 6098 + if t.Website != nil { 6099 + 6100 + if len("website") > 1000000 { 6101 + return xerrors.Errorf("Value in field \"website\" was too long") 6102 + } 6103 + 6104 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("website"))); err != nil { 6105 + return err 6106 + } 6107 + if _, err := cw.WriteString(string("website")); err != nil { 6108 + return err 6109 + } 6110 + 6111 + if t.Website == nil { 6112 + if _, err := cw.Write(cbg.CborNull); err != nil { 6113 + return err 6114 + } 6115 + } else { 6116 + if len(*t.Website) > 1000000 { 6117 + return xerrors.Errorf("Value in field t.Website was too long") 6118 + } 6119 + 6120 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Website))); err != nil { 6121 + return err 6122 + } 6123 + if _, err := cw.WriteString(string(*t.Website)); err != nil { 6124 + return err 6125 + } 6126 + } 6127 + } 6128 + 5996 6129 // t.CreatedAt (string) (string) 5997 6130 if len("createdAt") > 1000000 { 5998 6131 return xerrors.Errorf("Value in field \"createdAt\" was too long") ··· 6185 6318 t.Source = (*string)(&sval) 6186 6319 } 6187 6320 } 6321 + // t.Topics ([]string) (slice) 6322 + case "topics": 6323 + 6324 + maj, extra, err = cr.ReadHeader() 6325 + if err != nil { 6326 + return err 6327 + } 6328 + 6329 + if extra > 8192 { 6330 + return fmt.Errorf("t.Topics: array too large (%d)", extra) 6331 + } 6332 + 6333 + if maj != cbg.MajArray { 6334 + return fmt.Errorf("expected cbor array") 6335 + } 6336 + 6337 + if extra > 0 { 6338 + t.Topics = make([]string, extra) 6339 + } 6340 + 6341 + for i := 0; i < int(extra); i++ { 6342 + { 6343 + var maj byte 6344 + var extra uint64 6345 + var err error 6346 + _ = maj 6347 + _ = extra 6348 + _ = err 6349 + 6350 + { 6351 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 6352 + if err != nil { 6353 + return err 6354 + } 6355 + 6356 + t.Topics[i] = string(sval) 6357 + } 6358 + 6359 + } 6360 + } 6188 6361 // t.Spindle (string) (string) 6189 6362 case "spindle": 6190 6363 ··· 6204 6377 } 6205 6378 6206 6379 t.Spindle = (*string)(&sval) 6380 + } 6381 + } 6382 + // t.Website (string) (string) 6383 + case "website": 6384 + 6385 + { 6386 + b, err := cr.ReadByte() 6387 + if err != nil { 6388 + return err 6389 + } 6390 + if b != cbg.CborNull[0] { 6391 + if err := cr.UnreadByte(); err != nil { 6392 + return err 6393 + } 6394 + 6395 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 6396 + if err != nil { 6397 + return err 6398 + } 6399 + 6400 + t.Website = (*string)(&sval) 6207 6401 } 6208 6402 } 6209 6403 // t.CreatedAt (string) (string) ··· 6744 6938 } 6745 6939 6746 6940 cw := cbg.NewCborWriter(w) 6747 - fieldCount := 5 6941 + fieldCount := 7 6748 6942 6749 6943 if t.Body == nil { 6944 + fieldCount-- 6945 + } 6946 + 6947 + if t.Mentions == nil { 6948 + fieldCount-- 6949 + } 6950 + 6951 + if t.References == nil { 6750 6952 fieldCount-- 6751 6953 } 6752 6954 ··· 6851 7053 return err 6852 7054 } 6853 7055 7056 + // t.Mentions ([]string) (slice) 7057 + if t.Mentions != nil { 7058 + 7059 + if len("mentions") > 1000000 { 7060 + return xerrors.Errorf("Value in field \"mentions\" was too long") 7061 + } 7062 + 7063 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("mentions"))); err != nil { 7064 + return err 7065 + } 7066 + if _, err := cw.WriteString(string("mentions")); err != nil { 7067 + return err 7068 + } 7069 + 7070 + if len(t.Mentions) > 8192 { 7071 + return xerrors.Errorf("Slice value in field t.Mentions was too long") 7072 + } 7073 + 7074 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Mentions))); err != nil { 7075 + return err 7076 + } 7077 + for _, v := range t.Mentions { 7078 + if len(v) > 1000000 { 7079 + return xerrors.Errorf("Value in field v was too long") 7080 + } 7081 + 7082 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 7083 + return err 7084 + } 7085 + if _, err := cw.WriteString(string(v)); err != nil { 7086 + return err 7087 + } 7088 + 7089 + } 7090 + } 7091 + 6854 7092 // t.CreatedAt (string) (string) 6855 7093 if len("createdAt") > 1000000 { 6856 7094 return xerrors.Errorf("Value in field \"createdAt\" was too long") ··· 6873 7111 if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 6874 7112 return err 6875 7113 } 7114 + 7115 + // t.References ([]string) (slice) 7116 + if t.References != nil { 7117 + 7118 + if len("references") > 1000000 { 7119 + return xerrors.Errorf("Value in field \"references\" was too long") 7120 + } 7121 + 7122 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("references"))); err != nil { 7123 + return err 7124 + } 7125 + if _, err := cw.WriteString(string("references")); err != nil { 7126 + return err 7127 + } 7128 + 7129 + if len(t.References) > 8192 { 7130 + return xerrors.Errorf("Slice value in field t.References was too long") 7131 + } 7132 + 7133 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.References))); err != nil { 7134 + return err 7135 + } 7136 + for _, v := range t.References { 7137 + if len(v) > 1000000 { 7138 + return xerrors.Errorf("Value in field v was too long") 7139 + } 7140 + 7141 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 7142 + return err 7143 + } 7144 + if _, err := cw.WriteString(string(v)); err != nil { 7145 + return err 7146 + } 7147 + 7148 + } 7149 + } 6876 7150 return nil 6877 7151 } 6878 7152 ··· 6901 7175 6902 7176 n := extra 6903 7177 6904 - nameBuf := make([]byte, 9) 7178 + nameBuf := make([]byte, 10) 6905 7179 for i := uint64(0); i < n; i++ { 6906 7180 nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 6907 7181 if err != nil { ··· 6971 7245 6972 7246 t.Title = string(sval) 6973 7247 } 7248 + // t.Mentions ([]string) (slice) 7249 + case "mentions": 7250 + 7251 + maj, extra, err = cr.ReadHeader() 7252 + if err != nil { 7253 + return err 7254 + } 7255 + 7256 + if extra > 8192 { 7257 + return fmt.Errorf("t.Mentions: array too large (%d)", extra) 7258 + } 7259 + 7260 + if maj != cbg.MajArray { 7261 + return fmt.Errorf("expected cbor array") 7262 + } 7263 + 7264 + if extra > 0 { 7265 + t.Mentions = make([]string, extra) 7266 + } 7267 + 7268 + for i := 0; i < int(extra); i++ { 7269 + { 7270 + var maj byte 7271 + var extra uint64 7272 + var err error 7273 + _ = maj 7274 + _ = extra 7275 + _ = err 7276 + 7277 + { 7278 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 7279 + if err != nil { 7280 + return err 7281 + } 7282 + 7283 + t.Mentions[i] = string(sval) 7284 + } 7285 + 7286 + } 7287 + } 6974 7288 // t.CreatedAt (string) (string) 6975 7289 case "createdAt": 6976 7290 ··· 6981 7295 } 6982 7296 6983 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 + } 6984 7338 } 6985 7339 6986 7340 default: ··· 7000 7354 } 7001 7355 7002 7356 cw := cbg.NewCborWriter(w) 7003 - fieldCount := 5 7357 + fieldCount := 7 7358 + 7359 + if t.Mentions == nil { 7360 + fieldCount-- 7361 + } 7362 + 7363 + if t.References == nil { 7364 + fieldCount-- 7365 + } 7004 7366 7005 7367 if t.ReplyTo == nil { 7006 7368 fieldCount-- ··· 7104 7466 if _, err := cw.WriteString(string(*t.ReplyTo)); err != nil { 7105 7467 return err 7106 7468 } 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 + 7107 7505 } 7108 7506 } 7109 7507 ··· 7129 7527 if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 7130 7528 return err 7131 7529 } 7530 + 7531 + // t.References ([]string) (slice) 7532 + if t.References != nil { 7533 + 7534 + if len("references") > 1000000 { 7535 + return xerrors.Errorf("Value in field \"references\" was too long") 7536 + } 7537 + 7538 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("references"))); err != nil { 7539 + return err 7540 + } 7541 + if _, err := cw.WriteString(string("references")); err != nil { 7542 + return err 7543 + } 7544 + 7545 + if len(t.References) > 8192 { 7546 + return xerrors.Errorf("Slice value in field t.References was too long") 7547 + } 7548 + 7549 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.References))); err != nil { 7550 + return err 7551 + } 7552 + for _, v := range t.References { 7553 + if len(v) > 1000000 { 7554 + return xerrors.Errorf("Value in field v was too long") 7555 + } 7556 + 7557 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 7558 + return err 7559 + } 7560 + if _, err := cw.WriteString(string(v)); err != nil { 7561 + return err 7562 + } 7563 + 7564 + } 7565 + } 7132 7566 return nil 7133 7567 } 7134 7568 ··· 7157 7591 7158 7592 n := extra 7159 7593 7160 - nameBuf := make([]byte, 9) 7594 + nameBuf := make([]byte, 10) 7161 7595 for i := uint64(0); i < n; i++ { 7162 7596 nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 7163 7597 if err != nil { ··· 7227 7661 t.ReplyTo = (*string)(&sval) 7228 7662 } 7229 7663 } 7664 + // t.Mentions ([]string) (slice) 7665 + case "mentions": 7666 + 7667 + maj, extra, err = cr.ReadHeader() 7668 + if err != nil { 7669 + return err 7670 + } 7671 + 7672 + if extra > 8192 { 7673 + return fmt.Errorf("t.Mentions: array too large (%d)", extra) 7674 + } 7675 + 7676 + if maj != cbg.MajArray { 7677 + return fmt.Errorf("expected cbor array") 7678 + } 7679 + 7680 + if extra > 0 { 7681 + t.Mentions = make([]string, extra) 7682 + } 7683 + 7684 + for i := 0; i < int(extra); i++ { 7685 + { 7686 + var maj byte 7687 + var extra uint64 7688 + var err error 7689 + _ = maj 7690 + _ = extra 7691 + _ = err 7692 + 7693 + { 7694 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 7695 + if err != nil { 7696 + return err 7697 + } 7698 + 7699 + t.Mentions[i] = string(sval) 7700 + } 7701 + 7702 + } 7703 + } 7230 7704 // t.CreatedAt (string) (string) 7231 7705 case "createdAt": 7232 7706 ··· 7238 7712 7239 7713 t.CreatedAt = string(sval) 7240 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 + } 7241 7755 7242 7756 default: 7243 7757 // Field doesn't exist on this type, so ignore it ··· 7420 7934 } 7421 7935 7422 7936 cw := cbg.NewCborWriter(w) 7423 - fieldCount := 7 7937 + fieldCount := 9 7424 7938 7425 7939 if t.Body == nil { 7426 7940 fieldCount-- 7427 7941 } 7428 7942 7943 + if t.Mentions == nil { 7944 + fieldCount-- 7945 + } 7946 + 7947 + if t.References == nil { 7948 + fieldCount-- 7949 + } 7950 + 7429 7951 if t.Source == nil { 7430 7952 fieldCount-- 7431 7953 } ··· 7566 8088 return err 7567 8089 } 7568 8090 8091 + // t.Mentions ([]string) (slice) 8092 + if t.Mentions != nil { 8093 + 8094 + if len("mentions") > 1000000 { 8095 + return xerrors.Errorf("Value in field \"mentions\" was too long") 8096 + } 8097 + 8098 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("mentions"))); err != nil { 8099 + return err 8100 + } 8101 + if _, err := cw.WriteString(string("mentions")); err != nil { 8102 + return err 8103 + } 8104 + 8105 + if len(t.Mentions) > 8192 { 8106 + return xerrors.Errorf("Slice value in field t.Mentions was too long") 8107 + } 8108 + 8109 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Mentions))); err != nil { 8110 + return err 8111 + } 8112 + for _, v := range t.Mentions { 8113 + if len(v) > 1000000 { 8114 + return xerrors.Errorf("Value in field v was too long") 8115 + } 8116 + 8117 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 8118 + return err 8119 + } 8120 + if _, err := cw.WriteString(string(v)); err != nil { 8121 + return err 8122 + } 8123 + 8124 + } 8125 + } 8126 + 7569 8127 // t.CreatedAt (string) (string) 7570 8128 if len("createdAt") > 1000000 { 7571 8129 return xerrors.Errorf("Value in field \"createdAt\" was too long") ··· 7588 8146 if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 7589 8147 return err 7590 8148 } 8149 + 8150 + // t.References ([]string) (slice) 8151 + if t.References != nil { 8152 + 8153 + if len("references") > 1000000 { 8154 + return xerrors.Errorf("Value in field \"references\" was too long") 8155 + } 8156 + 8157 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("references"))); err != nil { 8158 + return err 8159 + } 8160 + if _, err := cw.WriteString(string("references")); err != nil { 8161 + return err 8162 + } 8163 + 8164 + if len(t.References) > 8192 { 8165 + return xerrors.Errorf("Slice value in field t.References was too long") 8166 + } 8167 + 8168 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.References))); err != nil { 8169 + return err 8170 + } 8171 + for _, v := range t.References { 8172 + if len(v) > 1000000 { 8173 + return xerrors.Errorf("Value in field v was too long") 8174 + } 8175 + 8176 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 8177 + return err 8178 + } 8179 + if _, err := cw.WriteString(string(v)); err != nil { 8180 + return err 8181 + } 8182 + 8183 + } 8184 + } 7591 8185 return nil 7592 8186 } 7593 8187 ··· 7616 8210 7617 8211 n := extra 7618 8212 7619 - nameBuf := make([]byte, 9) 8213 + nameBuf := make([]byte, 10) 7620 8214 for i := uint64(0); i < n; i++ { 7621 8215 nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 7622 8216 if err != nil { ··· 7726 8320 } 7727 8321 7728 8322 } 8323 + // t.Mentions ([]string) (slice) 8324 + case "mentions": 8325 + 8326 + maj, extra, err = cr.ReadHeader() 8327 + if err != nil { 8328 + return err 8329 + } 8330 + 8331 + if extra > 8192 { 8332 + return fmt.Errorf("t.Mentions: array too large (%d)", extra) 8333 + } 8334 + 8335 + if maj != cbg.MajArray { 8336 + return fmt.Errorf("expected cbor array") 8337 + } 8338 + 8339 + if extra > 0 { 8340 + t.Mentions = make([]string, extra) 8341 + } 8342 + 8343 + for i := 0; i < int(extra); i++ { 8344 + { 8345 + var maj byte 8346 + var extra uint64 8347 + var err error 8348 + _ = maj 8349 + _ = extra 8350 + _ = err 8351 + 8352 + { 8353 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8354 + if err != nil { 8355 + return err 8356 + } 8357 + 8358 + t.Mentions[i] = string(sval) 8359 + } 8360 + 8361 + } 8362 + } 7729 8363 // t.CreatedAt (string) (string) 7730 8364 case "createdAt": 7731 8365 ··· 7737 8371 7738 8372 t.CreatedAt = string(sval) 7739 8373 } 8374 + // t.References ([]string) (slice) 8375 + case "references": 8376 + 8377 + maj, extra, err = cr.ReadHeader() 8378 + if err != nil { 8379 + return err 8380 + } 8381 + 8382 + if extra > 8192 { 8383 + return fmt.Errorf("t.References: array too large (%d)", extra) 8384 + } 8385 + 8386 + if maj != cbg.MajArray { 8387 + return fmt.Errorf("expected cbor array") 8388 + } 8389 + 8390 + if extra > 0 { 8391 + t.References = make([]string, extra) 8392 + } 8393 + 8394 + for i := 0; i < int(extra); i++ { 8395 + { 8396 + var maj byte 8397 + var extra uint64 8398 + var err error 8399 + _ = maj 8400 + _ = extra 8401 + _ = err 8402 + 8403 + { 8404 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8405 + if err != nil { 8406 + return err 8407 + } 8408 + 8409 + t.References[i] = string(sval) 8410 + } 8411 + 8412 + } 8413 + } 7740 8414 7741 8415 default: 7742 8416 // Field doesn't exist on this type, so ignore it ··· 7755 8429 } 7756 8430 7757 8431 cw := cbg.NewCborWriter(w) 8432 + fieldCount := 6 7758 8433 7759 - if _, err := cw.Write([]byte{164}); err != nil { 8434 + if t.Mentions == nil { 8435 + fieldCount-- 8436 + } 8437 + 8438 + if t.References == nil { 8439 + fieldCount-- 8440 + } 8441 + 8442 + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 7760 8443 return err 7761 8444 } 7762 8445 ··· 7825 8508 return err 7826 8509 } 7827 8510 8511 + // t.Mentions ([]string) (slice) 8512 + if t.Mentions != nil { 8513 + 8514 + if len("mentions") > 1000000 { 8515 + return xerrors.Errorf("Value in field \"mentions\" was too long") 8516 + } 8517 + 8518 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("mentions"))); err != nil { 8519 + return err 8520 + } 8521 + if _, err := cw.WriteString(string("mentions")); err != nil { 8522 + return err 8523 + } 8524 + 8525 + if len(t.Mentions) > 8192 { 8526 + return xerrors.Errorf("Slice value in field t.Mentions was too long") 8527 + } 8528 + 8529 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Mentions))); err != nil { 8530 + return err 8531 + } 8532 + for _, v := range t.Mentions { 8533 + if len(v) > 1000000 { 8534 + return xerrors.Errorf("Value in field v was too long") 8535 + } 8536 + 8537 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 8538 + return err 8539 + } 8540 + if _, err := cw.WriteString(string(v)); err != nil { 8541 + return err 8542 + } 8543 + 8544 + } 8545 + } 8546 + 7828 8547 // t.CreatedAt (string) (string) 7829 8548 if len("createdAt") > 1000000 { 7830 8549 return xerrors.Errorf("Value in field \"createdAt\" was too long") ··· 7847 8566 if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 7848 8567 return err 7849 8568 } 8569 + 8570 + // t.References ([]string) (slice) 8571 + if t.References != nil { 8572 + 8573 + if len("references") > 1000000 { 8574 + return xerrors.Errorf("Value in field \"references\" was too long") 8575 + } 8576 + 8577 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("references"))); err != nil { 8578 + return err 8579 + } 8580 + if _, err := cw.WriteString(string("references")); err != nil { 8581 + return err 8582 + } 8583 + 8584 + if len(t.References) > 8192 { 8585 + return xerrors.Errorf("Slice value in field t.References was too long") 8586 + } 8587 + 8588 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.References))); err != nil { 8589 + return err 8590 + } 8591 + for _, v := range t.References { 8592 + if len(v) > 1000000 { 8593 + return xerrors.Errorf("Value in field v was too long") 8594 + } 8595 + 8596 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 8597 + return err 8598 + } 8599 + if _, err := cw.WriteString(string(v)); err != nil { 8600 + return err 8601 + } 8602 + 8603 + } 8604 + } 7850 8605 return nil 7851 8606 } 7852 8607 ··· 7875 8630 7876 8631 n := extra 7877 8632 7878 - nameBuf := make([]byte, 9) 8633 + nameBuf := make([]byte, 10) 7879 8634 for i := uint64(0); i < n; i++ { 7880 8635 nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 7881 8636 if err != nil { ··· 7924 8679 7925 8680 t.LexiconTypeID = string(sval) 7926 8681 } 8682 + // t.Mentions ([]string) (slice) 8683 + case "mentions": 8684 + 8685 + maj, extra, err = cr.ReadHeader() 8686 + if err != nil { 8687 + return err 8688 + } 8689 + 8690 + if extra > 8192 { 8691 + return fmt.Errorf("t.Mentions: array too large (%d)", extra) 8692 + } 8693 + 8694 + if maj != cbg.MajArray { 8695 + return fmt.Errorf("expected cbor array") 8696 + } 8697 + 8698 + if extra > 0 { 8699 + t.Mentions = make([]string, extra) 8700 + } 8701 + 8702 + for i := 0; i < int(extra); i++ { 8703 + { 8704 + var maj byte 8705 + var extra uint64 8706 + var err error 8707 + _ = maj 8708 + _ = extra 8709 + _ = err 8710 + 8711 + { 8712 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8713 + if err != nil { 8714 + return err 8715 + } 8716 + 8717 + t.Mentions[i] = string(sval) 8718 + } 8719 + 8720 + } 8721 + } 7927 8722 // t.CreatedAt (string) (string) 7928 8723 case "createdAt": 7929 8724 ··· 7934 8729 } 7935 8730 7936 8731 t.CreatedAt = string(sval) 8732 + } 8733 + // t.References ([]string) (slice) 8734 + case "references": 8735 + 8736 + maj, extra, err = cr.ReadHeader() 8737 + if err != nil { 8738 + return err 8739 + } 8740 + 8741 + if extra > 8192 { 8742 + return fmt.Errorf("t.References: array too large (%d)", extra) 8743 + } 8744 + 8745 + if maj != cbg.MajArray { 8746 + return fmt.Errorf("expected cbor array") 8747 + } 8748 + 8749 + if extra > 0 { 8750 + t.References = make([]string, extra) 8751 + } 8752 + 8753 + for i := 0; i < int(extra); i++ { 8754 + { 8755 + var maj byte 8756 + var extra uint64 8757 + var err error 8758 + _ = maj 8759 + _ = extra 8760 + _ = err 8761 + 8762 + { 8763 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8764 + if err != nil { 8765 + return err 8766 + } 8767 + 8768 + t.References[i] = string(sval) 8769 + } 8770 + 8771 + } 7937 8772 } 7938 8773 7939 8774 default:
+7 -5
api/tangled/issuecomment.go
··· 17 17 } // 18 18 // RECORDTYPE: RepoIssueComment 19 19 type RepoIssueComment struct { 20 - LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue.comment" cborgen:"$type,const=sh.tangled.repo.issue.comment"` 21 - Body string `json:"body" cborgen:"body"` 22 - CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 - Issue string `json:"issue" cborgen:"issue"` 24 - ReplyTo *string `json:"replyTo,omitempty" cborgen:"replyTo,omitempty"` 20 + LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue.comment" cborgen:"$type,const=sh.tangled.repo.issue.comment"` 21 + Body string `json:"body" cborgen:"body"` 22 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 + Issue string `json:"issue" cborgen:"issue"` 24 + Mentions []string `json:"mentions,omitempty" cborgen:"mentions,omitempty"` 25 + References []string `json:"references,omitempty" cborgen:"references,omitempty"` 26 + ReplyTo *string `json:"replyTo,omitempty" cborgen:"replyTo,omitempty"` 25 27 }
+6 -4
api/tangled/pullcomment.go
··· 17 17 } // 18 18 // RECORDTYPE: RepoPullComment 19 19 type RepoPullComment struct { 20 - LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull.comment" cborgen:"$type,const=sh.tangled.repo.pull.comment"` 21 - Body string `json:"body" cborgen:"body"` 22 - CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 - Pull string `json:"pull" cborgen:"pull"` 20 + LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull.comment" cborgen:"$type,const=sh.tangled.repo.pull.comment"` 21 + Body string `json:"body" cborgen:"body"` 22 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 + Mentions []string `json:"mentions,omitempty" cborgen:"mentions,omitempty"` 24 + Pull string `json:"pull" cborgen:"pull"` 25 + References []string `json:"references,omitempty" cborgen:"references,omitempty"` 24 26 }
+13 -1
api/tangled/repoblob.go
··· 30 30 // RepoBlob_Output is the output of a sh.tangled.repo.blob call. 31 31 type RepoBlob_Output struct { 32 32 // content: File content (base64 encoded for binary files) 33 - Content string `json:"content" cborgen:"content"` 33 + Content *string `json:"content,omitempty" cborgen:"content,omitempty"` 34 34 // encoding: Content encoding 35 35 Encoding *string `json:"encoding,omitempty" cborgen:"encoding,omitempty"` 36 36 // isBinary: Whether the file is binary ··· 44 44 Ref string `json:"ref" cborgen:"ref"` 45 45 // size: File size in bytes 46 46 Size *int64 `json:"size,omitempty" cborgen:"size,omitempty"` 47 + // submodule: Submodule information if path is a submodule 48 + Submodule *RepoBlob_Submodule `json:"submodule,omitempty" cborgen:"submodule,omitempty"` 47 49 } 48 50 49 51 // RepoBlob_Signature is a "signature" in the sh.tangled.repo.blob schema. ··· 54 56 Name string `json:"name" cborgen:"name"` 55 57 // when: Author timestamp 56 58 When string `json:"when" cborgen:"when"` 59 + } 60 + 61 + // RepoBlob_Submodule is a "submodule" in the sh.tangled.repo.blob schema. 62 + type RepoBlob_Submodule struct { 63 + // branch: Branch to track in the submodule 64 + Branch *string `json:"branch,omitempty" cborgen:"branch,omitempty"` 65 + // name: Submodule name 66 + Name string `json:"name" cborgen:"name"` 67 + // url: Submodule repository URL 68 + Url string `json:"url" cborgen:"url"` 57 69 } 58 70 59 71 // RepoBlob calls the XRPC method "sh.tangled.repo.blob".
+30
api/tangled/repodeleteBranch.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.deleteBranch 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoDeleteBranchNSID = "sh.tangled.repo.deleteBranch" 15 + ) 16 + 17 + // RepoDeleteBranch_Input is the input argument to a sh.tangled.repo.deleteBranch call. 18 + type RepoDeleteBranch_Input struct { 19 + Branch string `json:"branch" cborgen:"branch"` 20 + Repo string `json:"repo" cborgen:"repo"` 21 + } 22 + 23 + // RepoDeleteBranch calls the XRPC method "sh.tangled.repo.deleteBranch". 24 + func RepoDeleteBranch(ctx context.Context, c util.LexClient, input *RepoDeleteBranch_Input) error { 25 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.deleteBranch", nil, input, nil); err != nil { 26 + return err 27 + } 28 + 29 + return nil 30 + }
+7 -5
api/tangled/repoissue.go
··· 17 17 } // 18 18 // RECORDTYPE: RepoIssue 19 19 type RepoIssue struct { 20 - LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue" cborgen:"$type,const=sh.tangled.repo.issue"` 21 - Body *string `json:"body,omitempty" cborgen:"body,omitempty"` 22 - CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 - Repo string `json:"repo" cborgen:"repo"` 24 - Title string `json:"title" cborgen:"title"` 20 + LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue" cborgen:"$type,const=sh.tangled.repo.issue"` 21 + Body *string `json:"body,omitempty" cborgen:"body,omitempty"` 22 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 + Mentions []string `json:"mentions,omitempty" cborgen:"mentions,omitempty"` 24 + References []string `json:"references,omitempty" cborgen:"references,omitempty"` 25 + Repo string `json:"repo" cborgen:"repo"` 26 + Title string `json:"title" cborgen:"title"` 25 27 }
+2
api/tangled/repopull.go
··· 20 20 LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull" cborgen:"$type,const=sh.tangled.repo.pull"` 21 21 Body *string `json:"body,omitempty" cborgen:"body,omitempty"` 22 22 CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 + Mentions []string `json:"mentions,omitempty" cborgen:"mentions,omitempty"` 23 24 Patch string `json:"patch" cborgen:"patch"` 25 + References []string `json:"references,omitempty" cborgen:"references,omitempty"` 24 26 Source *RepoPull_Source `json:"source,omitempty" cborgen:"source,omitempty"` 25 27 Target *RepoPull_Target `json:"target" cborgen:"target"` 26 28 Title string `json:"title" cborgen:"title"`
-4
api/tangled/repotree.go
··· 47 47 48 48 // RepoTree_TreeEntry is a "treeEntry" in the sh.tangled.repo.tree schema. 49 49 type RepoTree_TreeEntry struct { 50 - // is_file: Whether this entry is a file 51 - Is_file bool `json:"is_file" cborgen:"is_file"` 52 - // is_subtree: Whether this entry is a directory/subtree 53 - Is_subtree bool `json:"is_subtree" cborgen:"is_subtree"` 54 50 Last_commit *RepoTree_LastCommit `json:"last_commit,omitempty" cborgen:"last_commit,omitempty"` 55 51 // mode: File mode 56 52 Mode string `json:"mode" cborgen:"mode"`
+4
api/tangled/tangledrepo.go
··· 30 30 Source *string `json:"source,omitempty" cborgen:"source,omitempty"` 31 31 // spindle: CI runner to send jobs to and receive results from 32 32 Spindle *string `json:"spindle,omitempty" cborgen:"spindle,omitempty"` 33 + // topics: Topics related to the repo 34 + Topics []string `json:"topics,omitempty" cborgen:"topics,omitempty"` 35 + // website: Any URI related to the repo 36 + Website *string `json:"website,omitempty" cborgen:"website,omitempty"` 33 37 }
+6 -45
appview/commitverify/verify.go
··· 3 3 import ( 4 4 "log" 5 5 6 - "github.com/go-git/go-git/v5/plumbing/object" 7 6 "tangled.org/core/appview/db" 8 7 "tangled.org/core/appview/models" 9 8 "tangled.org/core/crypto" ··· 35 34 return "" 36 35 } 37 36 38 - func GetVerifiedObjectCommits(e db.Execer, emailToDid map[string]string, commits []*object.Commit) (VerifiedCommits, error) { 39 - ndCommits := []types.NiceDiff{} 40 - for _, commit := range commits { 41 - ndCommits = append(ndCommits, ObjectCommitToNiceDiff(commit)) 42 - } 43 - return GetVerifiedCommits(e, emailToDid, ndCommits) 44 - } 45 - 46 - func GetVerifiedCommits(e db.Execer, emailToDid map[string]string, ndCommits []types.NiceDiff) (VerifiedCommits, error) { 37 + func GetVerifiedCommits(e db.Execer, emailToDid map[string]string, ndCommits []types.Commit) (VerifiedCommits, error) { 47 38 vcs := VerifiedCommits{} 48 39 49 40 didPubkeyCache := make(map[string][]models.PublicKey) 50 41 51 42 for _, commit := range ndCommits { 52 - c := commit.Commit 53 - 54 - committerEmail := c.Committer.Email 43 + committerEmail := commit.Committer.Email 55 44 if did, exists := emailToDid[committerEmail]; exists { 56 45 // check if we've already fetched public keys for this did 57 46 pubKeys, ok := didPubkeyCache[did] ··· 67 56 } 68 57 69 58 // try to verify with any associated pubkeys 59 + payload := commit.Payload() 60 + signature := commit.PGPSignature 70 61 for _, pk := range pubKeys { 71 - if _, ok := crypto.VerifyCommitSignature(pk.Key, commit); ok { 62 + if _, ok := crypto.VerifySignature([]byte(pk.Key), []byte(signature), []byte(payload)); ok { 72 63 73 64 fp, err := crypto.SSHFingerprint(pk.Key) 74 65 if err != nil { 75 66 log.Println("error computing ssh fingerprint:", err) 76 67 } 77 68 78 - vc := verifiedCommit{fingerprint: fp, hash: c.This} 69 + vc := verifiedCommit{fingerprint: fp, hash: commit.This} 79 70 vcs[vc] = struct{}{} 80 71 break 81 72 } ··· 86 77 87 78 return vcs, nil 88 79 } 89 - 90 - // ObjectCommitToNiceDiff is a compatibility function to convert a 91 - // commit object into a NiceDiff structure. 92 - func ObjectCommitToNiceDiff(c *object.Commit) types.NiceDiff { 93 - var niceDiff types.NiceDiff 94 - 95 - // set commit information 96 - niceDiff.Commit.Message = c.Message 97 - niceDiff.Commit.Author = c.Author 98 - niceDiff.Commit.This = c.Hash.String() 99 - niceDiff.Commit.Committer = c.Committer 100 - niceDiff.Commit.Tree = c.TreeHash.String() 101 - niceDiff.Commit.PGPSignature = c.PGPSignature 102 - 103 - changeId, ok := c.ExtraHeaders["change-id"] 104 - if ok { 105 - niceDiff.Commit.ChangedId = string(changeId) 106 - } 107 - 108 - // set parent hash if available 109 - if len(c.ParentHashes) > 0 { 110 - niceDiff.Commit.Parent = c.ParentHashes[0].String() 111 - } 112 - 113 - // XXX: Stats and Diff fields are typically populated 114 - // after fetching the actual diff information, which isn't 115 - // directly available in the commit object itself. 116 - 117 - return niceDiff 118 - }
+15 -2
appview/config/config.go
··· 13 13 CookieSecret string `env:"COOKIE_SECRET, default=00000000000000000000000000000000"` 14 14 DbPath string `env:"DB_PATH, default=appview.db"` 15 15 ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:3000"` 16 - AppviewHost string `env:"APPVIEW_HOST, default=https://tangled.sh"` 16 + AppviewHost string `env:"APPVIEW_HOST, default=https://tangled.org"` 17 + AppviewName string `env:"APPVIEW_Name, default=Tangled"` 17 18 Dev bool `env:"DEV, default=false"` 18 19 DisallowedNicknamesFile string `env:"DISALLOWED_NICKNAMES_FILE"` 19 20 ··· 25 26 } 26 27 27 28 type OAuthConfig struct { 28 - Jwks string `env:"JWKS"` 29 + ClientSecret string `env:"CLIENT_SECRET"` 30 + ClientKid string `env:"CLIENT_KID"` 31 + } 32 + 33 + type PlcConfig struct { 34 + PLCURL string `env:"URL, default=https://plc.directory"` 29 35 } 30 36 31 37 type JetstreamConfig struct { ··· 78 84 TurnstileSecretKey string `env:"TURNSTILE_SECRET_KEY"` 79 85 } 80 86 87 + type LabelConfig struct { 88 + DefaultLabelDefs []string `env:"DEFAULTS, default=at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/wontfix,at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/good-first-issue,at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/duplicate,at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/documentation,at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/assignee"` // delimiter=, 89 + GoodFirstIssue string `env:"GFI, default=at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/good-first-issue"` 90 + } 91 + 81 92 func (cfg RedisConfig) ToURL() string { 82 93 u := &url.URL{ 83 94 Scheme: "redis", ··· 103 114 Avatar AvatarConfig `env:",prefix=TANGLED_AVATAR_"` 104 115 OAuth OAuthConfig `env:",prefix=TANGLED_OAUTH_"` 105 116 Redis RedisConfig `env:",prefix=TANGLED_REDIS_"` 117 + Plc PlcConfig `env:",prefix=TANGLED_PLC_"` 106 118 Pds PdsConfig `env:",prefix=TANGLED_PDS_"` 107 119 Cloudflare Cloudflare `env:",prefix=TANGLED_CLOUDFLARE_"` 120 + Label LabelConfig `env:",prefix=TANGLED_LABEL_"` 108 121 } 109 122 110 123 func LoadConfig(ctx context.Context) (*Config, error) {
+3 -3
appview/db/artifact.go
··· 8 8 "github.com/go-git/go-git/v5/plumbing" 9 9 "github.com/ipfs/go-cid" 10 10 "tangled.org/core/appview/models" 11 + "tangled.org/core/orm" 11 12 ) 12 13 13 14 func AddArtifact(e Execer, artifact models.Artifact) error { ··· 37 38 return err 38 39 } 39 40 40 - func GetArtifact(e Execer, filters ...filter) ([]models.Artifact, error) { 41 + func GetArtifact(e Execer, filters ...orm.Filter) ([]models.Artifact, error) { 41 42 var artifacts []models.Artifact 42 43 43 44 var conditions []string ··· 67 68 ) 68 69 69 70 rows, err := e.Query(query, args...) 70 - 71 71 if err != nil { 72 72 return nil, err 73 73 } ··· 110 110 return artifacts, nil 111 111 } 112 112 113 - func DeleteArtifact(e Execer, filters ...filter) error { 113 + func DeleteArtifact(e Execer, filters ...orm.Filter) error { 114 114 var conditions []string 115 115 var args []any 116 116 for _, filter := range filters {
+56 -2
appview/db/collaborators.go
··· 3 3 import ( 4 4 "fmt" 5 5 "strings" 6 + "time" 6 7 7 8 "tangled.org/core/appview/models" 9 + "tangled.org/core/orm" 8 10 ) 9 11 10 12 func AddCollaborator(e Execer, c models.Collaborator) error { ··· 15 17 return err 16 18 } 17 19 18 - func DeleteCollaborator(e Execer, filters ...filter) error { 20 + func DeleteCollaborator(e Execer, filters ...orm.Filter) error { 19 21 var conditions []string 20 22 var args []any 21 23 for _, filter := range filters { ··· 57 59 return nil, nil 58 60 } 59 61 60 - return GetRepos(e, 0, FilterIn("at_uri", repoAts)) 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 + for _, filter := range filters { 70 + conditions = append(conditions, filter.Condition()) 71 + args = append(args, filter.Arg()...) 72 + } 73 + whereClause := "" 74 + if conditions != nil { 75 + whereClause = " where " + strings.Join(conditions, " and ") 76 + } 77 + query := fmt.Sprintf(`select 78 + id, 79 + did, 80 + rkey, 81 + subject_did, 82 + repo_at, 83 + created 84 + from collaborators %s`, 85 + whereClause, 86 + ) 87 + rows, err := e.Query(query, args...) 88 + if err != nil { 89 + return nil, err 90 + } 91 + defer rows.Close() 92 + for rows.Next() { 93 + var collaborator models.Collaborator 94 + var createdAt string 95 + if err := rows.Scan( 96 + &collaborator.Id, 97 + &collaborator.Did, 98 + &collaborator.Rkey, 99 + &collaborator.SubjectDid, 100 + &collaborator.RepoAt, 101 + &createdAt, 102 + ); err != nil { 103 + return nil, err 104 + } 105 + collaborator.Created, err = time.Parse(time.RFC3339, createdAt) 106 + if err != nil { 107 + collaborator.Created = time.Now() 108 + } 109 + collaborators = append(collaborators, collaborator) 110 + } 111 + if err := rows.Err(); err != nil { 112 + return nil, err 113 + } 114 + return collaborators, nil 61 115 }
+99 -127
appview/db/db.go
··· 3 3 import ( 4 4 "context" 5 5 "database/sql" 6 - "fmt" 7 - "log" 8 - "reflect" 6 + "log/slog" 9 7 "strings" 10 8 11 9 _ "github.com/mattn/go-sqlite3" 10 + "tangled.org/core/log" 11 + "tangled.org/core/orm" 12 12 ) 13 13 14 14 type DB struct { 15 15 *sql.DB 16 + logger *slog.Logger 16 17 } 17 18 18 19 type Execer interface { ··· 26 27 PrepareContext(ctx context.Context, query string) (*sql.Stmt, error) 27 28 } 28 29 29 - func Make(dbPath string) (*DB, error) { 30 + func Make(ctx context.Context, dbPath string) (*DB, error) { 30 31 // https://github.com/mattn/go-sqlite3#connection-string 31 32 opts := []string{ 32 33 "_foreign_keys=1", ··· 35 36 "_auto_vacuum=incremental", 36 37 } 37 38 39 + logger := log.FromContext(ctx) 40 + logger = log.SubLogger(logger, "db") 41 + 38 42 db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&")) 39 43 if err != nil { 40 44 return nil, err 41 45 } 42 - 43 - ctx := context.Background() 44 46 45 47 conn, err := db.Conn(ctx) 46 48 if err != nil { ··· 558 560 email_notifications integer not null default 0 559 561 ); 560 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 + 561 570 create table if not exists migrations ( 562 571 id integer primary key autoincrement, 563 572 name text unique ··· 566 575 -- indexes for better performance 567 576 create index if not exists idx_notifications_recipient_created on notifications(recipient_did, created desc); 568 577 create index if not exists idx_notifications_recipient_read on notifications(recipient_did, read); 569 - create index if not exists idx_stars_created on stars(created); 570 - create index if not exists idx_stars_repo_at_created on stars(repo_at, created); 578 + create index if not exists idx_references_from_at on reference_links(from_at); 579 + create index if not exists idx_references_to_at on reference_links(to_at); 571 580 `) 572 581 if err != nil { 573 582 return nil, err 574 583 } 575 584 576 585 // run migrations 577 - runMigration(conn, "add-description-to-repos", func(tx *sql.Tx) error { 586 + orm.RunMigration(conn, logger, "add-description-to-repos", func(tx *sql.Tx) error { 578 587 tx.Exec(` 579 588 alter table repos add column description text check (length(description) <= 200); 580 589 `) 581 590 return nil 582 591 }) 583 592 584 - runMigration(conn, "add-rkey-to-pubkeys", func(tx *sql.Tx) error { 593 + orm.RunMigration(conn, logger, "add-rkey-to-pubkeys", func(tx *sql.Tx) error { 585 594 // add unconstrained column 586 595 _, err := tx.Exec(` 587 596 alter table public_keys ··· 604 613 return nil 605 614 }) 606 615 607 - runMigration(conn, "add-rkey-to-comments", func(tx *sql.Tx) error { 616 + orm.RunMigration(conn, logger, "add-rkey-to-comments", func(tx *sql.Tx) error { 608 617 _, err := tx.Exec(` 609 618 alter table comments drop column comment_at; 610 619 alter table comments add column rkey text; ··· 612 621 return err 613 622 }) 614 623 615 - runMigration(conn, "add-deleted-and-edited-to-issue-comments", func(tx *sql.Tx) error { 624 + orm.RunMigration(conn, logger, "add-deleted-and-edited-to-issue-comments", func(tx *sql.Tx) error { 616 625 _, err := tx.Exec(` 617 626 alter table comments add column deleted text; -- timestamp 618 627 alter table comments add column edited text; -- timestamp ··· 620 629 return err 621 630 }) 622 631 623 - runMigration(conn, "add-source-info-to-pulls-and-submissions", func(tx *sql.Tx) error { 632 + orm.RunMigration(conn, logger, "add-source-info-to-pulls-and-submissions", func(tx *sql.Tx) error { 624 633 _, err := tx.Exec(` 625 634 alter table pulls add column source_branch text; 626 635 alter table pulls add column source_repo_at text; ··· 629 638 return err 630 639 }) 631 640 632 - runMigration(conn, "add-source-to-repos", func(tx *sql.Tx) error { 641 + orm.RunMigration(conn, logger, "add-source-to-repos", func(tx *sql.Tx) error { 633 642 _, err := tx.Exec(` 634 643 alter table repos add column source text; 635 644 `) ··· 641 650 // 642 651 // [0]: https://sqlite.org/pragma.html#pragma_foreign_keys 643 652 conn.ExecContext(ctx, "pragma foreign_keys = off;") 644 - runMigration(conn, "recreate-pulls-column-for-stacking-support", func(tx *sql.Tx) error { 653 + orm.RunMigration(conn, logger, "recreate-pulls-column-for-stacking-support", func(tx *sql.Tx) error { 645 654 _, err := tx.Exec(` 646 655 create table pulls_new ( 647 656 -- identifiers ··· 698 707 }) 699 708 conn.ExecContext(ctx, "pragma foreign_keys = on;") 700 709 701 - runMigration(conn, "add-spindle-to-repos", func(tx *sql.Tx) error { 710 + orm.RunMigration(conn, logger, "add-spindle-to-repos", func(tx *sql.Tx) error { 702 711 tx.Exec(` 703 712 alter table repos add column spindle text; 704 713 `) ··· 708 717 // drop all knot secrets, add unique constraint to knots 709 718 // 710 719 // knots will henceforth use service auth for signed requests 711 - runMigration(conn, "no-more-secrets", func(tx *sql.Tx) error { 720 + orm.RunMigration(conn, logger, "no-more-secrets", func(tx *sql.Tx) error { 712 721 _, err := tx.Exec(` 713 722 create table registrations_new ( 714 723 id integer primary key autoincrement, ··· 731 740 }) 732 741 733 742 // recreate and add rkey + created columns with default constraint 734 - runMigration(conn, "rework-collaborators-table", func(tx *sql.Tx) error { 743 + orm.RunMigration(conn, logger, "rework-collaborators-table", func(tx *sql.Tx) error { 735 744 // create new table 736 745 // - repo_at instead of repo integer 737 746 // - rkey field ··· 785 794 return err 786 795 }) 787 796 788 - runMigration(conn, "add-rkey-to-issues", func(tx *sql.Tx) error { 797 + orm.RunMigration(conn, logger, "add-rkey-to-issues", func(tx *sql.Tx) error { 789 798 _, err := tx.Exec(` 790 799 alter table issues add column rkey text not null default ''; 791 800 ··· 797 806 }) 798 807 799 808 // repurpose the read-only column to "needs-upgrade" 800 - runMigration(conn, "rename-registrations-read-only-to-needs-upgrade", func(tx *sql.Tx) error { 809 + orm.RunMigration(conn, logger, "rename-registrations-read-only-to-needs-upgrade", func(tx *sql.Tx) error { 801 810 _, err := tx.Exec(` 802 811 alter table registrations rename column read_only to needs_upgrade; 803 812 `) ··· 805 814 }) 806 815 807 816 // require all knots to upgrade after the release of total xrpc 808 - runMigration(conn, "migrate-knots-to-total-xrpc", func(tx *sql.Tx) error { 817 + orm.RunMigration(conn, logger, "migrate-knots-to-total-xrpc", func(tx *sql.Tx) error { 809 818 _, err := tx.Exec(` 810 819 update registrations set needs_upgrade = 1; 811 820 `) ··· 813 822 }) 814 823 815 824 // require all knots to upgrade after the release of total xrpc 816 - runMigration(conn, "migrate-spindles-to-xrpc-owner", func(tx *sql.Tx) error { 825 + orm.RunMigration(conn, logger, "migrate-spindles-to-xrpc-owner", func(tx *sql.Tx) error { 817 826 _, err := tx.Exec(` 818 827 alter table spindles add column needs_upgrade integer not null default 0; 819 828 `) ··· 831 840 // 832 841 // disable foreign-keys for the next migration 833 842 conn.ExecContext(ctx, "pragma foreign_keys = off;") 834 - runMigration(conn, "remove-issue-at-from-issues", func(tx *sql.Tx) error { 843 + orm.RunMigration(conn, logger, "remove-issue-at-from-issues", func(tx *sql.Tx) error { 835 844 _, err := tx.Exec(` 836 845 create table if not exists issues_new ( 837 846 -- identifiers ··· 901 910 // - new columns 902 911 // * column "reply_to" which can be any other comment 903 912 // * column "at-uri" which is a generated column 904 - runMigration(conn, "rework-issue-comments", func(tx *sql.Tx) error { 913 + orm.RunMigration(conn, logger, "rework-issue-comments", func(tx *sql.Tx) error { 905 914 _, err := tx.Exec(` 906 915 create table if not exists issue_comments ( 907 916 -- identifiers ··· 961 970 // 962 971 // disable foreign-keys for the next migration 963 972 conn.ExecContext(ctx, "pragma foreign_keys = off;") 964 - runMigration(conn, "add-at-uri-to-pulls", func(tx *sql.Tx) error { 973 + orm.RunMigration(conn, logger, "add-at-uri-to-pulls", func(tx *sql.Tx) error { 965 974 _, err := tx.Exec(` 966 975 create table if not exists pulls_new ( 967 976 -- identifiers ··· 1042 1051 // 1043 1052 // disable foreign-keys for the next migration 1044 1053 conn.ExecContext(ctx, "pragma foreign_keys = off;") 1045 - runMigration(conn, "remove-repo-at-pull-id-from-pull-submissions", func(tx *sql.Tx) error { 1054 + orm.RunMigration(conn, logger, "remove-repo-at-pull-id-from-pull-submissions", func(tx *sql.Tx) error { 1046 1055 _, err := tx.Exec(` 1047 1056 create table if not exists pull_submissions_new ( 1048 1057 -- identifiers ··· 1094 1103 }) 1095 1104 conn.ExecContext(ctx, "pragma foreign_keys = on;") 1096 1105 1097 - return &DB{db}, nil 1098 - } 1099 - 1100 - type migrationFn = func(*sql.Tx) error 1101 - 1102 - func runMigration(c *sql.Conn, name string, migrationFn migrationFn) error { 1103 - tx, err := c.BeginTx(context.Background(), nil) 1104 - if err != nil { 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 + `) 1105 1112 return err 1106 - } 1107 - defer tx.Rollback() 1113 + }) 1108 1114 1109 - var exists bool 1110 - err = tx.QueryRow("select exists (select 1 from migrations where name = ?)", name).Scan(&exists) 1111 - if err != nil { 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 + `) 1112 1119 return err 1113 - } 1114 - 1115 - if !exists { 1116 - // run migration 1117 - err = migrationFn(tx) 1118 - if err != nil { 1119 - log.Printf("Failed to run migration %s: %v", name, err) 1120 - return err 1121 - } 1122 - 1123 - // mark migration as complete 1124 - _, err = tx.Exec("insert into migrations (name) values (?)", name) 1125 - if err != nil { 1126 - log.Printf("Failed to mark migration %s as complete: %v", name, err) 1127 - return err 1128 - } 1129 - 1130 - // commit the transaction 1131 - if err := tx.Commit(); err != nil { 1132 - return err 1133 - } 1134 - 1135 - log.Printf("migration %s applied successfully", name) 1136 - } else { 1137 - log.Printf("skipped migration %s, already applied", name) 1138 - } 1139 - 1140 - return nil 1141 - } 1120 + }) 1142 1121 1143 - func (d *DB) Close() error { 1144 - return d.DB.Close() 1145 - } 1122 + orm.RunMigration(conn, logger, "add-meta-column-repos", func(tx *sql.Tx) error { 1123 + _, err := tx.Exec(` 1124 + alter table repos add column website text; 1125 + alter table repos add column topics text; 1126 + `) 1127 + return err 1128 + }) 1146 1129 1147 - type filter struct { 1148 - key string 1149 - arg any 1150 - cmp string 1151 - } 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 + }) 1152 1136 1153 - func newFilter(key, cmp string, arg any) filter { 1154 - return filter{ 1155 - key: key, 1156 - arg: arg, 1157 - cmp: cmp, 1158 - } 1159 - } 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, 1160 1144 1161 - func FilterEq(key string, arg any) filter { return newFilter(key, "=", arg) } 1162 - func FilterNotEq(key string, arg any) filter { return newFilter(key, "<>", arg) } 1163 - func FilterGte(key string, arg any) filter { return newFilter(key, ">=", arg) } 1164 - func FilterLte(key string, arg any) filter { return newFilter(key, "<=", arg) } 1165 - func FilterIs(key string, arg any) filter { return newFilter(key, "is", arg) } 1166 - func FilterIsNot(key string, arg any) filter { return newFilter(key, "is not", arg) } 1167 - func FilterIn(key string, arg any) filter { return newFilter(key, "in", arg) } 1168 - func FilterLike(key string, arg any) filter { return newFilter(key, "like", arg) } 1169 - func FilterNotLike(key string, arg any) filter { return newFilter(key, "not like", arg) } 1170 - func FilterContains(key string, arg any) filter { 1171 - return newFilter(key, "like", fmt.Sprintf("%%%v%%", arg)) 1172 - } 1145 + subject_at text not null, 1173 1146 1174 - func (f filter) Condition() string { 1175 - rv := reflect.ValueOf(f.arg) 1176 - kind := rv.Kind() 1147 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 1148 + unique(did, rkey), 1149 + unique(did, subject_at) 1150 + ); 1177 1151 1178 - // if we have `FilterIn(k, [1, 2, 3])`, compile it down to `k in (?, ?, ?)` 1179 - if (kind == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8) || kind == reflect.Array { 1180 - if rv.Len() == 0 { 1181 - // always false 1182 - return "1 = 0" 1183 - } 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; 1184 1166 1185 - placeholders := make([]string, rv.Len()) 1186 - for i := range placeholders { 1187 - placeholders[i] = "?" 1188 - } 1167 + drop table stars; 1168 + alter table stars_new rename to stars; 1189 1169 1190 - return fmt.Sprintf("%s %s (%s)", f.key, f.cmp, strings.Join(placeholders, ", ")) 1191 - } 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 + }) 1192 1175 1193 - return fmt.Sprintf("%s %s ?", f.key, f.cmp) 1176 + return &DB{ 1177 + db, 1178 + logger, 1179 + }, nil 1194 1180 } 1195 1181 1196 - func (f filter) Arg() []any { 1197 - rv := reflect.ValueOf(f.arg) 1198 - kind := rv.Kind() 1199 - if (kind == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8) || kind == reflect.Array { 1200 - if rv.Len() == 0 { 1201 - return nil 1202 - } 1203 - 1204 - out := make([]any, rv.Len()) 1205 - for i := range rv.Len() { 1206 - out[i] = rv.Index(i).Interface() 1207 - } 1208 - return out 1209 - } 1210 - 1211 - return []any{f.arg} 1182 + func (d *DB) Close() error { 1183 + return d.DB.Close() 1212 1184 }
+6 -3
appview/db/follow.go
··· 7 7 "time" 8 8 9 9 "tangled.org/core/appview/models" 10 + "tangled.org/core/orm" 10 11 ) 11 12 12 13 func AddFollow(e Execer, follow *models.Follow) error { ··· 134 135 return result, nil 135 136 } 136 137 137 - func GetFollows(e Execer, limit int, filters ...filter) ([]models.Follow, error) { 138 + func GetFollows(e Execer, limit int, filters ...orm.Filter) ([]models.Follow, error) { 138 139 var follows []models.Follow 139 140 140 141 var conditions []string ··· 166 167 if err != nil { 167 168 return nil, err 168 169 } 170 + defer rows.Close() 171 + 169 172 for rows.Next() { 170 173 var follow models.Follow 171 174 var followedAt string ··· 191 194 } 192 195 193 196 func GetFollowers(e Execer, did string) ([]models.Follow, error) { 194 - return GetFollows(e, 0, FilterEq("subject_did", did)) 197 + return GetFollows(e, 0, orm.FilterEq("subject_did", did)) 195 198 } 196 199 197 200 func GetFollowing(e Execer, did string) ([]models.Follow, error) { 198 - return GetFollows(e, 0, FilterEq("user_did", did)) 201 + return GetFollows(e, 0, orm.FilterEq("user_did", did)) 199 202 } 200 203 201 204 func getFollowStatuses(e Execer, userDid string, subjectDids []string) (map[string]models.FollowStatus, error) {
+160 -47
appview/db/issues.go
··· 10 10 "time" 11 11 12 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 + "tangled.org/core/api/tangled" 13 14 "tangled.org/core/appview/models" 14 15 "tangled.org/core/appview/pagination" 16 + "tangled.org/core/orm" 15 17 ) 16 18 17 19 func PutIssue(tx *sql.Tx, issue *models.Issue) error { ··· 26 28 27 29 issues, err := GetIssues( 28 30 tx, 29 - FilterEq("did", issue.Did), 30 - FilterEq("rkey", issue.Rkey), 31 + orm.FilterEq("did", issue.Did), 32 + orm.FilterEq("rkey", issue.Rkey), 31 33 ) 32 34 switch { 33 35 case err != nil: ··· 69 71 returning rowid, issue_id 70 72 `, issue.RepoAt, issue.Did, issue.Rkey, newIssueId, issue.Title, issue.Body) 71 73 72 - return row.Scan(&issue.Id, &issue.IssueId) 74 + err = row.Scan(&issue.Id, &issue.IssueId) 75 + if err != nil { 76 + return fmt.Errorf("scan row: %w", err) 77 + } 78 + 79 + if err := putReferences(tx, issue.AtUri(), issue.References); err != nil { 80 + return fmt.Errorf("put reference_links: %w", err) 81 + } 82 + return nil 73 83 } 74 84 75 85 func updateIssue(tx *sql.Tx, issue *models.Issue) error { ··· 79 89 set title = ?, body = ?, edited = ? 80 90 where did = ? and rkey = ? 81 91 `, issue.Title, issue.Body, time.Now().Format(time.RFC3339), issue.Did, issue.Rkey) 82 - return err 92 + if err != nil { 93 + return err 94 + } 95 + 96 + if err := putReferences(tx, issue.AtUri(), issue.References); err != nil { 97 + return fmt.Errorf("put reference_links: %w", err) 98 + } 99 + return nil 83 100 } 84 101 85 - func GetIssuesPaginated(e Execer, page pagination.Page, filters ...filter) ([]models.Issue, error) { 102 + func GetIssuesPaginated(e Execer, page pagination.Page, filters ...orm.Filter) ([]models.Issue, error) { 86 103 issueMap := make(map[string]*models.Issue) // at-uri -> issue 87 104 88 105 var conditions []string ··· 98 115 whereClause = " where " + strings.Join(conditions, " and ") 99 116 } 100 117 101 - pLower := FilterGte("row_num", page.Offset+1) 102 - pUpper := FilterLte("row_num", page.Offset+page.Limit) 118 + pLower := orm.FilterGte("row_num", page.Offset+1) 119 + pUpper := orm.FilterLte("row_num", page.Offset+page.Limit) 103 120 104 - args = append(args, pLower.Arg()...) 105 - args = append(args, pUpper.Arg()...) 106 - pagination := " where " + pLower.Condition() + " and " + pUpper.Condition() 121 + pageClause := "" 122 + if page.Limit > 0 { 123 + args = append(args, pLower.Arg()...) 124 + args = append(args, pUpper.Arg()...) 125 + pageClause = " where " + pLower.Condition() + " and " + pUpper.Condition() 126 + } 107 127 108 128 query := fmt.Sprintf( 109 129 ` ··· 128 148 %s 129 149 `, 130 150 whereClause, 131 - pagination, 151 + pageClause, 132 152 ) 133 153 134 154 rows, err := e.Query(query, args...) ··· 186 206 repoAts = append(repoAts, string(issue.RepoAt)) 187 207 } 188 208 189 - repos, err := GetRepos(e, 0, FilterIn("at_uri", repoAts)) 209 + repos, err := GetRepos(e, 0, orm.FilterIn("at_uri", repoAts)) 190 210 if err != nil { 191 211 return nil, fmt.Errorf("failed to build repo mappings: %w", err) 192 212 } ··· 209 229 // collect comments 210 230 issueAts := slices.Collect(maps.Keys(issueMap)) 211 231 212 - comments, err := GetIssueComments(e, FilterIn("issue_at", issueAts)) 232 + comments, err := GetIssueComments(e, orm.FilterIn("issue_at", issueAts)) 213 233 if err != nil { 214 234 return nil, fmt.Errorf("failed to query comments: %w", err) 215 235 } ··· 221 241 } 222 242 223 243 // collect allLabels for each issue 224 - allLabels, err := GetLabels(e, FilterIn("subject", issueAts)) 244 + allLabels, err := GetLabels(e, orm.FilterIn("subject", issueAts)) 225 245 if err != nil { 226 246 return nil, fmt.Errorf("failed to query labels: %w", err) 227 247 } ··· 231 251 } 232 252 } 233 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 + 234 265 var issues []models.Issue 235 266 for _, i := range issueMap { 236 267 issues = append(issues, *i) ··· 243 274 return issues, nil 244 275 } 245 276 246 - func GetIssues(e Execer, filters ...filter) ([]models.Issue, error) { 247 - return GetIssuesPaginated(e, pagination.FirstPage(), filters...) 277 + func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*models.Issue, error) { 278 + issues, err := GetIssuesPaginated( 279 + e, 280 + pagination.Page{}, 281 + orm.FilterEq("repo_at", repoAt), 282 + orm.FilterEq("issue_id", issueId), 283 + ) 284 + if err != nil { 285 + return nil, err 286 + } 287 + if len(issues) != 1 { 288 + return nil, sql.ErrNoRows 289 + } 290 + 291 + return &issues[0], nil 248 292 } 249 293 250 - func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*models.Issue, error) { 251 - query := `select id, owner_did, rkey, created, title, body, open from issues where repo_at = ? and issue_id = ?` 252 - row := e.QueryRow(query, repoAt, issueId) 294 + func GetIssues(e Execer, filters ...orm.Filter) ([]models.Issue, error) { 295 + return GetIssuesPaginated(e, pagination.Page{}, filters...) 296 + } 297 + 298 + // GetIssueIDs gets list of all existing issue's IDs 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 313 + var args []any 314 + 315 + for _, filter := range filters { 316 + conditions = append(conditions, filter.Condition()) 317 + args = append(args, filter.Arg()...) 318 + } 253 319 254 - var issue models.Issue 255 - var createdAt string 256 - err := row.Scan(&issue.Id, &issue.Did, &issue.Rkey, &createdAt, &issue.Title, &issue.Body, &issue.Open) 320 + whereClause := "" 321 + if conditions != nil { 322 + whereClause = " where " + strings.Join(conditions, " and ") 323 + } 324 + query := fmt.Sprintf( 325 + ` 326 + select 327 + id 328 + from 329 + issues 330 + %s 331 + limit ? offset ?`, 332 + whereClause, 333 + ) 334 + args = append(args, opts.Page.Limit, opts.Page.Offset) 335 + rows, err := e.Query(query, args...) 257 336 if err != nil { 258 337 return nil, err 259 338 } 339 + defer rows.Close() 260 340 261 - createdTime, err := time.Parse(time.RFC3339, createdAt) 262 - if err != nil { 263 - return nil, err 341 + for rows.Next() { 342 + var id int64 343 + err := rows.Scan(&id) 344 + if err != nil { 345 + return nil, err 346 + } 347 + 348 + ids = append(ids, id) 264 349 } 265 - issue.Created = createdTime 266 350 267 - return &issue, nil 351 + return ids, nil 268 352 } 269 353 270 - func AddIssueComment(e Execer, c models.IssueComment) (int64, error) { 271 - result, err := e.Exec( 354 + func AddIssueComment(tx *sql.Tx, c models.IssueComment) (int64, error) { 355 + result, err := tx.Exec( 272 356 `insert into issue_comments ( 273 357 did, 274 358 rkey, ··· 307 391 return 0, err 308 392 } 309 393 394 + if err := putReferences(tx, c.AtUri(), c.References); err != nil { 395 + return 0, fmt.Errorf("put reference_links: %w", err) 396 + } 397 + 310 398 return id, nil 311 399 } 312 400 313 - func DeleteIssueComments(e Execer, filters ...filter) error { 401 + func DeleteIssueComments(e Execer, filters ...orm.Filter) error { 314 402 var conditions []string 315 403 var args []any 316 404 for _, filter := range filters { ··· 329 417 return err 330 418 } 331 419 332 - func GetIssueComments(e Execer, filters ...filter) ([]models.IssueComment, error) { 333 - var comments []models.IssueComment 420 + func GetIssueComments(e Execer, filters ...orm.Filter) ([]models.IssueComment, error) { 421 + commentMap := make(map[string]*models.IssueComment) 334 422 335 423 var conditions []string 336 424 var args []any ··· 364 452 if err != nil { 365 453 return nil, err 366 454 } 455 + defer rows.Close() 367 456 368 457 for rows.Next() { 369 458 var comment models.IssueComment ··· 409 498 comment.ReplyTo = &replyTo.V 410 499 } 411 500 412 - comments = append(comments, comment) 501 + atUri := comment.AtUri().String() 502 + commentMap[atUri] = &comment 413 503 } 414 504 415 505 if err = rows.Err(); err != nil { 416 506 return nil, err 417 507 } 418 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 + 419 530 return comments, nil 420 531 } 421 532 422 - func DeleteIssues(e Execer, filters ...filter) error { 423 - var conditions []string 424 - var args []any 425 - for _, filter := range filters { 426 - conditions = append(conditions, filter.Condition()) 427 - args = append(args, filter.Arg()...) 533 + func DeleteIssues(tx *sql.Tx, did, rkey string) error { 534 + _, err := tx.Exec( 535 + `delete from issues 536 + where did = ? and rkey = ?`, 537 + did, 538 + rkey, 539 + ) 540 + if err != nil { 541 + return fmt.Errorf("delete issue: %w", err) 428 542 } 429 543 430 - whereClause := "" 431 - if conditions != nil { 432 - whereClause = " where " + strings.Join(conditions, " and ") 544 + uri := syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", did, tangled.RepoIssueNSID, rkey)) 545 + err = deleteReferences(tx, uri) 546 + if err != nil { 547 + return fmt.Errorf("delete reference_links: %w", err) 433 548 } 434 549 435 - query := fmt.Sprintf(`delete from issues %s`, whereClause) 436 - _, err := e.Exec(query, args...) 437 - return err 550 + return nil 438 551 } 439 552 440 - func CloseIssues(e Execer, filters ...filter) error { 553 + func CloseIssues(e Execer, filters ...orm.Filter) error { 441 554 var conditions []string 442 555 var args []any 443 556 for _, filter := range filters { ··· 455 568 return err 456 569 } 457 570 458 - func ReopenIssues(e Execer, filters ...filter) error { 571 + func ReopenIssues(e Execer, filters ...orm.Filter) error { 459 572 var conditions []string 460 573 var args []any 461 574 for _, filter := range filters {
+8 -7
appview/db/label.go
··· 10 10 11 11 "github.com/bluesky-social/indigo/atproto/syntax" 12 12 "tangled.org/core/appview/models" 13 + "tangled.org/core/orm" 13 14 ) 14 15 15 16 // no updating type for now ··· 59 60 return id, nil 60 61 } 61 62 62 - func DeleteLabelDefinition(e Execer, filters ...filter) error { 63 + func DeleteLabelDefinition(e Execer, filters ...orm.Filter) error { 63 64 var conditions []string 64 65 var args []any 65 66 for _, filter := range filters { ··· 75 76 return err 76 77 } 77 78 78 - func GetLabelDefinitions(e Execer, filters ...filter) ([]models.LabelDefinition, error) { 79 + func GetLabelDefinitions(e Execer, filters ...orm.Filter) ([]models.LabelDefinition, error) { 79 80 var labelDefinitions []models.LabelDefinition 80 81 var conditions []string 81 82 var args []any ··· 167 168 } 168 169 169 170 // helper to get exactly one label def 170 - func GetLabelDefinition(e Execer, filters ...filter) (*models.LabelDefinition, error) { 171 + func GetLabelDefinition(e Execer, filters ...orm.Filter) (*models.LabelDefinition, error) { 171 172 labels, err := GetLabelDefinitions(e, filters...) 172 173 if err != nil { 173 174 return nil, err ··· 227 228 return id, nil 228 229 } 229 230 230 - func GetLabelOps(e Execer, filters ...filter) ([]models.LabelOp, error) { 231 + func GetLabelOps(e Execer, filters ...orm.Filter) ([]models.LabelOp, error) { 231 232 var labelOps []models.LabelOp 232 233 var conditions []string 233 234 var args []any ··· 302 303 } 303 304 304 305 // get labels for a given list of subject URIs 305 - func GetLabels(e Execer, filters ...filter) (map[syntax.ATURI]models.LabelState, error) { 306 + func GetLabels(e Execer, filters ...orm.Filter) (map[syntax.ATURI]models.LabelState, error) { 306 307 ops, err := GetLabelOps(e, filters...) 307 308 if err != nil { 308 309 return nil, err ··· 322 323 } 323 324 labelAts := slices.Collect(maps.Keys(labelAtSet)) 324 325 325 - actx, err := NewLabelApplicationCtx(e, FilterIn("at_uri", labelAts)) 326 + actx, err := NewLabelApplicationCtx(e, orm.FilterIn("at_uri", labelAts)) 326 327 if err != nil { 327 328 return nil, err 328 329 } ··· 338 339 return results, nil 339 340 } 340 341 341 - func NewLabelApplicationCtx(e Execer, filters ...filter) (*models.LabelApplicationCtx, error) { 342 + func NewLabelApplicationCtx(e Execer, filters ...orm.Filter) (*models.LabelApplicationCtx, error) { 342 343 labels, err := GetLabelDefinitions(e, filters...) 343 344 if err != nil { 344 345 return nil, err
+6 -5
appview/db/language.go
··· 7 7 8 8 "github.com/bluesky-social/indigo/atproto/syntax" 9 9 "tangled.org/core/appview/models" 10 + "tangled.org/core/orm" 10 11 ) 11 12 12 - func GetRepoLanguages(e Execer, filters ...filter) ([]models.RepoLanguage, error) { 13 + func GetRepoLanguages(e Execer, filters ...orm.Filter) ([]models.RepoLanguage, error) { 13 14 var conditions []string 14 15 var args []any 15 16 for _, filter := range filters { ··· 27 28 whereClause, 28 29 ) 29 30 rows, err := e.Query(query, args...) 30 - 31 31 if err != nil { 32 32 return nil, fmt.Errorf("failed to execute query: %w ", err) 33 33 } 34 + defer rows.Close() 34 35 35 36 var langs []models.RepoLanguage 36 37 for rows.Next() { ··· 85 86 return nil 86 87 } 87 88 88 - func DeleteRepoLanguages(e Execer, filters ...filter) error { 89 + func DeleteRepoLanguages(e Execer, filters ...orm.Filter) error { 89 90 var conditions []string 90 91 var args []any 91 92 for _, filter := range filters { ··· 107 108 func UpdateRepoLanguages(tx *sql.Tx, repoAt syntax.ATURI, ref string, langs []models.RepoLanguage) error { 108 109 err := DeleteRepoLanguages( 109 110 tx, 110 - FilterEq("repo_at", repoAt), 111 - FilterEq("ref", ref), 111 + orm.FilterEq("repo_at", repoAt), 112 + orm.FilterEq("ref", ref), 112 113 ) 113 114 if err != nil { 114 115 return fmt.Errorf("failed to delete existing languages: %w", err)
+115 -65
appview/db/notifications.go
··· 8 8 "strings" 9 9 "time" 10 10 11 + "github.com/bluesky-social/indigo/atproto/syntax" 11 12 "tangled.org/core/appview/models" 12 13 "tangled.org/core/appview/pagination" 14 + "tangled.org/core/orm" 13 15 ) 14 16 15 - func (d *DB) CreateNotification(ctx context.Context, notification *models.Notification) error { 17 + func CreateNotification(e Execer, notification *models.Notification) error { 16 18 query := ` 17 19 INSERT INTO notifications (recipient_did, actor_did, type, entity_type, entity_id, read, repo_id, issue_id, pull_id) 18 20 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) 19 21 ` 20 22 21 - result, err := d.DB.ExecContext(ctx, query, 23 + result, err := e.Exec(query, 22 24 notification.RecipientDid, 23 25 notification.ActorDid, 24 26 string(notification.Type), ··· 43 45 } 44 46 45 47 // GetNotificationsPaginated retrieves notifications with filters and pagination 46 - func GetNotificationsPaginated(e Execer, page pagination.Page, filters ...filter) ([]*models.Notification, error) { 48 + func GetNotificationsPaginated(e Execer, page pagination.Page, filters ...orm.Filter) ([]*models.Notification, error) { 47 49 var conditions []string 48 50 var args []any 49 51 ··· 59 61 whereClause += " AND " + condition 60 62 } 61 63 } 64 + pageClause := "" 65 + if page.Limit > 0 { 66 + pageClause = " limit ? offset ? " 67 + args = append(args, page.Limit, page.Offset) 68 + } 62 69 63 70 query := fmt.Sprintf(` 64 71 select id, recipient_did, actor_did, type, entity_type, entity_id, read, created, repo_id, issue_id, pull_id 65 72 from notifications 66 73 %s 67 74 order by created desc 68 - limit ? offset ? 69 - `, whereClause) 70 - 71 - args = append(args, page.Limit, page.Offset) 75 + %s 76 + `, whereClause, pageClause) 72 77 73 78 rows, err := e.QueryContext(context.Background(), query, args...) 74 79 if err != nil { ··· 109 114 } 110 115 111 116 // GetNotificationsWithEntities retrieves notifications with their related entities 112 - func GetNotificationsWithEntities(e Execer, page pagination.Page, filters ...filter) ([]*models.NotificationWithEntity, error) { 117 + func GetNotificationsWithEntities(e Execer, page pagination.Page, filters ...orm.Filter) ([]*models.NotificationWithEntity, error) { 113 118 var conditions []string 114 119 var args []any 115 120 ··· 130 135 select 131 136 n.id, n.recipient_did, n.actor_did, n.type, n.entity_type, n.entity_id, 132 137 n.read, n.created, n.repo_id, n.issue_id, n.pull_id, 133 - r.id as r_id, r.did as r_did, r.name as r_name, r.description as r_description, 138 + r.id as r_id, r.did as r_did, r.name as r_name, r.description as r_description, r.website as r_website, r.topics as r_topics, 134 139 i.id as i_id, i.did as i_did, i.issue_id as i_issue_id, i.title as i_title, i.open as i_open, 135 140 p.id as p_id, p.owner_did as p_owner_did, p.pull_id as p_pull_id, p.title as p_title, p.state as p_state 136 141 from notifications n ··· 159 164 var issue models.Issue 160 165 var pull models.Pull 161 166 var rId, iId, pId sql.NullInt64 162 - var rDid, rName, rDescription sql.NullString 167 + var rDid, rName, rDescription, rWebsite, rTopicStr sql.NullString 163 168 var iDid sql.NullString 164 169 var iIssueId sql.NullInt64 165 170 var iTitle sql.NullString ··· 172 177 err := rows.Scan( 173 178 &n.ID, &n.RecipientDid, &n.ActorDid, &typeStr, &n.EntityType, &n.EntityId, 174 179 &n.Read, &createdStr, &n.RepoId, &n.IssueId, &n.PullId, 175 - &rId, &rDid, &rName, &rDescription, 180 + &rId, &rDid, &rName, &rDescription, &rWebsite, &rTopicStr, 176 181 &iId, &iDid, &iIssueId, &iTitle, &iOpen, 177 182 &pId, &pOwnerDid, &pPullId, &pTitle, &pState, 178 183 ) ··· 199 204 } 200 205 if rDescription.Valid { 201 206 repo.Description = rDescription.String 207 + } 208 + if rWebsite.Valid { 209 + repo.Website = rWebsite.String 210 + } 211 + if rTopicStr.Valid { 212 + repo.Topics = strings.Fields(rTopicStr.String) 202 213 } 203 214 nwe.Repo = &repo 204 215 } ··· 246 257 } 247 258 248 259 // GetNotifications retrieves notifications with filters 249 - func GetNotifications(e Execer, filters ...filter) ([]*models.Notification, error) { 260 + func GetNotifications(e Execer, filters ...orm.Filter) ([]*models.Notification, error) { 250 261 return GetNotificationsPaginated(e, pagination.FirstPage(), filters...) 251 262 } 252 263 253 - func CountNotifications(e Execer, filters ...filter) (int64, error) { 264 + func CountNotifications(e Execer, filters ...orm.Filter) (int64, error) { 254 265 var conditions []string 255 266 var args []any 256 267 for _, filter := range filters { ··· 274 285 return count, nil 275 286 } 276 287 277 - func (d *DB) MarkNotificationRead(ctx context.Context, notificationID int64, userDID string) error { 278 - idFilter := FilterEq("id", notificationID) 279 - recipientFilter := FilterEq("recipient_did", userDID) 288 + func MarkNotificationRead(e Execer, notificationID int64, userDID string) error { 289 + idFilter := orm.FilterEq("id", notificationID) 290 + recipientFilter := orm.FilterEq("recipient_did", userDID) 280 291 281 292 query := fmt.Sprintf(` 282 293 UPDATE notifications ··· 286 297 287 298 args := append(idFilter.Arg(), recipientFilter.Arg()...) 288 299 289 - result, err := d.DB.ExecContext(ctx, query, args...) 300 + result, err := e.Exec(query, args...) 290 301 if err != nil { 291 302 return fmt.Errorf("failed to mark notification as read: %w", err) 292 303 } ··· 303 314 return nil 304 315 } 305 316 306 - func (d *DB) MarkAllNotificationsRead(ctx context.Context, userDID string) error { 307 - recipientFilter := FilterEq("recipient_did", userDID) 308 - readFilter := FilterEq("read", 0) 317 + func MarkAllNotificationsRead(e Execer, userDID string) error { 318 + recipientFilter := orm.FilterEq("recipient_did", userDID) 319 + readFilter := orm.FilterEq("read", 0) 309 320 310 321 query := fmt.Sprintf(` 311 322 UPDATE notifications ··· 315 326 316 327 args := append(recipientFilter.Arg(), readFilter.Arg()...) 317 328 318 - _, err := d.DB.ExecContext(ctx, query, args...) 329 + _, err := e.Exec(query, args...) 319 330 if err != nil { 320 331 return fmt.Errorf("failed to mark all notifications as read: %w", err) 321 332 } ··· 323 334 return nil 324 335 } 325 336 326 - func (d *DB) DeleteNotification(ctx context.Context, notificationID int64, userDID string) error { 327 - idFilter := FilterEq("id", notificationID) 328 - recipientFilter := FilterEq("recipient_did", userDID) 337 + func DeleteNotification(e Execer, notificationID int64, userDID string) error { 338 + idFilter := orm.FilterEq("id", notificationID) 339 + recipientFilter := orm.FilterEq("recipient_did", userDID) 329 340 330 341 query := fmt.Sprintf(` 331 342 DELETE FROM notifications ··· 334 345 335 346 args := append(idFilter.Arg(), recipientFilter.Arg()...) 336 347 337 - result, err := d.DB.ExecContext(ctx, query, args...) 348 + result, err := e.Exec(query, args...) 338 349 if err != nil { 339 350 return fmt.Errorf("failed to delete notification: %w", err) 340 351 } ··· 351 362 return nil 352 363 } 353 364 354 - func (d *DB) GetNotificationPreferences(ctx context.Context, userDID string) (*models.NotificationPreferences, error) { 355 - userFilter := FilterEq("user_did", userDID) 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 + } 356 370 357 - query := fmt.Sprintf(` 358 - SELECT id, user_did, repo_starred, issue_created, issue_commented, pull_created, 359 - pull_commented, followed, pull_merged, issue_closed, email_notifications 360 - FROM notification_preferences 361 - WHERE %s 362 - `, userFilter.Condition()) 371 + p, ok := prefs[syntax.DID(userDid)] 372 + if !ok { 373 + return models.DefaultNotificationPreferences(syntax.DID(userDid)), nil 374 + } 363 375 364 - var prefs models.NotificationPreferences 365 - err := d.DB.QueryRowContext(ctx, query, userFilter.Arg()...).Scan( 366 - &prefs.ID, 367 - &prefs.UserDid, 368 - &prefs.RepoStarred, 369 - &prefs.IssueCreated, 370 - &prefs.IssueCommented, 371 - &prefs.PullCreated, 372 - &prefs.PullCommented, 373 - &prefs.Followed, 374 - &prefs.PullMerged, 375 - &prefs.IssueClosed, 376 - &prefs.EmailNotifications, 377 - ) 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 383 + var args []any 384 + for _, filter := range filters { 385 + conditions = append(conditions, filter.Condition()) 386 + args = append(args, filter.Arg()...) 387 + } 388 + 389 + whereClause := "" 390 + if conditions != nil { 391 + whereClause = " where " + strings.Join(conditions, " and ") 392 + } 393 + 394 + query := fmt.Sprintf(` 395 + select 396 + id, 397 + user_did, 398 + repo_starred, 399 + issue_created, 400 + issue_commented, 401 + pull_created, 402 + pull_commented, 403 + followed, 404 + user_mentioned, 405 + pull_merged, 406 + issue_closed, 407 + email_notifications 408 + from 409 + notification_preferences 410 + %s 411 + `, whereClause) 378 412 413 + rows, err := e.Query(query, args...) 379 414 if err != nil { 380 - if err == sql.ErrNoRows { 381 - return &models.NotificationPreferences{ 382 - UserDid: userDID, 383 - RepoStarred: true, 384 - IssueCreated: true, 385 - IssueCommented: true, 386 - PullCreated: true, 387 - PullCommented: true, 388 - Followed: true, 389 - PullMerged: true, 390 - IssueClosed: true, 391 - EmailNotifications: false, 392 - }, nil 415 + return nil, err 416 + } 417 + defer rows.Close() 418 + 419 + for rows.Next() { 420 + var prefs models.NotificationPreferences 421 + if err := rows.Scan( 422 + &prefs.ID, 423 + &prefs.UserDid, 424 + &prefs.RepoStarred, 425 + &prefs.IssueCreated, 426 + &prefs.IssueCommented, 427 + &prefs.PullCreated, 428 + &prefs.PullCommented, 429 + &prefs.Followed, 430 + &prefs.UserMentioned, 431 + &prefs.PullMerged, 432 + &prefs.IssueClosed, 433 + &prefs.EmailNotifications, 434 + ); err != nil { 435 + return nil, err 393 436 } 394 - return nil, fmt.Errorf("failed to get notification preferences: %w", err) 437 + 438 + prefsMap[prefs.UserDid] = &prefs 395 439 } 396 440 397 - return &prefs, nil 441 + if err := rows.Err(); err != nil { 442 + return nil, err 443 + } 444 + 445 + return prefsMap, nil 398 446 } 399 447 400 448 func (d *DB) UpdateNotificationPreferences(ctx context.Context, prefs *models.NotificationPreferences) error { 401 449 query := ` 402 450 INSERT OR REPLACE INTO notification_preferences 403 451 (user_did, repo_starred, issue_created, issue_commented, pull_created, 404 - pull_commented, followed, pull_merged, issue_closed, email_notifications) 405 - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 452 + pull_commented, followed, user_mentioned, pull_merged, issue_closed, 453 + email_notifications) 454 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 406 455 ` 407 456 408 457 result, err := d.DB.ExecContext(ctx, query, ··· 413 462 prefs.PullCreated, 414 463 prefs.PullCommented, 415 464 prefs.Followed, 465 + prefs.UserMentioned, 416 466 prefs.PullMerged, 417 467 prefs.IssueClosed, 418 468 prefs.EmailNotifications, ··· 434 484 435 485 func (d *DB) ClearOldNotifications(ctx context.Context, olderThan time.Duration) error { 436 486 cutoff := time.Now().Add(-olderThan) 437 - createdFilter := FilterLte("created", cutoff) 487 + createdFilter := orm.FilterLte("created", cutoff) 438 488 439 489 query := fmt.Sprintf(` 440 490 DELETE FROM notifications
+9 -6
appview/db/pipeline.go
··· 7 7 "time" 8 8 9 9 "tangled.org/core/appview/models" 10 + "tangled.org/core/orm" 10 11 ) 11 12 12 - func GetPipelines(e Execer, filters ...filter) ([]models.Pipeline, error) { 13 + func GetPipelines(e Execer, filters ...orm.Filter) ([]models.Pipeline, error) { 13 14 var pipelines []models.Pipeline 14 15 15 16 var conditions []string ··· 168 169 169 170 // this is a mega query, but the most useful one: 170 171 // get N pipelines, for each one get the latest status of its N workflows 171 - func GetPipelineStatuses(e Execer, filters ...filter) ([]models.Pipeline, error) { 172 + func GetPipelineStatuses(e Execer, limit int, filters ...orm.Filter) ([]models.Pipeline, error) { 172 173 var conditions []string 173 174 var args []any 174 175 for _, filter := range filters { 175 - filter.key = "p." + filter.key // the table is aliased in the query to `p` 176 + filter.Key = "p." + filter.Key // the table is aliased in the query to `p` 176 177 conditions = append(conditions, filter.Condition()) 177 178 args = append(args, filter.Arg()...) 178 179 } ··· 205 206 join 206 207 triggers t ON p.trigger_id = t.id 207 208 %s 208 - `, whereClause) 209 + order by p.created desc 210 + limit %d 211 + `, whereClause, limit) 209 212 210 213 rows, err := e.Query(query, args...) 211 214 if err != nil { ··· 262 265 conditions = nil 263 266 args = nil 264 267 for _, p := range pipelines { 265 - knotFilter := FilterEq("pipeline_knot", p.Knot) 266 - rkeyFilter := FilterEq("pipeline_rkey", p.Rkey) 268 + knotFilter := orm.FilterEq("pipeline_knot", p.Knot) 269 + rkeyFilter := orm.FilterEq("pipeline_rkey", p.Rkey) 267 270 conditions = append(conditions, fmt.Sprintf("(%s and %s)", knotFilter.Condition(), rkeyFilter.Condition())) 268 271 args = append(args, p.Knot) 269 272 args = append(args, p.Rkey)
+37 -11
appview/db/profile.go
··· 11 11 12 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 13 "tangled.org/core/appview/models" 14 + "tangled.org/core/orm" 14 15 ) 15 16 16 17 const TimeframeMonths = 7 ··· 44 45 45 46 issues, err := GetIssues( 46 47 e, 47 - FilterEq("did", forDid), 48 - FilterGte("created", time.Now().AddDate(0, -TimeframeMonths, 0)), 48 + orm.FilterEq("did", forDid), 49 + orm.FilterGte("created", time.Now().AddDate(0, -TimeframeMonths, 0)), 49 50 ) 50 51 if err != nil { 51 52 return nil, fmt.Errorf("error getting issues by owner did: %w", err) ··· 65 66 *items = append(*items, &issue) 66 67 } 67 68 68 - repos, err := GetRepos(e, 0, FilterEq("did", forDid)) 69 + repos, err := GetRepos(e, 0, orm.FilterEq("did", forDid)) 69 70 if err != nil { 70 71 return nil, fmt.Errorf("error getting all repos by did: %w", err) 71 72 } ··· 129 130 did, 130 131 description, 131 132 include_bluesky, 132 - location 133 + location, 134 + pronouns 133 135 ) 134 - values (?, ?, ?, ?)`, 136 + values (?, ?, ?, ?, ?)`, 135 137 profile.Did, 136 138 profile.Description, 137 139 includeBskyValue, 138 140 profile.Location, 141 + profile.Pronouns, 139 142 ) 140 143 141 144 if err != nil { ··· 197 200 return tx.Commit() 198 201 } 199 202 200 - func GetProfiles(e Execer, filters ...filter) (map[string]*models.Profile, error) { 203 + func GetProfiles(e Execer, filters ...orm.Filter) (map[string]*models.Profile, error) { 201 204 var conditions []string 202 205 var args []any 203 206 for _, filter := range filters { ··· 216 219 did, 217 220 description, 218 221 include_bluesky, 219 - location 222 + location, 223 + pronouns 220 224 from 221 225 profile 222 226 %s`, ··· 226 230 if err != nil { 227 231 return nil, err 228 232 } 233 + defer rows.Close() 229 234 230 235 profileMap := make(map[string]*models.Profile) 231 236 for rows.Next() { 232 237 var profile models.Profile 233 238 var includeBluesky int 239 + var pronouns sql.Null[string] 234 240 235 - err = rows.Scan(&profile.ID, &profile.Did, &profile.Description, &includeBluesky, &profile.Location) 241 + err = rows.Scan(&profile.ID, &profile.Did, &profile.Description, &includeBluesky, &profile.Location, &pronouns) 236 242 if err != nil { 237 243 return nil, err 238 244 } ··· 241 247 profile.IncludeBluesky = true 242 248 } 243 249 250 + if pronouns.Valid { 251 + profile.Pronouns = pronouns.V 252 + } 253 + 244 254 profileMap[profile.Did] = &profile 245 255 } 246 256 if err = rows.Err(); err != nil { ··· 261 271 if err != nil { 262 272 return nil, err 263 273 } 274 + defer rows.Close() 275 + 264 276 idxs := make(map[string]int) 265 277 for did := range profileMap { 266 278 idxs[did] = 0 ··· 281 293 if err != nil { 282 294 return nil, err 283 295 } 296 + defer rows.Close() 297 + 284 298 idxs = make(map[string]int) 285 299 for did := range profileMap { 286 300 idxs[did] = 0 ··· 302 316 303 317 func GetProfile(e Execer, did string) (*models.Profile, error) { 304 318 var profile models.Profile 319 + var pronouns sql.Null[string] 320 + 305 321 profile.Did = did 306 322 307 323 includeBluesky := 0 324 + 308 325 err := e.QueryRow( 309 - `select description, include_bluesky, location from profile where did = ?`, 326 + `select description, include_bluesky, location, pronouns from profile where did = ?`, 310 327 did, 311 - ).Scan(&profile.Description, &includeBluesky, &profile.Location) 328 + ).Scan(&profile.Description, &includeBluesky, &profile.Location, &pronouns) 312 329 if err == sql.ErrNoRows { 313 330 profile := models.Profile{} 314 331 profile.Did = did ··· 321 338 322 339 if includeBluesky != 0 { 323 340 profile.IncludeBluesky = true 341 + } 342 + 343 + if pronouns.Valid { 344 + profile.Pronouns = pronouns.V 324 345 } 325 346 326 347 rows, err := e.Query(`select link from profile_links where did = ?`, did) ··· 414 435 return fmt.Errorf("Entered location is too long.") 415 436 } 416 437 438 + // ensure pronouns are not too long 439 + if len(profile.Pronouns) > 40 { 440 + return fmt.Errorf("Entered pronouns are too long.") 441 + } 442 + 417 443 // ensure links are in order 418 444 err := validateLinks(profile) 419 445 if err != nil { ··· 421 447 } 422 448 423 449 // ensure all pinned repos are either own repos or collaborating repos 424 - repos, err := GetRepos(e, 0, FilterEq("did", profile.Did)) 450 + repos, err := GetRepos(e, 0, orm.FilterEq("did", profile.Did)) 425 451 if err != nil { 426 452 log.Printf("getting repos for %s: %s", profile.Did, err) 427 453 }
+152 -43
appview/db/pulls.go
··· 13 13 14 14 "github.com/bluesky-social/indigo/atproto/syntax" 15 15 "tangled.org/core/appview/models" 16 + "tangled.org/core/orm" 16 17 ) 17 18 18 19 func NewPull(tx *sql.Tx, pull *models.Pull) error { ··· 90 91 pull.ID = int(id) 91 92 92 93 _, err = tx.Exec(` 93 - insert into pull_submissions (pull_at, round_number, patch, source_rev) 94 - values (?, ?, ?, ?) 95 - `, pull.PullAt(), 0, pull.Submissions[0].Patch, pull.Submissions[0].SourceRev) 96 - return err 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 97 106 } 98 107 99 108 func GetPullAt(e Execer, repoAt syntax.ATURI, pullId int) (syntax.ATURI, error) { ··· 101 110 if err != nil { 102 111 return "", err 103 112 } 104 - return pull.PullAt(), err 113 + return pull.AtUri(), err 105 114 } 106 115 107 116 func NextPullId(e Execer, repoAt syntax.ATURI) (int, error) { ··· 110 119 return pullId - 1, err 111 120 } 112 121 113 - func GetPullsWithLimit(e Execer, limit int, filters ...filter) ([]*models.Pull, error) { 122 + func GetPullsWithLimit(e Execer, limit int, filters ...orm.Filter) ([]*models.Pull, error) { 114 123 pulls := make(map[syntax.ATURI]*models.Pull) 115 124 116 125 var conditions []string ··· 214 223 pull.ParentChangeId = parentChangeId.String 215 224 } 216 225 217 - pulls[pull.PullAt()] = &pull 226 + pulls[pull.AtUri()] = &pull 218 227 } 219 228 220 229 var pullAts []syntax.ATURI 221 230 for _, p := range pulls { 222 - pullAts = append(pullAts, p.PullAt()) 231 + pullAts = append(pullAts, p.AtUri()) 223 232 } 224 - submissionsMap, err := GetPullSubmissions(e, FilterIn("pull_at", pullAts)) 233 + submissionsMap, err := GetPullSubmissions(e, orm.FilterIn("pull_at", pullAts)) 225 234 if err != nil { 226 235 return nil, fmt.Errorf("failed to get submissions: %w", err) 227 236 } ··· 233 242 } 234 243 235 244 // collect allLabels for each issue 236 - allLabels, err := GetLabels(e, FilterIn("subject", pullAts)) 245 + allLabels, err := GetLabels(e, orm.FilterIn("subject", pullAts)) 237 246 if err != nil { 238 247 return nil, fmt.Errorf("failed to query labels: %w", err) 239 248 } ··· 250 259 sourceAts = append(sourceAts, *p.PullSource.RepoAt) 251 260 } 252 261 } 253 - sourceRepos, err := GetRepos(e, 0, FilterIn("at_uri", sourceAts)) 262 + sourceRepos, err := GetRepos(e, 0, orm.FilterIn("at_uri", sourceAts)) 254 263 if err != nil && !errors.Is(err, sql.ErrNoRows) { 255 264 return nil, fmt.Errorf("failed to get source repos: %w", err) 256 265 } ··· 266 275 } 267 276 } 268 277 278 + allReferences, err := GetReferencesAll(e, orm.FilterIn("from_at", pullAts)) 279 + if err != nil { 280 + return nil, fmt.Errorf("failed to query reference_links: %w", err) 281 + } 282 + for pullAt, references := range allReferences { 283 + if pull, ok := pulls[pullAt]; ok { 284 + pull.References = references 285 + } 286 + } 287 + 269 288 orderedByPullId := []*models.Pull{} 270 289 for _, p := range pulls { 271 290 orderedByPullId = append(orderedByPullId, p) ··· 277 296 return orderedByPullId, nil 278 297 } 279 298 280 - func GetPulls(e Execer, filters ...filter) ([]*models.Pull, error) { 299 + func GetPulls(e Execer, filters ...orm.Filter) ([]*models.Pull, error) { 281 300 return GetPullsWithLimit(e, 0, filters...) 282 301 } 283 302 303 + func GetPullIDs(e Execer, opts models.PullSearchOptions) ([]int64, error) { 304 + var ids []int64 305 + 306 + var filters []orm.Filter 307 + filters = append(filters, orm.FilterEq("state", opts.State)) 308 + if opts.RepoAt != "" { 309 + filters = append(filters, orm.FilterEq("repo_at", opts.RepoAt)) 310 + } 311 + 312 + var conditions []string 313 + var args []any 314 + 315 + for _, filter := range filters { 316 + conditions = append(conditions, filter.Condition()) 317 + args = append(args, filter.Arg()...) 318 + } 319 + 320 + whereClause := "" 321 + if conditions != nil { 322 + whereClause = " where " + strings.Join(conditions, " and ") 323 + } 324 + pageClause := "" 325 + if opts.Page.Limit != 0 { 326 + pageClause = fmt.Sprintf( 327 + " limit %d offset %d ", 328 + opts.Page.Limit, 329 + opts.Page.Offset, 330 + ) 331 + } 332 + 333 + query := fmt.Sprintf( 334 + ` 335 + select 336 + id 337 + from 338 + pulls 339 + %s 340 + %s`, 341 + whereClause, 342 + pageClause, 343 + ) 344 + args = append(args, opts.Page.Limit, opts.Page.Offset) 345 + rows, err := e.Query(query, args...) 346 + if err != nil { 347 + return nil, err 348 + } 349 + defer rows.Close() 350 + 351 + for rows.Next() { 352 + var id int64 353 + err := rows.Scan(&id) 354 + if err != nil { 355 + return nil, err 356 + } 357 + 358 + ids = append(ids, id) 359 + } 360 + 361 + return ids, nil 362 + } 363 + 284 364 func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*models.Pull, error) { 285 - pulls, err := GetPullsWithLimit(e, 1, FilterEq("repo_at", repoAt), FilterEq("pull_id", pullId)) 365 + pulls, err := GetPullsWithLimit(e, 1, orm.FilterEq("repo_at", repoAt), orm.FilterEq("pull_id", pullId)) 286 366 if err != nil { 287 367 return nil, err 288 368 } 289 - if pulls == nil { 369 + if len(pulls) == 0 { 290 370 return nil, sql.ErrNoRows 291 371 } 292 372 ··· 294 374 } 295 375 296 376 // mapping from pull -> pull submissions 297 - func GetPullSubmissions(e Execer, filters ...filter) (map[syntax.ATURI][]*models.PullSubmission, error) { 377 + func GetPullSubmissions(e Execer, filters ...orm.Filter) (map[syntax.ATURI][]*models.PullSubmission, error) { 298 378 var conditions []string 299 379 var args []any 300 380 for _, filter := range filters { ··· 313 393 pull_at, 314 394 round_number, 315 395 patch, 396 + combined, 316 397 created, 317 398 source_rev 318 399 from ··· 332 413 333 414 for rows.Next() { 334 415 var submission models.PullSubmission 335 - var createdAt string 336 - var sourceRev sql.NullString 416 + var submissionCreatedStr string 417 + var submissionSourceRev, submissionCombined sql.NullString 337 418 err := rows.Scan( 338 419 &submission.ID, 339 420 &submission.PullAt, 340 421 &submission.RoundNumber, 341 422 &submission.Patch, 342 - &createdAt, 343 - &sourceRev, 423 + &submissionCombined, 424 + &submissionCreatedStr, 425 + &submissionSourceRev, 344 426 ) 345 427 if err != nil { 346 428 return nil, err 347 429 } 348 430 349 - createdTime, err := time.Parse(time.RFC3339, createdAt) 350 - if err != nil { 351 - return nil, err 431 + if t, err := time.Parse(time.RFC3339, submissionCreatedStr); err == nil { 432 + submission.Created = t 433 + } 434 + 435 + if submissionSourceRev.Valid { 436 + submission.SourceRev = submissionSourceRev.String 352 437 } 353 - submission.Created = createdTime 354 438 355 - if sourceRev.Valid { 356 - submission.SourceRev = sourceRev.String 439 + if submissionCombined.Valid { 440 + submission.Combined = submissionCombined.String 357 441 } 358 442 359 443 submissionMap[submission.ID] = &submission ··· 365 449 366 450 // Get comments for all submissions using GetPullComments 367 451 submissionIds := slices.Collect(maps.Keys(submissionMap)) 368 - comments, err := GetPullComments(e, FilterIn("submission_id", submissionIds)) 452 + comments, err := GetPullComments(e, orm.FilterIn("submission_id", submissionIds)) 369 453 if err != nil { 370 - return nil, err 454 + return nil, fmt.Errorf("failed to get pull comments: %w", err) 371 455 } 372 456 for _, comment := range comments { 373 457 if submission, ok := submissionMap[comment.SubmissionId]; ok { ··· 391 475 return m, nil 392 476 } 393 477 394 - func GetPullComments(e Execer, filters ...filter) ([]models.PullComment, error) { 478 + func GetPullComments(e Execer, filters ...orm.Filter) ([]models.PullComment, error) { 395 479 var conditions []string 396 480 var args []any 397 481 for _, filter := range filters { ··· 427 511 } 428 512 defer rows.Close() 429 513 430 - var comments []models.PullComment 514 + commentMap := make(map[string]*models.PullComment) 431 515 for rows.Next() { 432 516 var comment models.PullComment 433 517 var createdAt string ··· 449 533 comment.Created = t 450 534 } 451 535 452 - comments = append(comments, comment) 536 + atUri := comment.AtUri().String() 537 + commentMap[atUri] = &comment 453 538 } 454 539 455 540 if err := rows.Err(); err != nil { 456 541 return nil, err 457 542 } 458 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 + 459 565 return comments, nil 460 566 } 461 567 ··· 535 641 return pulls, nil 536 642 } 537 643 538 - func NewPullComment(e Execer, comment *models.PullComment) (int64, error) { 644 + func NewPullComment(tx *sql.Tx, comment *models.PullComment) (int64, error) { 539 645 query := `insert into pull_comments (owner_did, repo_at, submission_id, comment_at, pull_id, body) values (?, ?, ?, ?, ?, ?)` 540 - res, err := e.Exec( 646 + res, err := tx.Exec( 541 647 query, 542 648 comment.OwnerDid, 543 649 comment.RepoAt, ··· 555 661 return 0, err 556 662 } 557 663 664 + if err := putReferences(tx, comment.AtUri(), comment.References); err != nil { 665 + return 0, fmt.Errorf("put reference_links: %w", err) 666 + } 667 + 558 668 return i, nil 559 669 } 560 670 ··· 590 700 return err 591 701 } 592 702 593 - func ResubmitPull(e Execer, pull *models.Pull, newPatch, sourceRev string) error { 594 - newRoundNumber := len(pull.Submissions) 703 + func ResubmitPull(e Execer, pullAt syntax.ATURI, newRoundNumber int, newPatch string, combinedPatch string, newSourceRev string) error { 595 704 _, err := e.Exec(` 596 - insert into pull_submissions (pull_at, round_number, patch, source_rev) 597 - values (?, ?, ?, ?) 598 - `, pull.PullAt(), newRoundNumber, newPatch, sourceRev) 705 + insert into pull_submissions (pull_at, round_number, patch, combined, source_rev) 706 + values (?, ?, ?, ?, ?) 707 + `, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev) 599 708 600 709 return err 601 710 } 602 711 603 - func SetPullParentChangeId(e Execer, parentChangeId string, filters ...filter) error { 712 + func SetPullParentChangeId(e Execer, parentChangeId string, filters ...orm.Filter) error { 604 713 var conditions []string 605 714 var args []any 606 715 ··· 624 733 625 734 // Only used when stacking to update contents in the event of a rebase (the interdiff should be empty). 626 735 // otherwise submissions are immutable 627 - func UpdatePull(e Execer, newPatch, sourceRev string, filters ...filter) error { 736 + func UpdatePull(e Execer, newPatch, sourceRev string, filters ...orm.Filter) error { 628 737 var conditions []string 629 738 var args []any 630 739 ··· 682 791 func GetStack(e Execer, stackId string) (models.Stack, error) { 683 792 unorderedPulls, err := GetPulls( 684 793 e, 685 - FilterEq("stack_id", stackId), 686 - FilterNotEq("state", models.PullDeleted), 794 + orm.FilterEq("stack_id", stackId), 795 + orm.FilterNotEq("state", models.PullDeleted), 687 796 ) 688 797 if err != nil { 689 798 return nil, err ··· 727 836 func GetAbandonedPulls(e Execer, stackId string) ([]*models.Pull, error) { 728 837 pulls, err := GetPulls( 729 838 e, 730 - FilterEq("stack_id", stackId), 731 - FilterEq("state", models.PullDeleted), 839 + orm.FilterEq("stack_id", stackId), 840 + orm.FilterEq("state", models.PullDeleted), 732 841 ) 733 842 if err != nil { 734 843 return nil, err
+2 -1
appview/db/punchcard.go
··· 7 7 "time" 8 8 9 9 "tangled.org/core/appview/models" 10 + "tangled.org/core/orm" 10 11 ) 11 12 12 13 // this adds to the existing count ··· 20 21 return err 21 22 } 22 23 23 - func MakePunchcard(e Execer, filters ...filter) (*models.Punchcard, error) { 24 + func MakePunchcard(e Execer, filters ...orm.Filter) (*models.Punchcard, error) { 24 25 punchcard := &models.Punchcard{} 25 26 now := time.Now() 26 27 startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC)
+463
appview/db/reference.go
··· 1 + package db 2 + 3 + import ( 4 + "database/sql" 5 + "fmt" 6 + "strings" 7 + 8 + "github.com/bluesky-social/indigo/atproto/syntax" 9 + "tangled.org/core/api/tangled" 10 + "tangled.org/core/appview/models" 11 + "tangled.org/core/orm" 12 + ) 13 + 14 + // ValidateReferenceLinks resolves refLinks to Issue/PR/IssueComment/PullComment ATURIs. 15 + // It will ignore missing refLinks. 16 + func ValidateReferenceLinks(e Execer, refLinks []models.ReferenceLink) ([]syntax.ATURI, error) { 17 + var ( 18 + issueRefs []models.ReferenceLink 19 + pullRefs []models.ReferenceLink 20 + ) 21 + for _, ref := range refLinks { 22 + switch ref.Kind { 23 + case models.RefKindIssue: 24 + issueRefs = append(issueRefs, ref) 25 + case models.RefKindPull: 26 + pullRefs = append(pullRefs, ref) 27 + } 28 + } 29 + issueUris, err := findIssueReferences(e, issueRefs) 30 + if err != nil { 31 + return nil, fmt.Errorf("find issue references: %w", err) 32 + } 33 + pullUris, err := findPullReferences(e, pullRefs) 34 + if err != nil { 35 + return nil, fmt.Errorf("find pull references: %w", err) 36 + } 37 + 38 + return append(issueUris, pullUris...), nil 39 + } 40 + 41 + func findIssueReferences(e Execer, refLinks []models.ReferenceLink) ([]syntax.ATURI, error) { 42 + if len(refLinks) == 0 { 43 + return nil, nil 44 + } 45 + vals := make([]string, len(refLinks)) 46 + args := make([]any, 0, len(refLinks)*4) 47 + for i, ref := range refLinks { 48 + vals[i] = "(?, ?, ?, ?)" 49 + args = append(args, ref.Handle, ref.Repo, ref.SubjectId, ref.CommentId) 50 + } 51 + query := fmt.Sprintf( 52 + `with input(owner_did, name, issue_id, comment_id) as ( 53 + values %s 54 + ) 55 + select 56 + i.did, i.rkey, 57 + c.did, c.rkey 58 + from input inp 59 + join repos r 60 + on r.did = inp.owner_did 61 + and r.name = inp.name 62 + join issues i 63 + on i.repo_at = r.at_uri 64 + and i.issue_id = inp.issue_id 65 + left join issue_comments c 66 + on inp.comment_id is not null 67 + and c.issue_at = i.at_uri 68 + and c.id = inp.comment_id 69 + `, 70 + strings.Join(vals, ","), 71 + ) 72 + rows, err := e.Query(query, args...) 73 + if err != nil { 74 + return nil, err 75 + } 76 + defer rows.Close() 77 + 78 + var uris []syntax.ATURI 79 + 80 + for rows.Next() { 81 + // Scan rows 82 + var issueOwner, issueRkey string 83 + var commentOwner, commentRkey sql.NullString 84 + var uri syntax.ATURI 85 + if err := rows.Scan(&issueOwner, &issueRkey, &commentOwner, &commentRkey); err != nil { 86 + return nil, err 87 + } 88 + if commentOwner.Valid && commentRkey.Valid { 89 + uri = syntax.ATURI(fmt.Sprintf( 90 + "at://%s/%s/%s", 91 + commentOwner.String, 92 + tangled.RepoIssueCommentNSID, 93 + commentRkey.String, 94 + )) 95 + } else { 96 + uri = syntax.ATURI(fmt.Sprintf( 97 + "at://%s/%s/%s", 98 + issueOwner, 99 + tangled.RepoIssueNSID, 100 + issueRkey, 101 + )) 102 + } 103 + uris = append(uris, uri) 104 + } 105 + if err := rows.Err(); err != nil { 106 + return nil, fmt.Errorf("iterate rows: %w", err) 107 + } 108 + 109 + return uris, nil 110 + } 111 + 112 + func findPullReferences(e Execer, refLinks []models.ReferenceLink) ([]syntax.ATURI, error) { 113 + if len(refLinks) == 0 { 114 + return nil, nil 115 + } 116 + vals := make([]string, len(refLinks)) 117 + args := make([]any, 0, len(refLinks)*4) 118 + for i, ref := range refLinks { 119 + vals[i] = "(?, ?, ?, ?)" 120 + args = append(args, ref.Handle, ref.Repo, ref.SubjectId, ref.CommentId) 121 + } 122 + query := fmt.Sprintf( 123 + `with input(owner_did, name, pull_id, comment_id) as ( 124 + values %s 125 + ) 126 + select 127 + p.owner_did, p.rkey, 128 + c.comment_at 129 + from input inp 130 + join repos r 131 + on r.did = inp.owner_did 132 + and r.name = inp.name 133 + join pulls p 134 + on p.repo_at = r.at_uri 135 + and p.pull_id = inp.pull_id 136 + left join pull_comments c 137 + on inp.comment_id is not null 138 + and c.repo_at = r.at_uri and c.pull_id = p.pull_id 139 + and c.id = inp.comment_id 140 + `, 141 + strings.Join(vals, ","), 142 + ) 143 + rows, err := e.Query(query, args...) 144 + if err != nil { 145 + return nil, err 146 + } 147 + defer rows.Close() 148 + 149 + var uris []syntax.ATURI 150 + 151 + for rows.Next() { 152 + // Scan rows 153 + var pullOwner, pullRkey string 154 + var commentUri sql.NullString 155 + var uri syntax.ATURI 156 + if err := rows.Scan(&pullOwner, &pullRkey, &commentUri); err != nil { 157 + return nil, err 158 + } 159 + if commentUri.Valid { 160 + // no-op 161 + uri = syntax.ATURI(commentUri.String) 162 + } else { 163 + uri = syntax.ATURI(fmt.Sprintf( 164 + "at://%s/%s/%s", 165 + pullOwner, 166 + tangled.RepoPullNSID, 167 + pullRkey, 168 + )) 169 + } 170 + uris = append(uris, uri) 171 + } 172 + return uris, nil 173 + } 174 + 175 + func putReferences(tx *sql.Tx, fromAt syntax.ATURI, references []syntax.ATURI) error { 176 + err := deleteReferences(tx, fromAt) 177 + if err != nil { 178 + return fmt.Errorf("delete old reference_links: %w", err) 179 + } 180 + if len(references) == 0 { 181 + return nil 182 + } 183 + 184 + values := make([]string, 0, len(references)) 185 + args := make([]any, 0, len(references)*2) 186 + for _, ref := range references { 187 + values = append(values, "(?, ?)") 188 + args = append(args, fromAt, ref) 189 + } 190 + _, err = tx.Exec( 191 + fmt.Sprintf( 192 + `insert into reference_links (from_at, to_at) 193 + values %s`, 194 + strings.Join(values, ","), 195 + ), 196 + args..., 197 + ) 198 + if err != nil { 199 + return fmt.Errorf("insert new reference_links: %w", err) 200 + } 201 + return nil 202 + } 203 + 204 + func deleteReferences(tx *sql.Tx, fromAt syntax.ATURI) error { 205 + _, err := tx.Exec(`delete from reference_links where from_at = ?`, fromAt) 206 + return err 207 + } 208 + 209 + func GetReferencesAll(e Execer, filters ...orm.Filter) (map[syntax.ATURI][]syntax.ATURI, error) { 210 + var ( 211 + conditions []string 212 + args []any 213 + ) 214 + for _, filter := range filters { 215 + conditions = append(conditions, filter.Condition()) 216 + args = append(args, filter.Arg()...) 217 + } 218 + 219 + whereClause := "" 220 + if conditions != nil { 221 + whereClause = " where " + strings.Join(conditions, " and ") 222 + } 223 + 224 + rows, err := e.Query( 225 + fmt.Sprintf( 226 + `select from_at, to_at from reference_links %s`, 227 + whereClause, 228 + ), 229 + args..., 230 + ) 231 + if err != nil { 232 + return nil, fmt.Errorf("query reference_links: %w", err) 233 + } 234 + defer rows.Close() 235 + 236 + result := make(map[syntax.ATURI][]syntax.ATURI) 237 + 238 + for rows.Next() { 239 + var from, to syntax.ATURI 240 + if err := rows.Scan(&from, &to); err != nil { 241 + return nil, fmt.Errorf("scan row: %w", err) 242 + } 243 + 244 + result[from] = append(result[from], to) 245 + } 246 + if err := rows.Err(); err != nil { 247 + return nil, fmt.Errorf("iterate rows: %w", err) 248 + } 249 + 250 + return result, nil 251 + } 252 + 253 + func GetBacklinks(e Execer, target syntax.ATURI) ([]models.RichReferenceLink, error) { 254 + rows, err := e.Query( 255 + `select from_at from reference_links 256 + where to_at = ?`, 257 + target, 258 + ) 259 + if err != nil { 260 + return nil, fmt.Errorf("query backlinks: %w", err) 261 + } 262 + defer rows.Close() 263 + 264 + var ( 265 + backlinks []models.RichReferenceLink 266 + backlinksMap = make(map[string][]syntax.ATURI) 267 + ) 268 + for rows.Next() { 269 + var from syntax.ATURI 270 + if err := rows.Scan(&from); err != nil { 271 + return nil, fmt.Errorf("scan row: %w", err) 272 + } 273 + nsid := from.Collection().String() 274 + backlinksMap[nsid] = append(backlinksMap[nsid], from) 275 + } 276 + if err := rows.Err(); err != nil { 277 + return nil, fmt.Errorf("iterate rows: %w", err) 278 + } 279 + 280 + var ls []models.RichReferenceLink 281 + ls, err = getIssueBacklinks(e, backlinksMap[tangled.RepoIssueNSID]) 282 + if err != nil { 283 + return nil, fmt.Errorf("get issue backlinks: %w", err) 284 + } 285 + backlinks = append(backlinks, ls...) 286 + ls, err = getIssueCommentBacklinks(e, backlinksMap[tangled.RepoIssueCommentNSID]) 287 + if err != nil { 288 + return nil, fmt.Errorf("get issue_comment backlinks: %w", err) 289 + } 290 + backlinks = append(backlinks, ls...) 291 + ls, err = getPullBacklinks(e, backlinksMap[tangled.RepoPullNSID]) 292 + if err != nil { 293 + return nil, fmt.Errorf("get pull backlinks: %w", err) 294 + } 295 + backlinks = append(backlinks, ls...) 296 + ls, err = getPullCommentBacklinks(e, backlinksMap[tangled.RepoPullCommentNSID]) 297 + if err != nil { 298 + return nil, fmt.Errorf("get pull_comment backlinks: %w", err) 299 + } 300 + backlinks = append(backlinks, ls...) 301 + 302 + return backlinks, nil 303 + } 304 + 305 + func getIssueBacklinks(e Execer, aturis []syntax.ATURI) ([]models.RichReferenceLink, error) { 306 + if len(aturis) == 0 { 307 + return nil, nil 308 + } 309 + vals := make([]string, len(aturis)) 310 + args := make([]any, 0, len(aturis)*2) 311 + for i, aturi := range aturis { 312 + vals[i] = "(?, ?)" 313 + did := aturi.Authority().String() 314 + rkey := aturi.RecordKey().String() 315 + args = append(args, did, rkey) 316 + } 317 + rows, err := e.Query( 318 + fmt.Sprintf( 319 + `select r.did, r.name, i.issue_id, i.title, i.open 320 + from issues i 321 + join repos r 322 + on r.at_uri = i.repo_at 323 + where (i.did, i.rkey) in (%s)`, 324 + strings.Join(vals, ","), 325 + ), 326 + args..., 327 + ) 328 + if err != nil { 329 + return nil, err 330 + } 331 + defer rows.Close() 332 + var refLinks []models.RichReferenceLink 333 + for rows.Next() { 334 + var l models.RichReferenceLink 335 + l.Kind = models.RefKindIssue 336 + if err := rows.Scan(&l.Handle, &l.Repo, &l.SubjectId, &l.Title, &l.State); err != nil { 337 + return nil, err 338 + } 339 + refLinks = append(refLinks, l) 340 + } 341 + if err := rows.Err(); err != nil { 342 + return nil, fmt.Errorf("iterate rows: %w", err) 343 + } 344 + return refLinks, nil 345 + } 346 + 347 + func getIssueCommentBacklinks(e Execer, aturis []syntax.ATURI) ([]models.RichReferenceLink, error) { 348 + if len(aturis) == 0 { 349 + return nil, nil 350 + } 351 + filter := orm.FilterIn("c.at_uri", aturis) 352 + rows, err := e.Query( 353 + fmt.Sprintf( 354 + `select r.did, r.name, i.issue_id, c.id, i.title, i.open 355 + from issue_comments c 356 + join issues i 357 + on i.at_uri = c.issue_at 358 + join repos r 359 + on r.at_uri = i.repo_at 360 + where %s`, 361 + filter.Condition(), 362 + ), 363 + filter.Arg()..., 364 + ) 365 + if err != nil { 366 + return nil, err 367 + } 368 + defer rows.Close() 369 + var refLinks []models.RichReferenceLink 370 + for rows.Next() { 371 + var l models.RichReferenceLink 372 + l.Kind = models.RefKindIssue 373 + l.CommentId = new(int) 374 + if err := rows.Scan(&l.Handle, &l.Repo, &l.SubjectId, l.CommentId, &l.Title, &l.State); err != nil { 375 + return nil, err 376 + } 377 + refLinks = append(refLinks, l) 378 + } 379 + if err := rows.Err(); err != nil { 380 + return nil, fmt.Errorf("iterate rows: %w", err) 381 + } 382 + return refLinks, nil 383 + } 384 + 385 + func getPullBacklinks(e Execer, aturis []syntax.ATURI) ([]models.RichReferenceLink, error) { 386 + if len(aturis) == 0 { 387 + return nil, nil 388 + } 389 + vals := make([]string, len(aturis)) 390 + args := make([]any, 0, len(aturis)*2) 391 + for i, aturi := range aturis { 392 + vals[i] = "(?, ?)" 393 + did := aturi.Authority().String() 394 + rkey := aturi.RecordKey().String() 395 + args = append(args, did, rkey) 396 + } 397 + rows, err := e.Query( 398 + fmt.Sprintf( 399 + `select r.did, r.name, p.pull_id, p.title, p.state 400 + from pulls p 401 + join repos r 402 + on r.at_uri = p.repo_at 403 + where (p.owner_did, p.rkey) in (%s)`, 404 + strings.Join(vals, ","), 405 + ), 406 + args..., 407 + ) 408 + if err != nil { 409 + return nil, err 410 + } 411 + defer rows.Close() 412 + var refLinks []models.RichReferenceLink 413 + for rows.Next() { 414 + var l models.RichReferenceLink 415 + l.Kind = models.RefKindPull 416 + if err := rows.Scan(&l.Handle, &l.Repo, &l.SubjectId, &l.Title, &l.State); err != nil { 417 + return nil, err 418 + } 419 + refLinks = append(refLinks, l) 420 + } 421 + if err := rows.Err(); err != nil { 422 + return nil, fmt.Errorf("iterate rows: %w", err) 423 + } 424 + return refLinks, nil 425 + } 426 + 427 + func getPullCommentBacklinks(e Execer, aturis []syntax.ATURI) ([]models.RichReferenceLink, error) { 428 + if len(aturis) == 0 { 429 + return nil, nil 430 + } 431 + filter := orm.FilterIn("c.comment_at", aturis) 432 + rows, err := e.Query( 433 + fmt.Sprintf( 434 + `select r.did, r.name, p.pull_id, c.id, p.title, p.state 435 + from repos r 436 + join pulls p 437 + on r.at_uri = p.repo_at 438 + join pull_comments c 439 + on r.at_uri = c.repo_at and p.pull_id = c.pull_id 440 + where %s`, 441 + filter.Condition(), 442 + ), 443 + filter.Arg()..., 444 + ) 445 + if err != nil { 446 + return nil, err 447 + } 448 + defer rows.Close() 449 + var refLinks []models.RichReferenceLink 450 + for rows.Next() { 451 + var l models.RichReferenceLink 452 + l.Kind = models.RefKindPull 453 + l.CommentId = new(int) 454 + if err := rows.Scan(&l.Handle, &l.Repo, &l.SubjectId, l.CommentId, &l.Title, &l.State); err != nil { 455 + return nil, err 456 + } 457 + refLinks = append(refLinks, l) 458 + } 459 + if err := rows.Err(); err != nil { 460 + return nil, fmt.Errorf("iterate rows: %w", err) 461 + } 462 + return refLinks, nil 463 + }
+5 -3
appview/db/registration.go
··· 7 7 "time" 8 8 9 9 "tangled.org/core/appview/models" 10 + "tangled.org/core/orm" 10 11 ) 11 12 12 - func GetRegistrations(e Execer, filters ...filter) ([]models.Registration, error) { 13 + func GetRegistrations(e Execer, filters ...orm.Filter) ([]models.Registration, error) { 13 14 var registrations []models.Registration 14 15 15 16 var conditions []string ··· 37 38 if err != nil { 38 39 return nil, err 39 40 } 41 + defer rows.Close() 40 42 41 43 for rows.Next() { 42 44 var createdAt string ··· 69 71 return registrations, nil 70 72 } 71 73 72 - func MarkRegistered(e Execer, filters ...filter) error { 74 + func MarkRegistered(e Execer, filters ...orm.Filter) error { 73 75 var conditions []string 74 76 var args []any 75 77 for _, filter := range filters { ··· 94 96 return err 95 97 } 96 98 97 - func DeleteKnot(e Execer, filters ...filter) error { 99 + func DeleteKnot(e Execer, filters ...orm.Filter) error { 98 100 var conditions []string 99 101 var args []any 100 102 for _, filter := range filters {
+81 -49
appview/db/repos.go
··· 10 10 "time" 11 11 12 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 - securejoin "github.com/cyphar/filepath-securejoin" 14 - "tangled.org/core/api/tangled" 15 13 "tangled.org/core/appview/models" 14 + "tangled.org/core/orm" 16 15 ) 17 16 18 - type Repo struct { 19 - Id int64 20 - Did string 21 - Name string 22 - Knot string 23 - Rkey string 24 - Created time.Time 25 - Description string 26 - Spindle string 27 - 28 - // optionally, populate this when querying for reverse mappings 29 - RepoStats *models.RepoStats 30 - 31 - // optional 32 - Source string 33 - } 34 - 35 - func (r Repo) RepoAt() syntax.ATURI { 36 - return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", r.Did, tangled.RepoNSID, r.Rkey)) 37 - } 38 - 39 - func (r Repo) DidSlashRepo() string { 40 - p, _ := securejoin.SecureJoin(r.Did, r.Name) 41 - return p 42 - } 43 - 44 - func GetRepos(e Execer, limit int, filters ...filter) ([]models.Repo, error) { 17 + func GetRepos(e Execer, limit int, filters ...orm.Filter) ([]models.Repo, error) { 45 18 repoMap := make(map[syntax.ATURI]*models.Repo) 46 19 47 20 var conditions []string ··· 70 43 rkey, 71 44 created, 72 45 description, 46 + website, 47 + topics, 73 48 source, 74 49 spindle 75 50 from ··· 81 56 limitClause, 82 57 ) 83 58 rows, err := e.Query(repoQuery, args...) 84 - 85 59 if err != nil { 86 60 return nil, fmt.Errorf("failed to execute repo query: %w ", err) 87 61 } 62 + defer rows.Close() 88 63 89 64 for rows.Next() { 90 65 var repo models.Repo 91 66 var createdAt string 92 - var description, source, spindle sql.NullString 67 + var description, website, topicStr, source, spindle sql.NullString 93 68 94 69 err := rows.Scan( 95 70 &repo.Id, ··· 99 74 &repo.Rkey, 100 75 &createdAt, 101 76 &description, 77 + &website, 78 + &topicStr, 102 79 &source, 103 80 &spindle, 104 81 ) ··· 112 89 if description.Valid { 113 90 repo.Description = description.String 114 91 } 92 + if website.Valid { 93 + repo.Website = website.String 94 + } 95 + if topicStr.Valid { 96 + repo.Topics = strings.Fields(topicStr.String) 97 + } 115 98 if source.Valid { 116 99 repo.Source = source.String 117 100 } ··· 145 128 if err != nil { 146 129 return nil, fmt.Errorf("failed to execute labels query: %w ", err) 147 130 } 131 + defer rows.Close() 132 + 148 133 for rows.Next() { 149 134 var repoat, labelat string 150 135 if err := rows.Scan(&repoat, &labelat); err != nil { ··· 182 167 if err != nil { 183 168 return nil, fmt.Errorf("failed to execute lang query: %w ", err) 184 169 } 170 + defer rows.Close() 171 + 185 172 for rows.Next() { 186 173 var repoat, lang string 187 174 if err := rows.Scan(&repoat, &lang); err != nil { ··· 198 185 199 186 starCountQuery := fmt.Sprintf( 200 187 `select 201 - repo_at, count(1) 188 + subject_at, count(1) 202 189 from stars 203 - where repo_at in (%s) 204 - group by repo_at`, 190 + where subject_at in (%s) 191 + group by subject_at`, 205 192 inClause, 206 193 ) 207 194 rows, err = e.Query(starCountQuery, args...) 208 195 if err != nil { 209 196 return nil, fmt.Errorf("failed to execute star-count query: %w ", err) 210 197 } 198 + defer rows.Close() 199 + 211 200 for rows.Next() { 212 201 var repoat string 213 202 var count int ··· 237 226 if err != nil { 238 227 return nil, fmt.Errorf("failed to execute issue-count query: %w ", err) 239 228 } 229 + defer rows.Close() 230 + 240 231 for rows.Next() { 241 232 var repoat string 242 233 var open, closed int ··· 278 269 if err != nil { 279 270 return nil, fmt.Errorf("failed to execute pulls-count query: %w ", err) 280 271 } 272 + defer rows.Close() 273 + 281 274 for rows.Next() { 282 275 var repoat string 283 276 var open, merged, closed, deleted int ··· 312 305 } 313 306 314 307 // helper to get exactly one repo 315 - func GetRepo(e Execer, filters ...filter) (*models.Repo, error) { 308 + func GetRepo(e Execer, filters ...orm.Filter) (*models.Repo, error) { 316 309 repos, err := GetRepos(e, 0, filters...) 317 310 if err != nil { 318 311 return nil, err ··· 329 322 return &repos[0], nil 330 323 } 331 324 332 - func CountRepos(e Execer, filters ...filter) (int64, error) { 325 + func CountRepos(e Execer, filters ...orm.Filter) (int64, error) { 333 326 var conditions []string 334 327 var args []any 335 328 for _, filter := range filters { ··· 356 349 func GetRepoByAtUri(e Execer, atUri string) (*models.Repo, error) { 357 350 var repo models.Repo 358 351 var nullableDescription sql.NullString 352 + var nullableWebsite sql.NullString 353 + var nullableTopicStr sql.NullString 359 354 360 - row := e.QueryRow(`select id, did, name, knot, created, rkey, description from repos where at_uri = ?`, atUri) 355 + row := e.QueryRow(`select id, did, name, knot, created, rkey, description, website, topics from repos where at_uri = ?`, atUri) 361 356 362 357 var createdAt string 363 - if err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription); err != nil { 358 + if err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription, &nullableWebsite, &nullableTopicStr); err != nil { 364 359 return nil, err 365 360 } 366 361 createdAtTime, _ := time.Parse(time.RFC3339, createdAt) ··· 368 363 369 364 if nullableDescription.Valid { 370 365 repo.Description = nullableDescription.String 371 - } else { 372 - repo.Description = "" 366 + } 367 + if nullableWebsite.Valid { 368 + repo.Website = nullableWebsite.String 369 + } 370 + if nullableTopicStr.Valid { 371 + repo.Topics = strings.Fields(nullableTopicStr.String) 373 372 } 374 373 375 374 return &repo, nil 376 375 } 377 376 377 + func PutRepo(tx *sql.Tx, repo models.Repo) error { 378 + _, err := tx.Exec( 379 + `update repos 380 + set knot = ?, description = ?, website = ?, topics = ? 381 + where did = ? and rkey = ? 382 + `, 383 + repo.Knot, repo.Description, repo.Website, repo.TopicStr(), repo.Did, repo.Rkey, 384 + ) 385 + return err 386 + } 387 + 378 388 func AddRepo(tx *sql.Tx, repo *models.Repo) error { 379 389 _, err := tx.Exec( 380 390 `insert into repos 381 - (did, name, knot, rkey, at_uri, description, source) 382 - values (?, ?, ?, ?, ?, ?, ?)`, 383 - repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.RepoAt().String(), repo.Description, repo.Source, 391 + (did, name, knot, rkey, at_uri, description, website, topics, source) 392 + values (?, ?, ?, ?, ?, ?, ?, ?, ?)`, 393 + repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.RepoAt().String(), repo.Description, repo.Website, repo.TopicStr(), repo.Source, 384 394 ) 385 395 if err != nil { 386 396 return fmt.Errorf("failed to insert repo: %w", err) ··· 412 422 return nullableSource.String, nil 413 423 } 414 424 425 + func GetRepoSourceRepo(e Execer, repoAt syntax.ATURI) (*models.Repo, error) { 426 + source, err := GetRepoSource(e, repoAt) 427 + if source == "" || errors.Is(err, sql.ErrNoRows) { 428 + return nil, nil 429 + } 430 + if err != nil { 431 + return nil, err 432 + } 433 + return GetRepoByAtUri(e, source) 434 + } 435 + 415 436 func GetForksByDid(e Execer, did string) ([]models.Repo, error) { 416 437 var repos []models.Repo 417 438 418 439 rows, err := e.Query( 419 - `select distinct r.id, r.did, r.name, r.knot, r.rkey, r.description, r.created, r.source 440 + `select distinct r.id, r.did, r.name, r.knot, r.rkey, r.description, r.website, r.created, r.source 420 441 from repos r 421 442 left join collaborators c on r.at_uri = c.repo_at 422 443 where (r.did = ? or c.subject_did = ?) ··· 434 455 var repo models.Repo 435 456 var createdAt string 436 457 var nullableDescription sql.NullString 458 + var nullableWebsite sql.NullString 437 459 var nullableSource sql.NullString 438 460 439 - err := rows.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 461 + err := rows.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &nullableWebsite, &createdAt, &nullableSource) 440 462 if err != nil { 441 463 return nil, err 442 464 } ··· 470 492 var repo models.Repo 471 493 var createdAt string 472 494 var nullableDescription sql.NullString 495 + var nullableWebsite sql.NullString 496 + var nullableTopicStr sql.NullString 473 497 var nullableSource sql.NullString 474 498 475 499 row := e.QueryRow( 476 - `select id, did, name, knot, rkey, description, created, source 500 + `select id, did, name, knot, rkey, description, website, topics, created, source 477 501 from repos 478 502 where did = ? and name = ? and source is not null and source != ''`, 479 503 did, name, 480 504 ) 481 505 482 - err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 506 + err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &nullableWebsite, &nullableTopicStr, &createdAt, &nullableSource) 483 507 if err != nil { 484 508 return nil, err 485 509 } ··· 488 512 repo.Description = nullableDescription.String 489 513 } 490 514 515 + if nullableWebsite.Valid { 516 + repo.Website = nullableWebsite.String 517 + } 518 + 519 + if nullableTopicStr.Valid { 520 + repo.Topics = strings.Fields(nullableTopicStr.String) 521 + } 522 + 491 523 if nullableSource.Valid { 492 524 repo.Source = nullableSource.String 493 525 } ··· 521 553 return err 522 554 } 523 555 524 - func UnsubscribeLabel(e Execer, filters ...filter) error { 556 + func UnsubscribeLabel(e Execer, filters ...orm.Filter) error { 525 557 var conditions []string 526 558 var args []any 527 559 for _, filter := range filters { ··· 539 571 return err 540 572 } 541 573 542 - func GetRepoLabels(e Execer, filters ...filter) ([]models.RepoLabel, error) { 574 + func GetRepoLabels(e Execer, filters ...orm.Filter) ([]models.RepoLabel, error) { 543 575 var conditions []string 544 576 var args []any 545 577 for _, filter := range filters {
+6 -5
appview/db/spindle.go
··· 7 7 "time" 8 8 9 9 "tangled.org/core/appview/models" 10 + "tangled.org/core/orm" 10 11 ) 11 12 12 - func GetSpindles(e Execer, filters ...filter) ([]models.Spindle, error) { 13 + func GetSpindles(e Execer, filters ...orm.Filter) ([]models.Spindle, error) { 13 14 var spindles []models.Spindle 14 15 15 16 var conditions []string ··· 91 92 return err 92 93 } 93 94 94 - func VerifySpindle(e Execer, filters ...filter) (int64, error) { 95 + func VerifySpindle(e Execer, filters ...orm.Filter) (int64, error) { 95 96 var conditions []string 96 97 var args []any 97 98 for _, filter := range filters { ··· 114 115 return res.RowsAffected() 115 116 } 116 117 117 - func DeleteSpindle(e Execer, filters ...filter) error { 118 + func DeleteSpindle(e Execer, filters ...orm.Filter) error { 118 119 var conditions []string 119 120 var args []any 120 121 for _, filter := range filters { ··· 144 145 return err 145 146 } 146 147 147 - func RemoveSpindleMember(e Execer, filters ...filter) error { 148 + func RemoveSpindleMember(e Execer, filters ...orm.Filter) error { 148 149 var conditions []string 149 150 var args []any 150 151 for _, filter := range filters { ··· 163 164 return err 164 165 } 165 166 166 - func GetSpindleMembers(e Execer, filters ...filter) ([]models.SpindleMember, error) { 167 + func GetSpindleMembers(e Execer, filters ...orm.Filter) ([]models.SpindleMember, error) { 167 168 var members []models.SpindleMember 168 169 169 170 var conditions []string
+44 -102
appview/db/star.go
··· 11 11 12 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 13 "tangled.org/core/appview/models" 14 + "tangled.org/core/orm" 14 15 ) 15 16 16 17 func AddStar(e Execer, star *models.Star) error { 17 - query := `insert or ignore into stars (starred_by_did, repo_at, rkey) values (?, ?, ?)` 18 + query := `insert or ignore into stars (did, subject_at, rkey) values (?, ?, ?)` 18 19 _, err := e.Exec( 19 20 query, 20 - star.StarredByDid, 21 + star.Did, 21 22 star.RepoAt.String(), 22 23 star.Rkey, 23 24 ) ··· 25 26 } 26 27 27 28 // Get a star record 28 - func GetStar(e Execer, starredByDid string, repoAt syntax.ATURI) (*models.Star, error) { 29 + func GetStar(e Execer, did string, subjectAt syntax.ATURI) (*models.Star, error) { 29 30 query := ` 30 - select starred_by_did, repo_at, created, rkey 31 + select did, subject_at, created, rkey 31 32 from stars 32 - where starred_by_did = ? and repo_at = ?` 33 - row := e.QueryRow(query, starredByDid, repoAt) 33 + where did = ? and subject_at = ?` 34 + row := e.QueryRow(query, did, subjectAt) 34 35 35 36 var star models.Star 36 37 var created string 37 - err := row.Scan(&star.StarredByDid, &star.RepoAt, &created, &star.Rkey) 38 + err := row.Scan(&star.Did, &star.RepoAt, &created, &star.Rkey) 38 39 if err != nil { 39 40 return nil, err 40 41 } ··· 51 52 } 52 53 53 54 // Remove a star 54 - func DeleteStar(e Execer, starredByDid string, repoAt syntax.ATURI) error { 55 - _, err := e.Exec(`delete from stars where starred_by_did = ? and repo_at = ?`, starredByDid, repoAt) 55 + func DeleteStar(e Execer, did string, subjectAt syntax.ATURI) error { 56 + _, err := e.Exec(`delete from stars where did = ? and subject_at = ?`, did, subjectAt) 56 57 return err 57 58 } 58 59 59 60 // Remove a star 60 - func DeleteStarByRkey(e Execer, starredByDid string, rkey string) error { 61 - _, err := e.Exec(`delete from stars where starred_by_did = ? and rkey = ?`, starredByDid, rkey) 61 + func DeleteStarByRkey(e Execer, did string, rkey string) error { 62 + _, err := e.Exec(`delete from stars where did = ? and rkey = ?`, did, rkey) 62 63 return err 63 64 } 64 65 65 - func GetStarCount(e Execer, repoAt syntax.ATURI) (int, error) { 66 + func GetStarCount(e Execer, subjectAt syntax.ATURI) (int, error) { 66 67 stars := 0 67 68 err := e.QueryRow( 68 - `select count(starred_by_did) from stars where repo_at = ?`, repoAt).Scan(&stars) 69 + `select count(did) from stars where subject_at = ?`, subjectAt).Scan(&stars) 69 70 if err != nil { 70 71 return 0, err 71 72 } ··· 89 90 } 90 91 91 92 query := fmt.Sprintf(` 92 - SELECT repo_at 93 + SELECT subject_at 93 94 FROM stars 94 - WHERE starred_by_did = ? AND repo_at IN (%s) 95 + WHERE did = ? AND subject_at IN (%s) 95 96 `, strings.Join(placeholders, ",")) 96 97 97 98 rows, err := e.Query(query, args...) ··· 118 119 return result, nil 119 120 } 120 121 121 - func GetStarStatus(e Execer, userDid string, repoAt syntax.ATURI) bool { 122 - statuses, err := getStarStatuses(e, userDid, []syntax.ATURI{repoAt}) 122 + func GetStarStatus(e Execer, userDid string, subjectAt syntax.ATURI) bool { 123 + statuses, err := getStarStatuses(e, userDid, []syntax.ATURI{subjectAt}) 123 124 if err != nil { 124 125 return false 125 126 } 126 - return statuses[repoAt.String()] 127 + return statuses[subjectAt.String()] 127 128 } 128 129 129 130 // GetStarStatuses returns a map of repo URIs to star status for a given user 130 - func GetStarStatuses(e Execer, userDid string, repoAts []syntax.ATURI) (map[string]bool, error) { 131 - return getStarStatuses(e, userDid, repoAts) 131 + func GetStarStatuses(e Execer, userDid string, subjectAts []syntax.ATURI) (map[string]bool, error) { 132 + return getStarStatuses(e, userDid, subjectAts) 132 133 } 133 - func GetStars(e Execer, limit int, filters ...filter) ([]models.Star, error) { 134 + 135 + // GetRepoStars return a list of stars each holding target repository. 136 + // If there isn't known repo with starred at-uri, those stars will be ignored. 137 + func GetRepoStars(e Execer, limit int, filters ...orm.Filter) ([]models.RepoStar, error) { 134 138 var conditions []string 135 139 var args []any 136 140 for _, filter := range filters { ··· 149 153 } 150 154 151 155 repoQuery := fmt.Sprintf( 152 - `select starred_by_did, repo_at, created, rkey 156 + `select did, subject_at, created, rkey 153 157 from stars 154 158 %s 155 159 order by created desc ··· 161 165 if err != nil { 162 166 return nil, err 163 167 } 168 + defer rows.Close() 164 169 165 170 starMap := make(map[string][]models.Star) 166 171 for rows.Next() { 167 172 var star models.Star 168 173 var created string 169 - err := rows.Scan(&star.StarredByDid, &star.RepoAt, &created, &star.Rkey) 174 + err := rows.Scan(&star.Did, &star.RepoAt, &created, &star.Rkey) 170 175 if err != nil { 171 176 return nil, err 172 177 } ··· 192 197 return nil, nil 193 198 } 194 199 195 - repos, err := GetRepos(e, 0, FilterIn("at_uri", args)) 200 + repos, err := GetRepos(e, 0, orm.FilterIn("at_uri", args)) 196 201 if err != nil { 197 202 return nil, err 198 203 } 199 204 205 + var repoStars []models.RepoStar 200 206 for _, r := range repos { 201 207 if stars, ok := starMap[string(r.RepoAt())]; ok { 202 - for i := range stars { 203 - stars[i].Repo = &r 208 + for _, star := range stars { 209 + repoStars = append(repoStars, models.RepoStar{ 210 + Star: star, 211 + Repo: &r, 212 + }) 204 213 } 205 214 } 206 215 } 207 216 208 - var stars []models.Star 209 - for _, s := range starMap { 210 - stars = append(stars, s...) 211 - } 212 - 213 - slices.SortFunc(stars, func(a, b models.Star) int { 217 + slices.SortFunc(repoStars, func(a, b models.RepoStar) int { 214 218 if a.Created.After(b.Created) { 215 219 return -1 216 220 } ··· 220 224 return 0 221 225 }) 222 226 223 - return stars, nil 227 + return repoStars, nil 224 228 } 225 229 226 - func CountStars(e Execer, filters ...filter) (int64, error) { 230 + func CountStars(e Execer, filters ...orm.Filter) (int64, error) { 227 231 var conditions []string 228 232 var args []any 229 233 for _, filter := range filters { ··· 247 251 return count, nil 248 252 } 249 253 250 - func GetAllStars(e Execer, limit int) ([]models.Star, error) { 251 - var stars []models.Star 252 - 253 - rows, err := e.Query(` 254 - select 255 - s.starred_by_did, 256 - s.repo_at, 257 - s.rkey, 258 - s.created, 259 - r.did, 260 - r.name, 261 - r.knot, 262 - r.rkey, 263 - r.created 264 - from stars s 265 - join repos r on s.repo_at = r.at_uri 266 - `) 267 - 268 - if err != nil { 269 - return nil, err 270 - } 271 - defer rows.Close() 272 - 273 - for rows.Next() { 274 - var star models.Star 275 - var repo models.Repo 276 - var starCreatedAt, repoCreatedAt string 277 - 278 - if err := rows.Scan( 279 - &star.StarredByDid, 280 - &star.RepoAt, 281 - &star.Rkey, 282 - &starCreatedAt, 283 - &repo.Did, 284 - &repo.Name, 285 - &repo.Knot, 286 - &repo.Rkey, 287 - &repoCreatedAt, 288 - ); err != nil { 289 - return nil, err 290 - } 291 - 292 - star.Created, err = time.Parse(time.RFC3339, starCreatedAt) 293 - if err != nil { 294 - star.Created = time.Now() 295 - } 296 - repo.Created, err = time.Parse(time.RFC3339, repoCreatedAt) 297 - if err != nil { 298 - repo.Created = time.Now() 299 - } 300 - star.Repo = &repo 301 - 302 - stars = append(stars, star) 303 - } 304 - 305 - if err := rows.Err(); err != nil { 306 - return nil, err 307 - } 308 - 309 - return stars, nil 310 - } 311 - 312 254 // GetTopStarredReposLastWeek returns the top 8 most starred repositories from the last week 313 255 func GetTopStarredReposLastWeek(e Execer) ([]models.Repo, error) { 314 256 // first, get the top repo URIs by star count from the last week 315 257 query := ` 316 258 with recent_starred_repos as ( 317 - select distinct repo_at 259 + select distinct subject_at 318 260 from stars 319 261 where created >= datetime('now', '-7 days') 320 262 ), 321 263 repo_star_counts as ( 322 264 select 323 - s.repo_at, 265 + s.subject_at, 324 266 count(*) as stars_gained_last_week 325 267 from stars s 326 - join recent_starred_repos rsr on s.repo_at = rsr.repo_at 268 + join recent_starred_repos rsr on s.subject_at = rsr.subject_at 327 269 where s.created >= datetime('now', '-7 days') 328 - group by s.repo_at 270 + group by s.subject_at 329 271 ) 330 - select rsc.repo_at 272 + select rsc.subject_at 331 273 from repo_star_counts rsc 332 274 order by rsc.stars_gained_last_week desc 333 275 limit 8 ··· 358 300 } 359 301 360 302 // get full repo data 361 - repos, err := GetRepos(e, 0, FilterIn("at_uri", repoUris)) 303 + repos, err := GetRepos(e, 0, orm.FilterIn("at_uri", repoUris)) 362 304 if err != nil { 363 305 return nil, err 364 306 }
+4 -3
appview/db/strings.go
··· 8 8 "time" 9 9 10 10 "tangled.org/core/appview/models" 11 + "tangled.org/core/orm" 11 12 ) 12 13 13 14 func AddString(e Execer, s models.String) error { ··· 44 45 return err 45 46 } 46 47 47 - func GetStrings(e Execer, limit int, filters ...filter) ([]models.String, error) { 48 + func GetStrings(e Execer, limit int, filters ...orm.Filter) ([]models.String, error) { 48 49 var all []models.String 49 50 50 51 var conditions []string ··· 127 128 return all, nil 128 129 } 129 130 130 - func CountStrings(e Execer, filters ...filter) (int64, error) { 131 + func CountStrings(e Execer, filters ...orm.Filter) (int64, error) { 131 132 var conditions []string 132 133 var args []any 133 134 for _, filter := range filters { ··· 151 152 return count, nil 152 153 } 153 154 154 - func DeleteString(e Execer, filters ...filter) error { 155 + func DeleteString(e Execer, filters ...orm.Filter) error { 155 156 var conditions []string 156 157 var args []any 157 158 for _, filter := range filters {
+42 -23
appview/db/timeline.go
··· 5 5 6 6 "github.com/bluesky-social/indigo/atproto/syntax" 7 7 "tangled.org/core/appview/models" 8 + "tangled.org/core/orm" 8 9 ) 9 10 10 11 // TODO: this gathers heterogenous events from different sources and aggregates 11 12 // them in code; if we did this entirely in sql, we could order and limit and paginate easily 12 - func MakeTimeline(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) { 13 + func MakeTimeline(e Execer, limit int, loggedInUserDid string, limitToUsersIsFollowing bool) ([]models.TimelineEvent, error) { 13 14 var events []models.TimelineEvent 14 15 15 - repos, err := getTimelineRepos(e, limit, loggedInUserDid) 16 + var userIsFollowing []string 17 + if limitToUsersIsFollowing { 18 + following, err := GetFollowing(e, loggedInUserDid) 19 + if err != nil { 20 + return nil, err 21 + } 22 + 23 + userIsFollowing = make([]string, 0, len(following)) 24 + for _, follow := range following { 25 + userIsFollowing = append(userIsFollowing, follow.SubjectDid) 26 + } 27 + } 28 + 29 + repos, err := getTimelineRepos(e, limit, loggedInUserDid, userIsFollowing) 16 30 if err != nil { 17 31 return nil, err 18 32 } 19 33 20 - stars, err := getTimelineStars(e, limit, loggedInUserDid) 34 + stars, err := getTimelineStars(e, limit, loggedInUserDid, userIsFollowing) 21 35 if err != nil { 22 36 return nil, err 23 37 } 24 38 25 - follows, err := getTimelineFollows(e, limit, loggedInUserDid) 39 + follows, err := getTimelineFollows(e, limit, loggedInUserDid, userIsFollowing) 26 40 if err != nil { 27 41 return nil, err 28 42 } ··· 70 84 return isStarred, starCount 71 85 } 72 86 73 - func getTimelineRepos(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) { 74 - repos, err := GetRepos(e, limit) 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...) 75 94 if err != nil { 76 95 return nil, err 77 96 } ··· 86 105 87 106 var origRepos []models.Repo 88 107 if args != nil { 89 - origRepos, err = GetRepos(e, 0, FilterIn("at_uri", args)) 108 + origRepos, err = GetRepos(e, 0, orm.FilterIn("at_uri", args)) 90 109 } 91 110 if err != nil { 92 111 return nil, err ··· 125 144 return events, nil 126 145 } 127 146 128 - func getTimelineStars(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) { 129 - stars, err := GetStars(e, limit) 130 - if err != nil { 131 - return nil, err 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)) 132 151 } 133 152 134 - // filter star records without a repo 135 - n := 0 136 - for _, s := range stars { 137 - if s.Repo != nil { 138 - stars[n] = s 139 - n++ 140 - } 153 + stars, err := GetRepoStars(e, limit, filters...) 154 + if err != nil { 155 + return nil, err 141 156 } 142 - stars = stars[:n] 143 157 144 158 var repos []models.Repo 145 159 for _, s := range stars { ··· 156 170 isStarred, starCount := getRepoStarInfo(s.Repo, starStatuses) 157 171 158 172 events = append(events, models.TimelineEvent{ 159 - Star: &s, 173 + RepoStar: &s, 160 174 EventAt: s.Created, 161 175 IsStarred: isStarred, 162 176 StarCount: starCount, ··· 166 180 return events, nil 167 181 } 168 182 169 - func getTimelineFollows(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) { 170 - follows, err := GetFollows(e, limit) 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...) 171 190 if err != nil { 172 191 return nil, err 173 192 } ··· 181 200 return nil, nil 182 201 } 183 202 184 - profiles, err := GetProfiles(e, FilterIn("did", subjects)) 203 + profiles, err := GetProfiles(e, orm.FilterIn("did", subjects)) 185 204 if err != nil { 186 205 return nil, err 187 206 }
+4 -4
appview/dns/cloudflare.go
··· 30 30 return &Cloudflare{api: api, zone: c.Cloudflare.ZoneId}, nil 31 31 } 32 32 33 - func (cf *Cloudflare) CreateDNSRecord(ctx context.Context, record Record) error { 34 - _, err := cf.api.CreateDNSRecord(ctx, cloudflare.ZoneIdentifier(cf.zone), cloudflare.CreateDNSRecordParams{ 33 + func (cf *Cloudflare) CreateDNSRecord(ctx context.Context, record Record) (string, error) { 34 + result, err := cf.api.CreateDNSRecord(ctx, cloudflare.ZoneIdentifier(cf.zone), cloudflare.CreateDNSRecordParams{ 35 35 Type: record.Type, 36 36 Name: record.Name, 37 37 Content: record.Content, ··· 39 39 Proxied: &record.Proxied, 40 40 }) 41 41 if err != nil { 42 - return fmt.Errorf("failed to create DNS record: %w", err) 42 + return "", fmt.Errorf("failed to create DNS record: %w", err) 43 43 } 44 - return nil 44 + return result.ID, nil 45 45 } 46 46 47 47 func (cf *Cloudflare) DeleteDNSRecord(ctx context.Context, recordID string) error {
+7 -12
appview/email/email.go
··· 3 3 import ( 4 4 "fmt" 5 5 "net" 6 - "regexp" 6 + "net/mail" 7 7 "strings" 8 8 9 9 "github.com/resend/resend-go/v2" ··· 34 34 } 35 35 36 36 func IsValidEmail(email string) bool { 37 - // Basic length check 38 - if len(email) < 3 || len(email) > 254 { 37 + // Reject whitespace (ParseAddress normalizes it away) 38 + if strings.ContainsAny(email, " \t\n\r") { 39 39 return false 40 40 } 41 41 42 - // Regular expression for email validation (RFC 5322 compliant) 43 - pattern := `^[a-zA-Z0-9.!#$%&'*+/=?^_\x60{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$` 44 - 45 - // Compile regex 46 - regex := regexp.MustCompile(pattern) 47 - 48 - // Check if email matches regex pattern 49 - if !regex.MatchString(email) { 42 + // Use stdlib RFC 5322 parser 43 + addr, err := mail.ParseAddress(email) 44 + if err != nil { 50 45 return false 51 46 } 52 47 53 48 // Split email into local and domain parts 54 - parts := strings.Split(email, "@") 49 + parts := strings.Split(addr.Address, "@") 55 50 domain := parts[1] 56 51 57 52 mx, err := net.LookupMX(domain)
+53
appview/email/email_test.go
··· 1 + package email 2 + 3 + import ( 4 + "testing" 5 + ) 6 + 7 + func TestIsValidEmail(t *testing.T) { 8 + tests := []struct { 9 + name string 10 + email string 11 + want bool 12 + }{ 13 + // Valid emails using RFC 2606 reserved domains 14 + {"standard email", "user@example.com", true}, 15 + {"single char local", "a@example.com", true}, 16 + {"dot in middle", "first.last@example.com", true}, 17 + {"multiple dots", "a.b.c@example.com", true}, 18 + {"plus tag", "user+tag@example.com", true}, 19 + {"numbers", "user123@example.com", true}, 20 + {"example.org", "user@example.org", true}, 21 + {"example.net", "user@example.net", true}, 22 + 23 + // Invalid format - rejected by mail.ParseAddress 24 + {"empty string", "", false}, 25 + {"no at sign", "userexample.com", false}, 26 + {"no domain", "user@", false}, 27 + {"no local part", "@example.com", false}, 28 + {"double at", "user@@example.com", false}, 29 + {"just at sign", "@", false}, 30 + {"leading dot", ".user@example.com", false}, 31 + {"trailing dot", "user.@example.com", false}, 32 + {"consecutive dots", "user..name@example.com", false}, 33 + 34 + // Whitespace - rejected before parsing 35 + {"space in local", "user @example.com", false}, 36 + {"space in domain", "user@ example.com", false}, 37 + {"tab", "user\t@example.com", false}, 38 + {"newline", "user\n@example.com", false}, 39 + 40 + // MX lookup - using RFC 2606 reserved TLDs (guaranteed no MX) 41 + {"invalid TLD", "user@example.invalid", false}, 42 + {"test TLD", "user@mail.test", false}, 43 + } 44 + 45 + for _, tt := range tests { 46 + t.Run(tt.name, func(t *testing.T) { 47 + got := IsValidEmail(tt.email) 48 + if got != tt.want { 49 + t.Errorf("IsValidEmail(%q) = %v, want %v", tt.email, got, tt.want) 50 + } 51 + }) 52 + } 53 + }
+20
appview/indexer/base36/base36.go
··· 1 + // mostly copied from gitea/modules/indexer/internal/base32 2 + 3 + package base36 4 + 5 + import ( 6 + "fmt" 7 + "strconv" 8 + ) 9 + 10 + func Encode(i int64) string { 11 + return strconv.FormatInt(i, 36) 12 + } 13 + 14 + func Decode(s string) (int64, error) { 15 + i, err := strconv.ParseInt(s, 36, 64) 16 + if err != nil { 17 + return 0, fmt.Errorf("invalid base36 integer %q: %w", s, err) 18 + } 19 + return i, nil 20 + }
+58
appview/indexer/bleve/batch.go
··· 1 + // Copyright 2021 The Gitea Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package bleveutil 5 + 6 + import ( 7 + "github.com/blevesearch/bleve/v2" 8 + ) 9 + 10 + // FlushingBatch is a batch of operations that automatically flushes to the 11 + // underlying index once it reaches a certain size. 12 + type FlushingBatch struct { 13 + maxBatchSize int 14 + batch *bleve.Batch 15 + index bleve.Index 16 + } 17 + 18 + // NewFlushingBatch creates a new flushing batch for the specified index. Once 19 + // the number of operations in the batch reaches the specified limit, the batch 20 + // automatically flushes its operations to the index. 21 + func NewFlushingBatch(index bleve.Index, maxBatchSize int) *FlushingBatch { 22 + return &FlushingBatch{ 23 + maxBatchSize: maxBatchSize, 24 + batch: index.NewBatch(), 25 + index: index, 26 + } 27 + } 28 + 29 + // Index add a new index to batch 30 + func (b *FlushingBatch) Index(id string, data any) error { 31 + if err := b.batch.Index(id, data); err != nil { 32 + return err 33 + } 34 + return b.flushIfFull() 35 + } 36 + 37 + // Delete add a delete index to batch 38 + func (b *FlushingBatch) Delete(id string) error { 39 + b.batch.Delete(id) 40 + return b.flushIfFull() 41 + } 42 + 43 + func (b *FlushingBatch) flushIfFull() error { 44 + if b.batch.Size() < b.maxBatchSize { 45 + return nil 46 + } 47 + return b.Flush() 48 + } 49 + 50 + // Flush submit the batch and create a new one 51 + func (b *FlushingBatch) Flush() error { 52 + err := b.index.Batch(b.batch) 53 + if err != nil { 54 + return err 55 + } 56 + b.batch = b.index.NewBatch() 57 + return nil 58 + }
+26
appview/indexer/bleve/query.go
··· 1 + package bleveutil 2 + 3 + import ( 4 + "github.com/blevesearch/bleve/v2" 5 + "github.com/blevesearch/bleve/v2/search/query" 6 + ) 7 + 8 + func MatchAndQuery(field, keyword, analyzer string, fuzziness int) query.Query { 9 + q := bleve.NewMatchQuery(keyword) 10 + q.FieldVal = field 11 + q.Analyzer = analyzer 12 + q.Fuzziness = fuzziness 13 + return q 14 + } 15 + 16 + func BoolFieldQuery(field string, val bool) query.Query { 17 + q := bleve.NewBoolFieldQuery(val) 18 + q.FieldVal = field 19 + return q 20 + } 21 + 22 + func KeywordFieldQuery(field, keyword string) query.Query { 23 + q := bleve.NewTermQuery(keyword) 24 + q.FieldVal = field 25 + return q 26 + }
+36
appview/indexer/indexer.go
··· 1 + package indexer 2 + 3 + import ( 4 + "context" 5 + "log/slog" 6 + 7 + "tangled.org/core/appview/db" 8 + issues_indexer "tangled.org/core/appview/indexer/issues" 9 + pulls_indexer "tangled.org/core/appview/indexer/pulls" 10 + "tangled.org/core/appview/notify" 11 + tlog "tangled.org/core/log" 12 + ) 13 + 14 + type Indexer struct { 15 + Issues *issues_indexer.Indexer 16 + Pulls *pulls_indexer.Indexer 17 + logger *slog.Logger 18 + notify.BaseNotifier 19 + } 20 + 21 + func New(logger *slog.Logger) *Indexer { 22 + return &Indexer{ 23 + issues_indexer.NewIndexer("indexes/issues.bleve"), 24 + pulls_indexer.NewIndexer("indexes/pulls.bleve"), 25 + logger, 26 + notify.BaseNotifier{}, 27 + } 28 + } 29 + 30 + // Init initializes all indexers 31 + func (ix *Indexer) Init(ctx context.Context, db *db.DB) error { 32 + ctx = tlog.IntoContext(ctx, ix.logger) 33 + ix.Issues.Init(ctx, db) 34 + ix.Pulls.Init(ctx, db) 35 + return nil 36 + }
+257
appview/indexer/issues/indexer.go
··· 1 + // heavily inspired by gitea's model (basically copy-pasted) 2 + package issues_indexer 3 + 4 + import ( 5 + "context" 6 + "errors" 7 + "log" 8 + "os" 9 + 10 + "github.com/blevesearch/bleve/v2" 11 + "github.com/blevesearch/bleve/v2/analysis/analyzer/custom" 12 + "github.com/blevesearch/bleve/v2/analysis/token/camelcase" 13 + "github.com/blevesearch/bleve/v2/analysis/token/lowercase" 14 + "github.com/blevesearch/bleve/v2/analysis/token/unicodenorm" 15 + "github.com/blevesearch/bleve/v2/analysis/tokenizer/unicode" 16 + "github.com/blevesearch/bleve/v2/index/upsidedown" 17 + "github.com/blevesearch/bleve/v2/mapping" 18 + "github.com/blevesearch/bleve/v2/search/query" 19 + "tangled.org/core/appview/db" 20 + "tangled.org/core/appview/indexer/base36" 21 + "tangled.org/core/appview/indexer/bleve" 22 + "tangled.org/core/appview/models" 23 + "tangled.org/core/appview/pagination" 24 + tlog "tangled.org/core/log" 25 + ) 26 + 27 + const ( 28 + issueIndexerAnalyzer = "issueIndexer" 29 + issueIndexerDocType = "issueIndexerDocType" 30 + 31 + unicodeNormalizeName = "uicodeNormalize" 32 + ) 33 + 34 + type Indexer struct { 35 + indexer bleve.Index 36 + path string 37 + } 38 + 39 + func NewIndexer(indexDir string) *Indexer { 40 + return &Indexer{ 41 + path: indexDir, 42 + } 43 + } 44 + 45 + // Init initializes the indexer 46 + func (ix *Indexer) Init(ctx context.Context, e db.Execer) { 47 + l := tlog.FromContext(ctx) 48 + existed, err := ix.intialize(ctx) 49 + if err != nil { 50 + log.Fatalln("failed to initialize issue indexer", err) 51 + } 52 + if !existed { 53 + l.Debug("Populating the issue indexer") 54 + err := PopulateIndexer(ctx, ix, e) 55 + if err != nil { 56 + log.Fatalln("failed to populate issue indexer", err) 57 + } 58 + } 59 + 60 + count, _ := ix.indexer.DocCount() 61 + l.Info("Initialized the issue indexer", "docCount", count) 62 + } 63 + 64 + func generateIssueIndexMapping() (mapping.IndexMapping, error) { 65 + mapping := bleve.NewIndexMapping() 66 + docMapping := bleve.NewDocumentMapping() 67 + 68 + textFieldMapping := bleve.NewTextFieldMapping() 69 + textFieldMapping.Store = false 70 + textFieldMapping.IncludeInAll = false 71 + 72 + boolFieldMapping := bleve.NewBooleanFieldMapping() 73 + boolFieldMapping.Store = false 74 + boolFieldMapping.IncludeInAll = false 75 + 76 + keywordFieldMapping := bleve.NewKeywordFieldMapping() 77 + keywordFieldMapping.Store = false 78 + keywordFieldMapping.IncludeInAll = false 79 + 80 + // numericFieldMapping := bleve.NewNumericFieldMapping() 81 + 82 + docMapping.AddFieldMappingsAt("title", textFieldMapping) 83 + docMapping.AddFieldMappingsAt("body", textFieldMapping) 84 + 85 + docMapping.AddFieldMappingsAt("repo_at", keywordFieldMapping) 86 + docMapping.AddFieldMappingsAt("is_open", boolFieldMapping) 87 + 88 + err := mapping.AddCustomTokenFilter(unicodeNormalizeName, map[string]any{ 89 + "type": unicodenorm.Name, 90 + "form": unicodenorm.NFC, 91 + }) 92 + if err != nil { 93 + return nil, err 94 + } 95 + 96 + err = mapping.AddCustomAnalyzer(issueIndexerAnalyzer, map[string]any{ 97 + "type": custom.Name, 98 + "char_filters": []string{}, 99 + "tokenizer": unicode.Name, 100 + "token_filters": []string{unicodeNormalizeName, camelcase.Name, lowercase.Name}, 101 + }) 102 + if err != nil { 103 + return nil, err 104 + } 105 + 106 + mapping.DefaultAnalyzer = issueIndexerAnalyzer 107 + mapping.AddDocumentMapping(issueIndexerDocType, docMapping) 108 + mapping.AddDocumentMapping("_all", bleve.NewDocumentDisabledMapping()) 109 + mapping.DefaultMapping = bleve.NewDocumentDisabledMapping() 110 + 111 + return mapping, nil 112 + } 113 + 114 + func (ix *Indexer) intialize(ctx context.Context) (bool, error) { 115 + if ix.indexer != nil { 116 + return false, errors.New("indexer is already initialized") 117 + } 118 + 119 + indexer, err := openIndexer(ctx, ix.path) 120 + if err != nil { 121 + return false, err 122 + } 123 + if indexer != nil { 124 + ix.indexer = indexer 125 + return true, nil 126 + } 127 + 128 + mapping, err := generateIssueIndexMapping() 129 + if err != nil { 130 + return false, err 131 + } 132 + indexer, err = bleve.New(ix.path, mapping) 133 + if err != nil { 134 + return false, err 135 + } 136 + 137 + ix.indexer = indexer 138 + 139 + return false, nil 140 + } 141 + 142 + func openIndexer(ctx context.Context, path string) (bleve.Index, error) { 143 + l := tlog.FromContext(ctx) 144 + indexer, err := bleve.Open(path) 145 + if err != nil { 146 + if errors.Is(err, upsidedown.IncompatibleVersion) { 147 + l.Info("Indexer was built with a previous version of bleve, deleting and rebuilding") 148 + return nil, os.RemoveAll(path) 149 + } 150 + return nil, nil 151 + } 152 + return indexer, nil 153 + } 154 + 155 + func PopulateIndexer(ctx context.Context, ix *Indexer, e db.Execer) error { 156 + l := tlog.FromContext(ctx) 157 + count := 0 158 + err := pagination.IterateAll( 159 + func(page pagination.Page) ([]models.Issue, error) { 160 + return db.GetIssuesPaginated(e, page) 161 + }, 162 + func(issues []models.Issue) error { 163 + count += len(issues) 164 + return ix.Index(ctx, issues...) 165 + }, 166 + ) 167 + l.Info("issues indexed", "count", count) 168 + return err 169 + } 170 + 171 + // issueData data stored and will be indexed 172 + type issueData struct { 173 + ID int64 `json:"id"` 174 + RepoAt string `json:"repo_at"` 175 + IssueID int `json:"issue_id"` 176 + Title string `json:"title"` 177 + Body string `json:"body"` 178 + 179 + IsOpen bool `json:"is_open"` 180 + Comments []IssueCommentData `json:"comments"` 181 + } 182 + 183 + func makeIssueData(issue *models.Issue) *issueData { 184 + return &issueData{ 185 + ID: issue.Id, 186 + RepoAt: issue.RepoAt.String(), 187 + IssueID: issue.IssueId, 188 + Title: issue.Title, 189 + Body: issue.Body, 190 + IsOpen: issue.Open, 191 + } 192 + } 193 + 194 + // Type returns the document type, for bleve's mapping.Classifier interface. 195 + func (i *issueData) Type() string { 196 + return issueIndexerDocType 197 + } 198 + 199 + type IssueCommentData struct { 200 + Body string `json:"body"` 201 + } 202 + 203 + type SearchResult struct { 204 + Hits []int64 205 + Total uint64 206 + } 207 + 208 + const maxBatchSize = 20 209 + 210 + func (ix *Indexer) Index(ctx context.Context, issues ...models.Issue) error { 211 + batch := bleveutil.NewFlushingBatch(ix.indexer, maxBatchSize) 212 + for _, issue := range issues { 213 + issueData := makeIssueData(&issue) 214 + if err := batch.Index(base36.Encode(issue.Id), issueData); err != nil { 215 + return err 216 + } 217 + } 218 + return batch.Flush() 219 + } 220 + 221 + func (ix *Indexer) Delete(ctx context.Context, issueId int64) error { 222 + return ix.indexer.Delete(base36.Encode(issueId)) 223 + } 224 + 225 + // Search searches for issues 226 + func (ix *Indexer) Search(ctx context.Context, opts models.IssueSearchOptions) (*SearchResult, error) { 227 + var queries []query.Query 228 + 229 + if opts.Keyword != "" { 230 + queries = append(queries, bleve.NewDisjunctionQuery( 231 + bleveutil.MatchAndQuery("title", opts.Keyword, issueIndexerAnalyzer, 0), 232 + bleveutil.MatchAndQuery("body", opts.Keyword, issueIndexerAnalyzer, 0), 233 + )) 234 + } 235 + queries = append(queries, bleveutil.KeywordFieldQuery("repo_at", opts.RepoAt)) 236 + queries = append(queries, bleveutil.BoolFieldQuery("is_open", opts.IsOpen)) 237 + // TODO: append more queries 238 + 239 + var indexerQuery query.Query = bleve.NewConjunctionQuery(queries...) 240 + searchReq := bleve.NewSearchRequestOptions(indexerQuery, opts.Page.Limit, opts.Page.Offset, false) 241 + res, err := ix.indexer.SearchInContext(ctx, searchReq) 242 + if err != nil { 243 + return nil, nil 244 + } 245 + ret := &SearchResult{ 246 + Total: res.Total, 247 + Hits: make([]int64, len(res.Hits)), 248 + } 249 + for i, hit := range res.Hits { 250 + id, err := base36.Decode(hit.ID) 251 + if err != nil { 252 + return nil, err 253 + } 254 + ret.Hits[i] = id 255 + } 256 + return ret, nil 257 + }
+57
appview/indexer/notifier.go
··· 1 + package indexer 2 + 3 + import ( 4 + "context" 5 + 6 + "github.com/bluesky-social/indigo/atproto/syntax" 7 + "tangled.org/core/appview/models" 8 + "tangled.org/core/appview/notify" 9 + "tangled.org/core/log" 10 + ) 11 + 12 + var _ notify.Notifier = &Indexer{} 13 + 14 + func (ix *Indexer) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) { 15 + l := log.FromContext(ctx).With("notifier", "indexer", "issue", issue) 16 + l.Debug("indexing new issue") 17 + err := ix.Issues.Index(ctx, *issue) 18 + if err != nil { 19 + l.Error("failed to index an issue", "err", err) 20 + } 21 + } 22 + 23 + func (ix *Indexer) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) { 24 + l := log.FromContext(ctx).With("notifier", "indexer", "issue", issue) 25 + l.Debug("updating an issue") 26 + err := ix.Issues.Index(ctx, *issue) 27 + if err != nil { 28 + l.Error("failed to index an issue", "err", err) 29 + } 30 + } 31 + 32 + func (ix *Indexer) DeleteIssue(ctx context.Context, issue *models.Issue) { 33 + l := log.FromContext(ctx).With("notifier", "indexer", "issue", issue) 34 + l.Debug("deleting an issue") 35 + err := ix.Issues.Delete(ctx, issue.Id) 36 + if err != nil { 37 + l.Error("failed to delete an issue", "err", err) 38 + } 39 + } 40 + 41 + func (ix *Indexer) NewPull(ctx context.Context, pull *models.Pull) { 42 + l := log.FromContext(ctx).With("notifier", "indexer", "pull", pull) 43 + l.Debug("indexing new pr") 44 + err := ix.Pulls.Index(ctx, pull) 45 + if err != nil { 46 + l.Error("failed to index a pr", "err", err) 47 + } 48 + } 49 + 50 + func (ix *Indexer) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) { 51 + l := log.FromContext(ctx).With("notifier", "indexer", "pull", pull) 52 + l.Debug("updating a pr") 53 + err := ix.Pulls.Index(ctx, pull) 54 + if err != nil { 55 + l.Error("failed to index a pr", "err", err) 56 + } 57 + }
+257
appview/indexer/pulls/indexer.go
··· 1 + // heavily inspired by gitea's model (basically copy-pasted) 2 + package pulls_indexer 3 + 4 + import ( 5 + "context" 6 + "errors" 7 + "log" 8 + "os" 9 + 10 + "github.com/blevesearch/bleve/v2" 11 + "github.com/blevesearch/bleve/v2/analysis/analyzer/custom" 12 + "github.com/blevesearch/bleve/v2/analysis/token/camelcase" 13 + "github.com/blevesearch/bleve/v2/analysis/token/lowercase" 14 + "github.com/blevesearch/bleve/v2/analysis/token/unicodenorm" 15 + "github.com/blevesearch/bleve/v2/analysis/tokenizer/unicode" 16 + "github.com/blevesearch/bleve/v2/index/upsidedown" 17 + "github.com/blevesearch/bleve/v2/mapping" 18 + "github.com/blevesearch/bleve/v2/search/query" 19 + "tangled.org/core/appview/db" 20 + "tangled.org/core/appview/indexer/base36" 21 + "tangled.org/core/appview/indexer/bleve" 22 + "tangled.org/core/appview/models" 23 + tlog "tangled.org/core/log" 24 + ) 25 + 26 + const ( 27 + pullIndexerAnalyzer = "pullIndexer" 28 + pullIndexerDocType = "pullIndexerDocType" 29 + 30 + unicodeNormalizeName = "uicodeNormalize" 31 + ) 32 + 33 + type Indexer struct { 34 + indexer bleve.Index 35 + path string 36 + } 37 + 38 + func NewIndexer(indexDir string) *Indexer { 39 + return &Indexer{ 40 + path: indexDir, 41 + } 42 + } 43 + 44 + // Init initializes the indexer 45 + func (ix *Indexer) Init(ctx context.Context, e db.Execer) { 46 + l := tlog.FromContext(ctx) 47 + existed, err := ix.intialize(ctx) 48 + if err != nil { 49 + log.Fatalln("failed to initialize pull indexer", err) 50 + } 51 + if !existed { 52 + l.Debug("Populating the pull indexer") 53 + err := PopulateIndexer(ctx, ix, e) 54 + if err != nil { 55 + log.Fatalln("failed to populate pull indexer", err) 56 + } 57 + } 58 + 59 + count, _ := ix.indexer.DocCount() 60 + l.Info("Initialized the pull indexer", "docCount", count) 61 + } 62 + 63 + func generatePullIndexMapping() (mapping.IndexMapping, error) { 64 + mapping := bleve.NewIndexMapping() 65 + docMapping := bleve.NewDocumentMapping() 66 + 67 + textFieldMapping := bleve.NewTextFieldMapping() 68 + textFieldMapping.Store = false 69 + textFieldMapping.IncludeInAll = false 70 + 71 + keywordFieldMapping := bleve.NewKeywordFieldMapping() 72 + keywordFieldMapping.Store = false 73 + keywordFieldMapping.IncludeInAll = false 74 + 75 + // numericFieldMapping := bleve.NewNumericFieldMapping() 76 + 77 + docMapping.AddFieldMappingsAt("title", textFieldMapping) 78 + docMapping.AddFieldMappingsAt("body", textFieldMapping) 79 + 80 + docMapping.AddFieldMappingsAt("repo_at", keywordFieldMapping) 81 + docMapping.AddFieldMappingsAt("state", keywordFieldMapping) 82 + 83 + err := mapping.AddCustomTokenFilter(unicodeNormalizeName, map[string]any{ 84 + "type": unicodenorm.Name, 85 + "form": unicodenorm.NFC, 86 + }) 87 + if err != nil { 88 + return nil, err 89 + } 90 + 91 + err = mapping.AddCustomAnalyzer(pullIndexerAnalyzer, map[string]any{ 92 + "type": custom.Name, 93 + "char_filters": []string{}, 94 + "tokenizer": unicode.Name, 95 + "token_filters": []string{unicodeNormalizeName, camelcase.Name, lowercase.Name}, 96 + }) 97 + if err != nil { 98 + return nil, err 99 + } 100 + 101 + mapping.DefaultAnalyzer = pullIndexerAnalyzer 102 + mapping.AddDocumentMapping(pullIndexerDocType, docMapping) 103 + mapping.AddDocumentMapping("_all", bleve.NewDocumentDisabledMapping()) 104 + mapping.DefaultMapping = bleve.NewDocumentDisabledMapping() 105 + 106 + return mapping, nil 107 + } 108 + 109 + func (ix *Indexer) intialize(ctx context.Context) (bool, error) { 110 + if ix.indexer != nil { 111 + return false, errors.New("indexer is already initialized") 112 + } 113 + 114 + indexer, err := openIndexer(ctx, ix.path) 115 + if err != nil { 116 + return false, err 117 + } 118 + if indexer != nil { 119 + ix.indexer = indexer 120 + return true, nil 121 + } 122 + 123 + mapping, err := generatePullIndexMapping() 124 + if err != nil { 125 + return false, err 126 + } 127 + indexer, err = bleve.New(ix.path, mapping) 128 + if err != nil { 129 + return false, err 130 + } 131 + 132 + ix.indexer = indexer 133 + 134 + return false, nil 135 + } 136 + 137 + func openIndexer(ctx context.Context, path string) (bleve.Index, error) { 138 + l := tlog.FromContext(ctx) 139 + indexer, err := bleve.Open(path) 140 + if err != nil { 141 + if errors.Is(err, upsidedown.IncompatibleVersion) { 142 + l.Info("Indexer was built with a previous version of bleve, deleting and rebuilding") 143 + return nil, os.RemoveAll(path) 144 + } 145 + return nil, nil 146 + } 147 + return indexer, nil 148 + } 149 + 150 + func PopulateIndexer(ctx context.Context, ix *Indexer, e db.Execer) error { 151 + l := tlog.FromContext(ctx) 152 + 153 + pulls, err := db.GetPulls(e) 154 + if err != nil { 155 + return err 156 + } 157 + count := len(pulls) 158 + err = ix.Index(ctx, pulls...) 159 + if err != nil { 160 + return err 161 + } 162 + l.Info("pulls indexed", "count", count) 163 + return err 164 + } 165 + 166 + // pullData data stored and will be indexed 167 + type pullData struct { 168 + ID int64 `json:"id"` 169 + RepoAt string `json:"repo_at"` 170 + PullID int `json:"pull_id"` 171 + Title string `json:"title"` 172 + Body string `json:"body"` 173 + State string `json:"state"` 174 + 175 + Comments []pullCommentData `json:"comments"` 176 + } 177 + 178 + func makePullData(pull *models.Pull) *pullData { 179 + return &pullData{ 180 + ID: int64(pull.ID), 181 + RepoAt: pull.RepoAt.String(), 182 + PullID: pull.PullId, 183 + Title: pull.Title, 184 + Body: pull.Body, 185 + State: pull.State.String(), 186 + } 187 + } 188 + 189 + // Type returns the document type, for bleve's mapping.Classifier interface. 190 + func (i *pullData) Type() string { 191 + return pullIndexerDocType 192 + } 193 + 194 + type pullCommentData struct { 195 + Body string `json:"body"` 196 + } 197 + 198 + type searchResult struct { 199 + Hits []int64 200 + Total uint64 201 + } 202 + 203 + const maxBatchSize = 20 204 + 205 + func (ix *Indexer) Index(ctx context.Context, pulls ...*models.Pull) error { 206 + batch := bleveutil.NewFlushingBatch(ix.indexer, maxBatchSize) 207 + for _, pull := range pulls { 208 + pullData := makePullData(pull) 209 + if err := batch.Index(base36.Encode(pullData.ID), pullData); err != nil { 210 + return err 211 + } 212 + } 213 + return batch.Flush() 214 + } 215 + 216 + func (ix *Indexer) Delete(ctx context.Context, pullID int64) error { 217 + return ix.indexer.Delete(base36.Encode(pullID)) 218 + } 219 + 220 + // Search searches for pulls 221 + func (ix *Indexer) Search(ctx context.Context, opts models.PullSearchOptions) (*searchResult, error) { 222 + var queries []query.Query 223 + 224 + // TODO(boltless): remove this after implementing pulls page pagination 225 + limit := opts.Page.Limit 226 + if limit == 0 { 227 + limit = 500 228 + } 229 + 230 + if opts.Keyword != "" { 231 + queries = append(queries, bleve.NewDisjunctionQuery( 232 + bleveutil.MatchAndQuery("title", opts.Keyword, pullIndexerAnalyzer, 0), 233 + bleveutil.MatchAndQuery("body", opts.Keyword, pullIndexerAnalyzer, 0), 234 + )) 235 + } 236 + queries = append(queries, bleveutil.KeywordFieldQuery("repo_at", opts.RepoAt)) 237 + queries = append(queries, bleveutil.KeywordFieldQuery("state", opts.State.String())) 238 + 239 + var indexerQuery query.Query = bleve.NewConjunctionQuery(queries...) 240 + searchReq := bleve.NewSearchRequestOptions(indexerQuery, limit, opts.Page.Offset, false) 241 + res, err := ix.indexer.SearchInContext(ctx, searchReq) 242 + if err != nil { 243 + return nil, nil 244 + } 245 + ret := &searchResult{ 246 + Total: res.Total, 247 + Hits: make([]int64, len(res.Hits)), 248 + } 249 + for i, hit := range res.Hits { 250 + id, err := base36.Decode(hit.ID) 251 + if err != nil { 252 + return nil, err 253 + } 254 + ret.Hits[i] = id 255 + } 256 + return ret, nil 257 + }
+57 -33
appview/ingester.go
··· 21 21 "tangled.org/core/appview/serververify" 22 22 "tangled.org/core/appview/validator" 23 23 "tangled.org/core/idresolver" 24 + "tangled.org/core/orm" 24 25 "tangled.org/core/rbac" 25 26 ) 26 27 ··· 89 90 } 90 91 91 92 if err != nil { 92 - l.Debug("error ingesting record", "err", err) 93 + l.Warn("refused to ingest record", "err", err) 93 94 } 94 95 95 96 return nil ··· 121 122 return err 122 123 } 123 124 err = db.AddStar(i.Db, &models.Star{ 124 - StarredByDid: did, 125 - RepoAt: subjectUri, 126 - Rkey: e.Commit.RKey, 125 + Did: did, 126 + RepoAt: subjectUri, 127 + Rkey: e.Commit.RKey, 127 128 }) 128 129 case jmodels.CommitOperationDelete: 129 130 err = db.DeleteStarByRkey(i.Db, did, e.Commit.RKey) ··· 253 254 254 255 err = db.AddArtifact(i.Db, artifact) 255 256 case jmodels.CommitOperationDelete: 256 - err = db.DeleteArtifact(i.Db, db.FilterEq("did", did), db.FilterEq("rkey", e.Commit.RKey)) 257 + err = db.DeleteArtifact(i.Db, orm.FilterEq("did", did), orm.FilterEq("rkey", e.Commit.RKey)) 257 258 } 258 259 259 260 if err != nil { ··· 291 292 292 293 includeBluesky := record.Bluesky 293 294 295 + pronouns := "" 296 + if record.Pronouns != nil { 297 + pronouns = *record.Pronouns 298 + } 299 + 294 300 location := "" 295 301 if record.Location != nil { 296 302 location = *record.Location ··· 325 331 Links: links, 326 332 Stats: stats, 327 333 PinnedRepos: pinned, 334 + Pronouns: pronouns, 328 335 } 329 336 330 337 ddb, ok := i.Db.Execer.(*db.DB) ··· 344 351 345 352 err = db.UpsertProfile(tx, &profile) 346 353 case jmodels.CommitOperationDelete: 347 - err = db.DeleteArtifact(i.Db, db.FilterEq("did", did), db.FilterEq("rkey", e.Commit.RKey)) 354 + err = db.DeleteArtifact(i.Db, orm.FilterEq("did", did), orm.FilterEq("rkey", e.Commit.RKey)) 348 355 } 349 356 350 357 if err != nil { ··· 418 425 // get record from db first 419 426 members, err := db.GetSpindleMembers( 420 427 ddb, 421 - db.FilterEq("did", did), 422 - db.FilterEq("rkey", rkey), 428 + orm.FilterEq("did", did), 429 + orm.FilterEq("rkey", rkey), 423 430 ) 424 431 if err != nil || len(members) != 1 { 425 432 return fmt.Errorf("failed to get member: %w, len(members) = %d", err, len(members)) ··· 434 441 // remove record by rkey && update enforcer 435 442 if err = db.RemoveSpindleMember( 436 443 tx, 437 - db.FilterEq("did", did), 438 - db.FilterEq("rkey", rkey), 444 + orm.FilterEq("did", did), 445 + orm.FilterEq("rkey", rkey), 439 446 ); err != nil { 440 447 return fmt.Errorf("failed to remove from db: %w", err) 441 448 } ··· 517 524 // get record from db first 518 525 spindles, err := db.GetSpindles( 519 526 ddb, 520 - db.FilterEq("owner", did), 521 - db.FilterEq("instance", instance), 527 + orm.FilterEq("owner", did), 528 + orm.FilterEq("instance", instance), 522 529 ) 523 530 if err != nil || len(spindles) != 1 { 524 531 return fmt.Errorf("failed to get spindles: %w, len(spindles) = %d", err, len(spindles)) ··· 537 544 // remove spindle members first 538 545 err = db.RemoveSpindleMember( 539 546 tx, 540 - db.FilterEq("owner", did), 541 - db.FilterEq("instance", instance), 547 + orm.FilterEq("owner", did), 548 + orm.FilterEq("instance", instance), 542 549 ) 543 550 if err != nil { 544 551 return err ··· 546 553 547 554 err = db.DeleteSpindle( 548 555 tx, 549 - db.FilterEq("owner", did), 550 - db.FilterEq("instance", instance), 556 + orm.FilterEq("owner", did), 557 + orm.FilterEq("instance", instance), 551 558 ) 552 559 if err != nil { 553 560 return err ··· 615 622 case jmodels.CommitOperationDelete: 616 623 if err := db.DeleteString( 617 624 ddb, 618 - db.FilterEq("did", did), 619 - db.FilterEq("rkey", rkey), 625 + orm.FilterEq("did", did), 626 + orm.FilterEq("rkey", rkey), 620 627 ); err != nil { 621 628 l.Error("failed to delete", "err", err) 622 629 return fmt.Errorf("failed to delete string record: %w", err) ··· 734 741 // get record from db first 735 742 registrations, err := db.GetRegistrations( 736 743 ddb, 737 - db.FilterEq("domain", domain), 738 - db.FilterEq("did", did), 744 + orm.FilterEq("domain", domain), 745 + orm.FilterEq("did", did), 739 746 ) 740 747 if err != nil { 741 748 return fmt.Errorf("failed to get registration: %w", err) ··· 756 763 757 764 err = db.DeleteKnot( 758 765 tx, 759 - db.FilterEq("did", did), 760 - db.FilterEq("domain", domain), 766 + orm.FilterEq("did", did), 767 + orm.FilterEq("domain", domain), 761 768 ) 762 769 if err != nil { 763 770 return err ··· 835 842 return nil 836 843 837 844 case jmodels.CommitOperationDelete: 845 + tx, err := ddb.BeginTx(ctx, nil) 846 + if err != nil { 847 + l.Error("failed to begin transaction", "err", err) 848 + return err 849 + } 850 + defer tx.Rollback() 851 + 838 852 if err := db.DeleteIssues( 839 - ddb, 840 - db.FilterEq("did", did), 841 - db.FilterEq("rkey", rkey), 853 + tx, 854 + did, 855 + rkey, 842 856 ); err != nil { 843 857 l.Error("failed to delete", "err", err) 844 858 return fmt.Errorf("failed to delete issue record: %w", err) 859 + } 860 + if err := tx.Commit(); err != nil { 861 + l.Error("failed to commit txn", "err", err) 862 + return err 845 863 } 846 864 847 865 return nil ··· 882 900 return fmt.Errorf("failed to validate comment: %w", err) 883 901 } 884 902 885 - _, err = db.AddIssueComment(ddb, *comment) 903 + tx, err := ddb.Begin() 904 + if err != nil { 905 + return fmt.Errorf("failed to start transaction: %w", err) 906 + } 907 + defer tx.Rollback() 908 + 909 + _, err = db.AddIssueComment(tx, *comment) 886 910 if err != nil { 887 911 return fmt.Errorf("failed to create issue comment: %w", err) 888 912 } 889 913 890 - return nil 914 + return tx.Commit() 891 915 892 916 case jmodels.CommitOperationDelete: 893 917 if err := db.DeleteIssueComments( 894 918 ddb, 895 - db.FilterEq("did", did), 896 - db.FilterEq("rkey", rkey), 919 + orm.FilterEq("did", did), 920 + orm.FilterEq("rkey", rkey), 897 921 ); err != nil { 898 922 return fmt.Errorf("failed to delete issue comment record: %w", err) 899 923 } ··· 946 970 case jmodels.CommitOperationDelete: 947 971 if err := db.DeleteLabelDefinition( 948 972 ddb, 949 - db.FilterEq("did", did), 950 - db.FilterEq("rkey", rkey), 973 + orm.FilterEq("did", did), 974 + orm.FilterEq("rkey", rkey), 951 975 ); err != nil { 952 976 return fmt.Errorf("failed to delete labeldef record: %w", err) 953 977 } ··· 987 1011 var repo *models.Repo 988 1012 switch collection { 989 1013 case tangled.RepoIssueNSID: 990 - i, err := db.GetIssues(ddb, db.FilterEq("at_uri", subject)) 1014 + i, err := db.GetIssues(ddb, orm.FilterEq("at_uri", subject)) 991 1015 if err != nil || len(i) != 1 { 992 1016 return fmt.Errorf("failed to find subject: %w || subject count %d", err, len(i)) 993 1017 } ··· 996 1020 return fmt.Errorf("unsupport label subject: %s", collection) 997 1021 } 998 1022 999 - actx, err := db.NewLabelApplicationCtx(ddb, db.FilterIn("at_uri", repo.Labels)) 1023 + actx, err := db.NewLabelApplicationCtx(ddb, orm.FilterIn("at_uri", repo.Labels)) 1000 1024 if err != nil { 1001 1025 return fmt.Errorf("failed to build label application ctx: %w", err) 1002 1026 }
+230 -149
appview/issues/issues.go
··· 5 5 "database/sql" 6 6 "errors" 7 7 "fmt" 8 - "log" 9 8 "log/slog" 10 9 "net/http" 11 - "slices" 12 10 "time" 13 11 14 12 comatproto "github.com/bluesky-social/indigo/api/atproto" ··· 20 18 "tangled.org/core/api/tangled" 21 19 "tangled.org/core/appview/config" 22 20 "tangled.org/core/appview/db" 21 + issues_indexer "tangled.org/core/appview/indexer/issues" 22 + "tangled.org/core/appview/mentions" 23 23 "tangled.org/core/appview/models" 24 24 "tangled.org/core/appview/notify" 25 25 "tangled.org/core/appview/oauth" 26 26 "tangled.org/core/appview/pages" 27 + "tangled.org/core/appview/pages/repoinfo" 27 28 "tangled.org/core/appview/pagination" 28 29 "tangled.org/core/appview/reporesolver" 29 30 "tangled.org/core/appview/validator" 30 31 "tangled.org/core/idresolver" 31 - tlog "tangled.org/core/log" 32 + "tangled.org/core/orm" 33 + "tangled.org/core/rbac" 32 34 "tangled.org/core/tid" 33 35 ) 34 36 35 37 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 38 + oauth *oauth.OAuth 39 + repoResolver *reporesolver.RepoResolver 40 + enforcer *rbac.Enforcer 41 + pages *pages.Pages 42 + idResolver *idresolver.Resolver 43 + mentionsResolver *mentions.Resolver 44 + db *db.DB 45 + config *config.Config 46 + notifier notify.Notifier 47 + logger *slog.Logger 48 + validator *validator.Validator 49 + indexer *issues_indexer.Indexer 45 50 } 46 51 47 52 func New( 48 53 oauth *oauth.OAuth, 49 54 repoResolver *reporesolver.RepoResolver, 55 + enforcer *rbac.Enforcer, 50 56 pages *pages.Pages, 51 57 idResolver *idresolver.Resolver, 58 + mentionsResolver *mentions.Resolver, 52 59 db *db.DB, 53 60 config *config.Config, 54 61 notifier notify.Notifier, 55 62 validator *validator.Validator, 63 + indexer *issues_indexer.Indexer, 64 + logger *slog.Logger, 56 65 ) *Issues { 57 66 return &Issues{ 58 - oauth: oauth, 59 - repoResolver: repoResolver, 60 - pages: pages, 61 - idResolver: idResolver, 62 - db: db, 63 - config: config, 64 - notifier: notifier, 65 - logger: tlog.New("issues"), 66 - validator: validator, 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, 67 79 } 68 80 } 69 81 ··· 72 84 user := rp.oauth.GetUser(r) 73 85 f, err := rp.repoResolver.Resolve(r) 74 86 if err != nil { 75 - log.Println("failed to get repo and knot", err) 87 + l.Error("failed to get repo and knot", "err", err) 76 88 return 77 89 } 78 90 ··· 93 105 userReactions = db.GetReactionStatusMap(rp.db, user.Did, issue.AtUri()) 94 106 } 95 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 + 96 115 labelDefs, err := db.GetLabelDefinitions( 97 116 rp.db, 98 - db.FilterIn("at_uri", f.Repo.Labels), 99 - db.FilterContains("scope", tangled.RepoIssueNSID), 117 + orm.FilterIn("at_uri", f.Labels), 118 + orm.FilterContains("scope", tangled.RepoIssueNSID), 100 119 ) 101 120 if err != nil { 102 - log.Println("failed to fetch labels", err) 121 + l.Error("failed to fetch labels", "err", err) 103 122 rp.pages.Error503(w) 104 123 return 105 124 } ··· 111 130 112 131 rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{ 113 132 LoggedInUser: user, 114 - RepoInfo: f.RepoInfo(user), 133 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 115 134 Issue: issue, 116 135 CommentList: issue.CommentList(), 136 + Backlinks: backlinks, 117 137 OrderedReactionKinds: models.OrderedReactionKinds, 118 138 Reactions: reactionMap, 119 139 UserReacted: userReactions, ··· 124 144 func (rp *Issues) EditIssue(w http.ResponseWriter, r *http.Request) { 125 145 l := rp.logger.With("handler", "EditIssue") 126 146 user := rp.oauth.GetUser(r) 127 - f, err := rp.repoResolver.Resolve(r) 128 - if err != nil { 129 - log.Println("failed to get repo and knot", err) 130 - return 131 - } 132 147 133 148 issue, ok := r.Context().Value("issue").(*models.Issue) 134 149 if !ok { ··· 141 156 case http.MethodGet: 142 157 rp.pages.EditIssueFragment(w, pages.EditIssueParams{ 143 158 LoggedInUser: user, 144 - RepoInfo: f.RepoInfo(user), 159 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 145 160 Issue: issue, 146 161 }) 147 162 case http.MethodPost: ··· 149 164 newIssue := issue 150 165 newIssue.Title = r.FormValue("title") 151 166 newIssue.Body = r.FormValue("body") 167 + newIssue.Mentions, newIssue.References = rp.mentionsResolver.Resolve(r.Context(), newIssue.Body) 152 168 153 169 if err := rp.validator.ValidateIssue(newIssue); err != nil { 154 170 l.Error("validation error", "err", err) ··· 199 215 200 216 err = db.PutIssue(tx, newIssue) 201 217 if err != nil { 202 - log.Println("failed to edit issue", err) 218 + l.Error("failed to edit issue", "err", err) 203 219 rp.pages.Notice(w, "issues", "Failed to edit issue.") 204 220 return 205 221 } ··· 217 233 func (rp *Issues) DeleteIssue(w http.ResponseWriter, r *http.Request) { 218 234 l := rp.logger.With("handler", "DeleteIssue") 219 235 noticeId := "issue-actions-error" 220 - 221 - user := rp.oauth.GetUser(r) 222 236 223 237 f, err := rp.repoResolver.Resolve(r) 224 238 if err != nil { ··· 234 248 } 235 249 l = l.With("did", issue.Did, "rkey", issue.Rkey) 236 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 + 237 259 // delete from PDS 238 260 client, err := rp.oauth.AuthorizedClient(r) 239 261 if err != nil { 240 - log.Println("failed to get authorized client", err) 262 + l.Error("failed to get authorized client", "err", err) 241 263 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 242 264 return 243 265 } ··· 254 276 } 255 277 256 278 // delete from db 257 - if err := db.DeleteIssues(rp.db, db.FilterEq("id", issue.Id)); err != nil { 279 + if err := db.DeleteIssues(tx, issue.Did, issue.Rkey); err != nil { 258 280 l.Error("failed to delete issue", "err", err) 259 281 rp.pages.Notice(w, noticeId, "Failed to delete issue.") 260 282 return 261 283 } 284 + tx.Commit() 285 + 286 + rp.notifier.DeleteIssue(r.Context(), issue) 262 287 263 288 // return to all issues page 264 - rp.pages.HxRedirect(w, "/"+f.RepoInfo(user).FullName()+"/issues") 289 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 290 + rp.pages.HxRedirect(w, "/"+ownerSlashRepo+"/issues") 265 291 } 266 292 267 293 func (rp *Issues) CloseIssue(w http.ResponseWriter, r *http.Request) { ··· 280 306 return 281 307 } 282 308 283 - collaborators, err := f.Collaborators(r.Context()) 284 - if err != nil { 285 - log.Println("failed to fetch repo collaborators: %w", err) 286 - } 287 - isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 288 - return user.Did == collab.Did 289 - }) 309 + roles := repoinfo.RolesInRepo{Roles: rp.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())} 310 + isRepoOwner := roles.IsOwner() 311 + isCollaborator := roles.IsCollaborator() 290 312 isIssueOwner := user.Did == issue.Did 291 313 292 314 // TODO: make this more granular 293 - if isIssueOwner || isCollaborator { 315 + if isIssueOwner || isRepoOwner || isCollaborator { 294 316 err = db.CloseIssues( 295 317 rp.db, 296 - db.FilterEq("id", issue.Id), 318 + orm.FilterEq("id", issue.Id), 297 319 ) 298 320 if err != nil { 299 - log.Println("failed to close issue", err) 321 + l.Error("failed to close issue", "err", err) 300 322 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 301 323 return 302 324 } 325 + // change the issue state (this will pass down to the notifiers) 326 + issue.Open = false 303 327 304 328 // notify about the issue closure 305 - rp.notifier.NewIssueClosed(r.Context(), issue) 329 + rp.notifier.NewIssueState(r.Context(), syntax.DID(user.Did), issue) 306 330 307 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 331 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 332 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId)) 308 333 return 309 334 } else { 310 - log.Println("user is not permitted to close issue") 335 + l.Error("user is not permitted to close issue") 311 336 http.Error(w, "for biden", http.StatusUnauthorized) 312 337 return 313 338 } ··· 318 343 user := rp.oauth.GetUser(r) 319 344 f, err := rp.repoResolver.Resolve(r) 320 345 if err != nil { 321 - log.Println("failed to get repo and knot", err) 346 + l.Error("failed to get repo and knot", "err", err) 322 347 return 323 348 } 324 349 ··· 329 354 return 330 355 } 331 356 332 - collaborators, err := f.Collaborators(r.Context()) 333 - if err != nil { 334 - log.Println("failed to fetch repo collaborators: %w", err) 335 - } 336 - isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 337 - return user.Did == collab.Did 338 - }) 357 + roles := repoinfo.RolesInRepo{Roles: rp.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())} 358 + isRepoOwner := roles.IsOwner() 359 + isCollaborator := roles.IsCollaborator() 339 360 isIssueOwner := user.Did == issue.Did 340 361 341 - if isCollaborator || isIssueOwner { 362 + if isCollaborator || isRepoOwner || isIssueOwner { 342 363 err := db.ReopenIssues( 343 364 rp.db, 344 - db.FilterEq("id", issue.Id), 365 + orm.FilterEq("id", issue.Id), 345 366 ) 346 367 if err != nil { 347 - log.Println("failed to reopen issue", err) 368 + l.Error("failed to reopen issue", "err", err) 348 369 rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") 349 370 return 350 371 } 351 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 372 + // change the issue state (this will pass down to the notifiers) 373 + issue.Open = true 374 + 375 + // notify about the issue reopen 376 + rp.notifier.NewIssueState(r.Context(), syntax.DID(user.Did), issue) 377 + 378 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 379 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId)) 352 380 return 353 381 } else { 354 - log.Println("user is not the owner of the repo") 382 + l.Error("user is not the owner of the repo") 355 383 http.Error(w, "forbidden", http.StatusUnauthorized) 356 384 return 357 385 } ··· 385 413 replyTo = &replyToUri 386 414 } 387 415 416 + mentions, references := rp.mentionsResolver.Resolve(r.Context(), body) 417 + 388 418 comment := models.IssueComment{ 389 - Did: user.Did, 390 - Rkey: tid.TID(), 391 - IssueAt: issue.AtUri().String(), 392 - ReplyTo: replyTo, 393 - Body: body, 394 - Created: time.Now(), 419 + Did: user.Did, 420 + Rkey: tid.TID(), 421 + IssueAt: issue.AtUri().String(), 422 + ReplyTo: replyTo, 423 + Body: body, 424 + Created: time.Now(), 425 + Mentions: mentions, 426 + References: references, 395 427 } 396 428 if err = rp.validator.ValidateIssueComment(&comment); err != nil { 397 429 l.Error("failed to validate comment", "err", err) ··· 428 460 } 429 461 }() 430 462 431 - commentId, err := db.AddIssueComment(rp.db, comment) 463 + tx, err := rp.db.Begin() 464 + if err != nil { 465 + l.Error("failed to start transaction", "err", err) 466 + rp.pages.Notice(w, "issue-comment", "Failed to create comment, try again later.") 467 + return 468 + } 469 + defer tx.Rollback() 470 + 471 + commentId, err := db.AddIssueComment(tx, comment) 432 472 if err != nil { 433 473 l.Error("failed to create comment", "err", err) 434 474 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 435 475 return 436 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 + } 437 483 438 484 // reset atUri to make rollback a no-op 439 485 atUri = "" 440 486 441 487 // notify about the new comment 442 488 comment.Id = commentId 443 - rp.notifier.NewIssueComment(r.Context(), &comment) 489 + 490 + rp.notifier.NewIssueComment(r.Context(), &comment, mentions) 444 491 445 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issue.IssueId, commentId)) 492 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 493 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", ownerSlashRepo, issue.IssueId, commentId)) 446 494 } 447 495 448 496 func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) { 449 497 l := rp.logger.With("handler", "IssueComment") 450 498 user := rp.oauth.GetUser(r) 451 - f, err := rp.repoResolver.Resolve(r) 452 - if err != nil { 453 - l.Error("failed to get repo and knot", "err", err) 454 - return 455 - } 456 499 457 500 issue, ok := r.Context().Value("issue").(*models.Issue) 458 501 if !ok { ··· 464 507 commentId := chi.URLParam(r, "commentId") 465 508 comments, err := db.GetIssueComments( 466 509 rp.db, 467 - db.FilterEq("id", commentId), 510 + orm.FilterEq("id", commentId), 468 511 ) 469 512 if err != nil { 470 513 l.Error("failed to fetch comment", "id", commentId) ··· 480 523 481 524 rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 482 525 LoggedInUser: user, 483 - RepoInfo: f.RepoInfo(user), 526 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 484 527 Issue: issue, 485 528 Comment: &comment, 486 529 }) ··· 489 532 func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) { 490 533 l := rp.logger.With("handler", "EditIssueComment") 491 534 user := rp.oauth.GetUser(r) 492 - f, err := rp.repoResolver.Resolve(r) 493 - if err != nil { 494 - l.Error("failed to get repo and knot", "err", err) 495 - return 496 - } 497 535 498 536 issue, ok := r.Context().Value("issue").(*models.Issue) 499 537 if !ok { ··· 505 543 commentId := chi.URLParam(r, "commentId") 506 544 comments, err := db.GetIssueComments( 507 545 rp.db, 508 - db.FilterEq("id", commentId), 546 + orm.FilterEq("id", commentId), 509 547 ) 510 548 if err != nil { 511 549 l.Error("failed to fetch comment", "id", commentId) ··· 529 567 case http.MethodGet: 530 568 rp.pages.EditIssueCommentFragment(w, pages.EditIssueCommentParams{ 531 569 LoggedInUser: user, 532 - RepoInfo: f.RepoInfo(user), 570 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 533 571 Issue: issue, 534 572 Comment: &comment, 535 573 }) ··· 538 576 newBody := r.FormValue("body") 539 577 client, err := rp.oauth.AuthorizedClient(r) 540 578 if err != nil { 541 - log.Println("failed to get authorized client", err) 579 + l.Error("failed to get authorized client", "err", err) 542 580 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 543 581 return 544 582 } ··· 547 585 newComment := comment 548 586 newComment.Body = newBody 549 587 newComment.Edited = &now 588 + newComment.Mentions, newComment.References = rp.mentionsResolver.Resolve(r.Context(), newBody) 589 + 550 590 record := newComment.AsRecord() 551 591 552 - _, err = db.AddIssueComment(rp.db, newComment) 592 + tx, err := rp.db.Begin() 553 593 if err != nil { 554 - log.Println("failed to perferom update-description query", err) 594 + l.Error("failed to start transaction", "err", err) 555 595 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 556 596 return 557 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() 558 607 559 608 // rkey is optional, it was introduced later 560 609 if newComment.Rkey != "" { 561 610 // update the record on pds 562 611 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey) 563 612 if err != nil { 564 - log.Println("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey) 613 + l.Error("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey) 565 614 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") 566 615 return 567 616 } ··· 583 632 // return new comment body with htmx 584 633 rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 585 634 LoggedInUser: user, 586 - RepoInfo: f.RepoInfo(user), 635 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 587 636 Issue: issue, 588 637 Comment: &newComment, 589 638 }) ··· 593 642 func (rp *Issues) ReplyIssueCommentPlaceholder(w http.ResponseWriter, r *http.Request) { 594 643 l := rp.logger.With("handler", "ReplyIssueCommentPlaceholder") 595 644 user := rp.oauth.GetUser(r) 596 - f, err := rp.repoResolver.Resolve(r) 597 - if err != nil { 598 - l.Error("failed to get repo and knot", "err", err) 599 - return 600 - } 601 645 602 646 issue, ok := r.Context().Value("issue").(*models.Issue) 603 647 if !ok { ··· 609 653 commentId := chi.URLParam(r, "commentId") 610 654 comments, err := db.GetIssueComments( 611 655 rp.db, 612 - db.FilterEq("id", commentId), 656 + orm.FilterEq("id", commentId), 613 657 ) 614 658 if err != nil { 615 659 l.Error("failed to fetch comment", "id", commentId) ··· 625 669 626 670 rp.pages.ReplyIssueCommentPlaceholderFragment(w, pages.ReplyIssueCommentPlaceholderParams{ 627 671 LoggedInUser: user, 628 - RepoInfo: f.RepoInfo(user), 672 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 629 673 Issue: issue, 630 674 Comment: &comment, 631 675 }) ··· 634 678 func (rp *Issues) ReplyIssueComment(w http.ResponseWriter, r *http.Request) { 635 679 l := rp.logger.With("handler", "ReplyIssueComment") 636 680 user := rp.oauth.GetUser(r) 637 - f, err := rp.repoResolver.Resolve(r) 638 - if err != nil { 639 - l.Error("failed to get repo and knot", "err", err) 640 - return 641 - } 642 681 643 682 issue, ok := r.Context().Value("issue").(*models.Issue) 644 683 if !ok { ··· 650 689 commentId := chi.URLParam(r, "commentId") 651 690 comments, err := db.GetIssueComments( 652 691 rp.db, 653 - db.FilterEq("id", commentId), 692 + orm.FilterEq("id", commentId), 654 693 ) 655 694 if err != nil { 656 695 l.Error("failed to fetch comment", "id", commentId) ··· 666 705 667 706 rp.pages.ReplyIssueCommentFragment(w, pages.ReplyIssueCommentParams{ 668 707 LoggedInUser: user, 669 - RepoInfo: f.RepoInfo(user), 708 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 670 709 Issue: issue, 671 710 Comment: &comment, 672 711 }) ··· 675 714 func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { 676 715 l := rp.logger.With("handler", "DeleteIssueComment") 677 716 user := rp.oauth.GetUser(r) 678 - f, err := rp.repoResolver.Resolve(r) 679 - if err != nil { 680 - l.Error("failed to get repo and knot", "err", err) 681 - return 682 - } 683 717 684 718 issue, ok := r.Context().Value("issue").(*models.Issue) 685 719 if !ok { ··· 691 725 commentId := chi.URLParam(r, "commentId") 692 726 comments, err := db.GetIssueComments( 693 727 rp.db, 694 - db.FilterEq("id", commentId), 728 + orm.FilterEq("id", commentId), 695 729 ) 696 730 if err != nil { 697 731 l.Error("failed to fetch comment", "id", commentId) ··· 718 752 719 753 // optimistic deletion 720 754 deleted := time.Now() 721 - err = db.DeleteIssueComments(rp.db, db.FilterEq("id", comment.Id)) 755 + err = db.DeleteIssueComments(rp.db, orm.FilterEq("id", comment.Id)) 722 756 if err != nil { 723 757 l.Error("failed to delete comment", "err", err) 724 758 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment") ··· 729 763 if comment.Rkey != "" { 730 764 client, err := rp.oauth.AuthorizedClient(r) 731 765 if err != nil { 732 - log.Println("failed to get authorized client", err) 766 + l.Error("failed to get authorized client", "err", err) 733 767 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 734 768 return 735 769 } ··· 739 773 Rkey: comment.Rkey, 740 774 }) 741 775 if err != nil { 742 - log.Println(err) 776 + l.Error("failed to delete from PDS", "err", err) 743 777 } 744 778 } 745 779 ··· 750 784 // htmx fragment of comment after deletion 751 785 rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 752 786 LoggedInUser: user, 753 - RepoInfo: f.RepoInfo(user), 787 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 754 788 Issue: issue, 755 789 Comment: &comment, 756 790 }) 757 791 } 758 792 759 793 func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) { 794 + l := rp.logger.With("handler", "RepoIssues") 795 + 760 796 params := r.URL.Query() 761 797 state := params.Get("state") 762 798 isOpen := true ··· 769 805 isOpen = true 770 806 } 771 807 772 - page, ok := r.Context().Value("page").(pagination.Page) 773 - if !ok { 774 - log.Println("failed to get page") 775 - page = pagination.FirstPage() 776 - } 808 + page := pagination.FromContext(r.Context()) 777 809 778 810 user := rp.oauth.GetUser(r) 779 811 f, err := rp.repoResolver.Resolve(r) 780 812 if err != nil { 781 - log.Println("failed to get repo and knot", err) 813 + l.Error("failed to get repo and knot", "err", err) 782 814 return 783 815 } 784 816 785 - openVal := 0 817 + totalIssues := 0 786 818 if isOpen { 787 - openVal = 1 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 827 + searchOpts := models.IssueSearchOptions{ 828 + Keyword: keyword, 829 + RepoAt: f.RepoAt().String(), 830 + IsOpen: isOpen, 831 + Page: page, 788 832 } 789 - issues, err := db.GetIssuesPaginated( 790 - rp.db, 791 - page, 792 - db.FilterEq("repo_at", f.RepoAt()), 793 - db.FilterEq("open", openVal), 794 - ) 795 - if err != nil { 796 - log.Println("failed to get issues", err) 797 - rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 798 - return 833 + if keyword != "" { 834 + res, err := rp.indexer.Search(r.Context(), searchOpts) 835 + if err != nil { 836 + l.Error("failed to search for issues", "err", err) 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) 848 + rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 849 + return 850 + } 851 + 852 + } else { 853 + openInt := 0 854 + if isOpen { 855 + openInt = 1 856 + } 857 + issues, err = db.GetIssuesPaginated( 858 + rp.db, 859 + page, 860 + orm.FilterEq("repo_at", f.RepoAt()), 861 + orm.FilterEq("open", openInt), 862 + ) 863 + if err != nil { 864 + l.Error("failed to get issues", "err", err) 865 + rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 866 + return 867 + } 799 868 } 800 869 801 870 labelDefs, err := db.GetLabelDefinitions( 802 871 rp.db, 803 - db.FilterIn("at_uri", f.Repo.Labels), 804 - db.FilterContains("scope", tangled.RepoIssueNSID), 872 + orm.FilterIn("at_uri", f.Labels), 873 + orm.FilterContains("scope", tangled.RepoIssueNSID), 805 874 ) 806 875 if err != nil { 807 - log.Println("failed to fetch labels", err) 876 + l.Error("failed to fetch labels", "err", err) 808 877 rp.pages.Error503(w) 809 878 return 810 879 } ··· 816 885 817 886 rp.pages.RepoIssues(w, pages.RepoIssuesParams{ 818 887 LoggedInUser: rp.oauth.GetUser(r), 819 - RepoInfo: f.RepoInfo(user), 888 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 820 889 Issues: issues, 890 + IssueCount: totalIssues, 821 891 LabelDefs: defs, 822 892 FilteringByOpen: isOpen, 893 + FilterQuery: keyword, 823 894 Page: page, 824 895 }) 825 896 } ··· 838 909 case http.MethodGet: 839 910 rp.pages.RepoNewIssue(w, pages.RepoNewIssueParams{ 840 911 LoggedInUser: user, 841 - RepoInfo: f.RepoInfo(user), 912 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 842 913 }) 843 914 case http.MethodPost: 915 + body := r.FormValue("body") 916 + mentions, references := rp.mentionsResolver.Resolve(r.Context(), body) 917 + 844 918 issue := &models.Issue{ 845 - RepoAt: f.RepoAt(), 846 - Rkey: tid.TID(), 847 - Title: r.FormValue("title"), 848 - Body: r.FormValue("body"), 849 - Did: user.Did, 850 - Created: time.Now(), 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, 851 929 } 852 930 853 931 if err := rp.validator.ValidateIssue(issue); err != nil { ··· 901 979 902 980 err = db.PutIssue(tx, issue) 903 981 if err != nil { 904 - log.Println("failed to create issue", err) 982 + l.Error("failed to create issue", "err", err) 905 983 rp.pages.Notice(w, "issues", "Failed to create issue.") 906 984 return 907 985 } 908 986 909 987 if err = tx.Commit(); err != nil { 910 - log.Println("failed to create issue", err) 988 + l.Error("failed to create issue", "err", err) 911 989 rp.pages.Notice(w, "issues", "Failed to create issue.") 912 990 return 913 991 } 914 992 915 993 // everything is successful, do not rollback the atproto record 916 994 atUri = "" 917 - rp.notifier.NewIssue(r.Context(), issue) 918 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 995 + 996 + rp.notifier.NewIssue(r.Context(), issue, mentions) 997 + 998 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 999 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId)) 919 1000 return 920 1001 } 921 1002 }
+267
appview/issues/opengraph.go
··· 1 + package issues 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "fmt" 7 + "image" 8 + "image/color" 9 + "image/png" 10 + "log" 11 + "net/http" 12 + 13 + "tangled.org/core/appview/models" 14 + "tangled.org/core/appview/ogcard" 15 + ) 16 + 17 + func (rp *Issues) drawIssueSummaryCard(issue *models.Issue, repo *models.Repo, commentCount int, ownerHandle string) (*ogcard.Card, error) { 18 + width, height := ogcard.DefaultSize() 19 + mainCard, err := ogcard.NewCard(width, height) 20 + if err != nil { 21 + return nil, err 22 + } 23 + 24 + // Split: content area (75%) and status/stats area (25%) 25 + contentCard, statsArea := mainCard.Split(false, 75) 26 + 27 + // Add padding to content 28 + contentCard.SetMargin(50) 29 + 30 + // Split content horizontally: main content (80%) and avatar area (20%) 31 + mainContent, avatarArea := contentCard.Split(true, 80) 32 + 33 + // Add margin to main content like repo card 34 + mainContent.SetMargin(10) 35 + 36 + // Use full main content area for repo name and title 37 + bounds := mainContent.Img.Bounds() 38 + startX := bounds.Min.X + mainContent.Margin 39 + startY := bounds.Min.Y + mainContent.Margin 40 + 41 + // Draw full repository name at top (owner/repo format) 42 + var repoOwner string 43 + owner, err := rp.idResolver.ResolveIdent(context.Background(), repo.Did) 44 + if err != nil { 45 + repoOwner = repo.Did 46 + } else { 47 + repoOwner = "@" + owner.Handle.String() 48 + } 49 + 50 + fullRepoName := repoOwner + " / " + repo.Name 51 + if len(fullRepoName) > 60 { 52 + fullRepoName = fullRepoName[:60] + "โ€ฆ" 53 + } 54 + 55 + grayColor := color.RGBA{88, 96, 105, 255} 56 + err = mainContent.DrawTextAt(fullRepoName, startX, startY, grayColor, 36, ogcard.Top, ogcard.Left) 57 + if err != nil { 58 + return nil, err 59 + } 60 + 61 + // Draw issue title below repo name with wrapping 62 + titleY := startY + 60 63 + titleX := startX 64 + 65 + // Truncate title if too long 66 + issueTitle := issue.Title 67 + maxTitleLength := 80 68 + if len(issueTitle) > maxTitleLength { 69 + issueTitle = issueTitle[:maxTitleLength] + "โ€ฆ" 70 + } 71 + 72 + // Create a temporary card for the title area to enable wrapping 73 + titleBounds := mainContent.Img.Bounds() 74 + titleWidth := titleBounds.Dx() - (startX - titleBounds.Min.X) - 20 // Leave some margin 75 + titleHeight := titleBounds.Dy() - (titleY - titleBounds.Min.Y) - 100 // Leave space for issue ID 76 + 77 + titleRect := image.Rect(titleX, titleY, titleX+titleWidth, titleY+titleHeight) 78 + titleCard := &ogcard.Card{ 79 + Img: mainContent.Img.SubImage(titleRect).(*image.RGBA), 80 + Font: mainContent.Font, 81 + Margin: 0, 82 + } 83 + 84 + // Draw wrapped title 85 + lines, err := titleCard.DrawText(issueTitle, color.Black, 54, ogcard.Top, ogcard.Left) 86 + if err != nil { 87 + return nil, err 88 + } 89 + 90 + // Calculate where title ends (number of lines * line height) 91 + lineHeight := 60 // Approximate line height for 54pt font 92 + titleEndY := titleY + (len(lines) * lineHeight) + 10 93 + 94 + // Draw issue ID in gray below the title 95 + issueIdText := fmt.Sprintf("#%d", issue.IssueId) 96 + err = mainContent.DrawTextAt(issueIdText, startX, titleEndY, grayColor, 54, ogcard.Top, ogcard.Left) 97 + if err != nil { 98 + return nil, err 99 + } 100 + 101 + // Get issue author handle (needed for avatar and metadata) 102 + var authorHandle string 103 + author, err := rp.idResolver.ResolveIdent(context.Background(), issue.Did) 104 + if err != nil { 105 + authorHandle = issue.Did 106 + } else { 107 + authorHandle = "@" + author.Handle.String() 108 + } 109 + 110 + // Draw avatar circle on the right side 111 + avatarBounds := avatarArea.Img.Bounds() 112 + avatarSize := min(avatarBounds.Dx(), avatarBounds.Dy()) - 20 // Leave some margin 113 + if avatarSize > 220 { 114 + avatarSize = 220 115 + } 116 + avatarX := avatarBounds.Min.X + (avatarBounds.Dx() / 2) - (avatarSize / 2) 117 + avatarY := avatarBounds.Min.Y + 20 118 + 119 + // Get avatar URL for issue author 120 + avatarURL := rp.pages.AvatarUrl(authorHandle, "256") 121 + err = avatarArea.DrawCircularExternalImage(avatarURL, avatarX, avatarY, avatarSize) 122 + if err != nil { 123 + log.Printf("failed to draw avatar (non-fatal): %v", err) 124 + } 125 + 126 + // Split stats area: left side for status/comments (80%), right side for dolly (20%) 127 + statusCommentsArea, dollyArea := statsArea.Split(true, 80) 128 + 129 + // Draw status and comment count in status/comments area 130 + statsBounds := statusCommentsArea.Img.Bounds() 131 + statsX := statsBounds.Min.X + 60 // left padding 132 + statsY := statsBounds.Min.Y 133 + 134 + iconColor := color.RGBA{88, 96, 105, 255} 135 + iconSize := 36 136 + textSize := 36.0 137 + labelSize := 28.0 138 + iconBaselineOffset := int(textSize) / 2 139 + 140 + // Draw status (open/closed) with colored icon and text 141 + var statusIcon string 142 + var statusText string 143 + var statusBgColor color.RGBA 144 + 145 + if issue.Open { 146 + statusIcon = "circle-dot" 147 + statusText = "open" 148 + statusBgColor = color.RGBA{34, 139, 34, 255} // green 149 + } else { 150 + statusIcon = "ban" 151 + statusText = "closed" 152 + statusBgColor = color.RGBA{52, 58, 64, 255} // dark gray 153 + } 154 + 155 + badgeIconSize := 36 156 + 157 + // Draw icon with status color (no background) 158 + err = statusCommentsArea.DrawLucideIcon(statusIcon, statsX, statsY+iconBaselineOffset-badgeIconSize/2+5, badgeIconSize, statusBgColor) 159 + if err != nil { 160 + log.Printf("failed to draw status icon: %v", err) 161 + } 162 + 163 + // Draw text with status color (no background) 164 + textX := statsX + badgeIconSize + 12 165 + badgeTextSize := 32.0 166 + err = statusCommentsArea.DrawTextAt(statusText, textX, statsY+iconBaselineOffset, statusBgColor, badgeTextSize, ogcard.Middle, ogcard.Left) 167 + if err != nil { 168 + log.Printf("failed to draw status text: %v", err) 169 + } 170 + 171 + statusTextWidth := len(statusText) * 20 172 + currentX := statsX + badgeIconSize + 12 + statusTextWidth + 50 173 + 174 + // Draw comment count 175 + err = statusCommentsArea.DrawLucideIcon("message-square", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) 176 + if err != nil { 177 + log.Printf("failed to draw comment icon: %v", err) 178 + } 179 + 180 + currentX += iconSize + 15 181 + commentText := fmt.Sprintf("%d comments", commentCount) 182 + if commentCount == 1 { 183 + commentText = "1 comment" 184 + } 185 + err = statusCommentsArea.DrawTextAt(commentText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left) 186 + if err != nil { 187 + log.Printf("failed to draw comment text: %v", err) 188 + } 189 + 190 + // Draw dolly logo on the right side 191 + dollyBounds := dollyArea.Img.Bounds() 192 + dollySize := 90 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 202 + labelY := statsY + iconSize + 30 203 + 204 + // Format the opened date 205 + openedDate := issue.Created.Format("Jan 2, 2006") 206 + metaText := fmt.Sprintf("opened by %s ยท %s", authorHandle, openedDate) 207 + 208 + err = statusCommentsArea.DrawTextAt(metaText, statsX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Left) 209 + if err != nil { 210 + log.Printf("failed to draw metadata: %v", err) 211 + } 212 + 213 + return mainCard, nil 214 + } 215 + 216 + func (rp *Issues) IssueOpenGraphSummary(w http.ResponseWriter, r *http.Request) { 217 + f, err := rp.repoResolver.Resolve(r) 218 + if err != nil { 219 + log.Println("failed to get repo and knot", err) 220 + return 221 + } 222 + 223 + issue, ok := r.Context().Value("issue").(*models.Issue) 224 + if !ok { 225 + log.Println("issue not found in context") 226 + http.Error(w, "issue not found", http.StatusNotFound) 227 + return 228 + } 229 + 230 + // Get comment count 231 + commentCount := len(issue.Comments) 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) 246 + return 247 + } 248 + 249 + var imageBuffer bytes.Buffer 250 + err = png.Encode(&imageBuffer, card.Img) 251 + if err != nil { 252 + log.Println("failed to encode issue summary card", err) 253 + http.Error(w, "failed to encode issue summary card", http.StatusInternalServerError) 254 + return 255 + } 256 + 257 + imageBytes := imageBuffer.Bytes() 258 + 259 + w.Header().Set("Content-Type", "image/png") 260 + w.Header().Set("Cache-Control", "public, max-age=3600") // 1 hour 261 + w.WriteHeader(http.StatusOK) 262 + _, err = w.Write(imageBytes) 263 + if err != nil { 264 + log.Println("failed to write issue summary card", err) 265 + return 266 + } 267 + }
+1
appview/issues/router.go
··· 16 16 r.Route("/{issue}", func(r chi.Router) { 17 17 r.Use(mw.ResolveIssue) 18 18 r.Get("/", i.RepoSingleIssue) 19 + r.Get("/opengraph", i.IssueOpenGraphSummary) 19 20 20 21 // authenticated routes 21 22 r.Group(func(r chi.Router) {
+46 -19
appview/knots/knots.go
··· 6 6 "log/slog" 7 7 "net/http" 8 8 "slices" 9 + "strings" 9 10 "time" 10 11 11 12 "github.com/go-chi/chi/v5" ··· 20 21 "tangled.org/core/appview/xrpcclient" 21 22 "tangled.org/core/eventconsumer" 22 23 "tangled.org/core/idresolver" 24 + "tangled.org/core/orm" 23 25 "tangled.org/core/rbac" 24 26 "tangled.org/core/tid" 25 27 ··· 38 40 Knotstream *eventconsumer.Consumer 39 41 } 40 42 43 + type tab = map[string]any 44 + 45 + var ( 46 + knotsTabs []tab = []tab{ 47 + {"Name": "profile", "Icon": "user"}, 48 + {"Name": "keys", "Icon": "key"}, 49 + {"Name": "emails", "Icon": "mail"}, 50 + {"Name": "notifications", "Icon": "bell"}, 51 + {"Name": "knots", "Icon": "volleyball"}, 52 + {"Name": "spindles", "Icon": "spool"}, 53 + } 54 + ) 55 + 41 56 func (k *Knots) Router() http.Handler { 42 57 r := chi.NewRouter() 43 58 ··· 58 73 user := k.OAuth.GetUser(r) 59 74 registrations, err := db.GetRegistrations( 60 75 k.Db, 61 - db.FilterEq("did", user.Did), 76 + orm.FilterEq("did", user.Did), 62 77 ) 63 78 if err != nil { 64 79 k.Logger.Error("failed to fetch knot registrations", "err", err) ··· 69 84 k.Pages.Knots(w, pages.KnotsParams{ 70 85 LoggedInUser: user, 71 86 Registrations: registrations, 87 + Tabs: knotsTabs, 88 + Tab: "knots", 72 89 }) 73 90 } 74 91 ··· 86 103 87 104 registrations, err := db.GetRegistrations( 88 105 k.Db, 89 - db.FilterEq("did", user.Did), 90 - db.FilterEq("domain", domain), 106 + orm.FilterEq("did", user.Did), 107 + orm.FilterEq("domain", domain), 91 108 ) 92 109 if err != nil { 93 110 l.Error("failed to get registrations", "err", err) ··· 111 128 repos, err := db.GetRepos( 112 129 k.Db, 113 130 0, 114 - db.FilterEq("knot", domain), 131 + orm.FilterEq("knot", domain), 115 132 ) 116 133 if err != nil { 117 134 l.Error("failed to get knot repos", "err", err) ··· 131 148 Members: members, 132 149 Repos: repoMap, 133 150 IsOwner: true, 151 + Tabs: knotsTabs, 152 + Tab: "knots", 134 153 }) 135 154 } 136 155 ··· 145 164 } 146 165 147 166 domain := r.FormValue("domain") 167 + // Strip protocol, trailing slashes, and whitespace 168 + // Rkey cannot contain slashes 169 + domain = strings.TrimSpace(domain) 170 + domain = strings.TrimPrefix(domain, "https://") 171 + domain = strings.TrimPrefix(domain, "http://") 172 + domain = strings.TrimSuffix(domain, "/") 148 173 if domain == "" { 149 174 k.Pages.Notice(w, noticeId, "Incomplete form.") 150 175 return ··· 269 294 // get record from db first 270 295 registrations, err := db.GetRegistrations( 271 296 k.Db, 272 - db.FilterEq("did", user.Did), 273 - db.FilterEq("domain", domain), 297 + orm.FilterEq("did", user.Did), 298 + orm.FilterEq("domain", domain), 274 299 ) 275 300 if err != nil { 276 301 l.Error("failed to get registration", "err", err) ··· 297 322 298 323 err = db.DeleteKnot( 299 324 tx, 300 - db.FilterEq("did", user.Did), 301 - db.FilterEq("domain", domain), 325 + orm.FilterEq("did", user.Did), 326 + orm.FilterEq("domain", domain), 302 327 ) 303 328 if err != nil { 304 329 l.Error("failed to delete registration", "err", err) ··· 378 403 // get record from db first 379 404 registrations, err := db.GetRegistrations( 380 405 k.Db, 381 - db.FilterEq("did", user.Did), 382 - db.FilterEq("domain", domain), 406 + orm.FilterEq("did", user.Did), 407 + orm.FilterEq("domain", domain), 383 408 ) 384 409 if err != nil { 385 410 l.Error("failed to get registration", "err", err) ··· 469 494 // Get updated registration to show 470 495 registrations, err = db.GetRegistrations( 471 496 k.Db, 472 - db.FilterEq("did", user.Did), 473 - db.FilterEq("domain", domain), 497 + orm.FilterEq("did", user.Did), 498 + orm.FilterEq("domain", domain), 474 499 ) 475 500 if err != nil { 476 501 l.Error("failed to get registration", "err", err) ··· 505 530 506 531 registrations, err := db.GetRegistrations( 507 532 k.Db, 508 - db.FilterEq("did", user.Did), 509 - db.FilterEq("domain", domain), 510 - db.FilterIsNot("registered", "null"), 533 + orm.FilterEq("did", user.Did), 534 + orm.FilterEq("domain", domain), 535 + orm.FilterIsNot("registered", "null"), 511 536 ) 512 537 if err != nil { 513 538 l.Error("failed to get registration", "err", err) ··· 526 551 } 527 552 528 553 member := r.FormValue("member") 554 + member = strings.TrimPrefix(member, "@") 529 555 if member == "" { 530 556 l.Error("empty member") 531 557 k.Pages.Notice(w, noticeId, "Failed to add member, empty form.") ··· 588 614 } 589 615 590 616 // success 591 - k.Pages.HxRedirect(w, fmt.Sprintf("/knots/%s", domain)) 617 + k.Pages.HxRedirect(w, fmt.Sprintf("/settings/knots/%s", domain)) 592 618 } 593 619 594 620 func (k *Knots) removeMember(w http.ResponseWriter, r *http.Request) { ··· 612 638 613 639 registrations, err := db.GetRegistrations( 614 640 k.Db, 615 - db.FilterEq("did", user.Did), 616 - db.FilterEq("domain", domain), 617 - db.FilterIsNot("registered", "null"), 641 + orm.FilterEq("did", user.Did), 642 + orm.FilterEq("domain", domain), 643 + orm.FilterIsNot("registered", "null"), 618 644 ) 619 645 if err != nil { 620 646 l.Error("failed to get registration", "err", err) ··· 626 652 } 627 653 628 654 member := r.FormValue("member") 655 + member = strings.TrimPrefix(member, "@") 629 656 if member == "" { 630 657 l.Error("empty member") 631 658 k.Pages.Notice(w, noticeId, "Failed to remove member, empty form.")
+7 -8
appview/labels/labels.go
··· 16 16 "tangled.org/core/appview/oauth" 17 17 "tangled.org/core/appview/pages" 18 18 "tangled.org/core/appview/validator" 19 - "tangled.org/core/log" 19 + "tangled.org/core/orm" 20 20 "tangled.org/core/rbac" 21 21 "tangled.org/core/tid" 22 22 ··· 42 42 db *db.DB, 43 43 validator *validator.Validator, 44 44 enforcer *rbac.Enforcer, 45 + logger *slog.Logger, 45 46 ) *Labels { 46 - logger := log.New("labels") 47 - 48 47 return &Labels{ 49 48 oauth: oauth, 50 49 pages: pages, ··· 55 54 } 56 55 } 57 56 58 - func (l *Labels) Router(mw *middleware.Middleware) http.Handler { 57 + func (l *Labels) Router() http.Handler { 59 58 r := chi.NewRouter() 60 59 61 60 r.Use(middleware.AuthMiddleware(l.oauth)) ··· 90 89 repoAt := r.Form.Get("repo") 91 90 subjectUri := r.Form.Get("subject") 92 91 93 - repo, err := db.GetRepo(l.db, db.FilterEq("at_uri", repoAt)) 92 + repo, err := db.GetRepo(l.db, orm.FilterEq("at_uri", repoAt)) 94 93 if err != nil { 95 94 fail("Failed to get repository.", err) 96 95 return 97 96 } 98 97 99 98 // find all the labels that this repo subscribes to 100 - repoLabels, err := db.GetRepoLabels(l.db, db.FilterEq("repo_at", repoAt)) 99 + repoLabels, err := db.GetRepoLabels(l.db, orm.FilterEq("repo_at", repoAt)) 101 100 if err != nil { 102 101 fail("Failed to get labels for this repository.", err) 103 102 return ··· 108 107 labelAts = append(labelAts, rl.LabelAt.String()) 109 108 } 110 109 111 - actx, err := db.NewLabelApplicationCtx(l.db, db.FilterIn("at_uri", labelAts)) 110 + actx, err := db.NewLabelApplicationCtx(l.db, orm.FilterIn("at_uri", labelAts)) 112 111 if err != nil { 113 112 fail("Invalid form data.", err) 114 113 return 115 114 } 116 115 117 116 // calculate the start state by applying already known labels 118 - existingOps, err := db.GetLabelOps(l.db, db.FilterEq("subject", subjectUri)) 117 + existingOps, err := db.GetLabelOps(l.db, orm.FilterEq("subject", subjectUri)) 119 118 if err != nil { 120 119 fail("Invalid form data.", err) 121 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 + }
+16 -20
appview/middleware/middleware.go
··· 18 18 "tangled.org/core/appview/pagination" 19 19 "tangled.org/core/appview/reporesolver" 20 20 "tangled.org/core/idresolver" 21 + "tangled.org/core/orm" 21 22 "tangled.org/core/rbac" 22 23 ) 23 24 ··· 105 106 } 106 107 } 107 108 108 - ctx := context.WithValue(r.Context(), "page", page) 109 + ctx := pagination.IntoContext(r.Context(), page) 109 110 next.ServeHTTP(w, r.WithContext(ctx)) 110 111 }) 111 112 } ··· 164 165 ok, err := mw.enforcer.E.Enforce(actor.Did, f.Knot, f.DidSlashRepo(), requiredPerm) 165 166 if err != nil || !ok { 166 167 // we need a logged in user 167 - log.Printf("%s does not have perms of a %s in repo %s", actor.Did, requiredPerm, f.OwnerSlashRepo()) 168 + log.Printf("%s does not have perms of a %s in repo %s", actor.Did, requiredPerm, f.DidSlashRepo()) 168 169 http.Error(w, "Forbiden", http.StatusUnauthorized) 169 170 return 170 171 } ··· 180 181 return func(next http.Handler) http.Handler { 181 182 return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 182 183 didOrHandle := chi.URLParam(req, "user") 184 + didOrHandle = strings.TrimPrefix(didOrHandle, "@") 185 + 183 186 if slices.Contains(excluded, didOrHandle) { 184 187 next.ServeHTTP(w, req) 185 188 return 186 189 } 187 - 188 - didOrHandle = strings.TrimPrefix(didOrHandle, "@") 189 190 190 191 id, err := mw.idResolver.ResolveIdent(req.Context(), didOrHandle) 191 192 if err != nil { ··· 206 207 return func(next http.Handler) http.Handler { 207 208 return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 208 209 repoName := chi.URLParam(req, "repo") 210 + repoName = strings.TrimSuffix(repoName, ".git") 211 + 209 212 id, ok := req.Context().Value("resolvedId").(identity.Identity) 210 213 if !ok { 211 214 log.Println("malformed middleware") ··· 215 218 216 219 repo, err := db.GetRepo( 217 220 mw.db, 218 - db.FilterEq("did", id.DID.String()), 219 - db.FilterEq("name", repoName), 221 + orm.FilterEq("did", id.DID.String()), 222 + orm.FilterEq("name", repoName), 220 223 ) 221 224 if err != nil { 222 225 log.Println("failed to resolve repo", "err", err) ··· 244 247 prId := chi.URLParam(r, "pull") 245 248 prIdInt, err := strconv.Atoi(prId) 246 249 if err != nil { 247 - http.Error(w, "bad pr id", http.StatusBadRequest) 248 250 log.Println("failed to parse pr id", err) 251 + mw.pages.Error404(w) 249 252 return 250 253 } 251 254 252 255 pr, err := db.GetPull(mw.db, f.RepoAt(), prIdInt) 253 256 if err != nil { 254 257 log.Println("failed to get pull and comments", err) 258 + mw.pages.Error404(w) 255 259 return 256 260 } 257 261 ··· 292 296 issueId, err := strconv.Atoi(issueIdStr) 293 297 if err != nil { 294 298 log.Println("failed to fully resolve issue ID", err) 295 - mw.pages.ErrorKnot404(w) 299 + mw.pages.Error404(w) 296 300 return 297 301 } 298 302 299 - issues, err := db.GetIssues( 300 - mw.db, 301 - db.FilterEq("repo_at", f.RepoAt()), 302 - db.FilterEq("issue_id", issueId), 303 - ) 303 + issue, err := db.GetIssue(mw.db, f.RepoAt(), issueId) 304 304 if err != nil { 305 305 log.Println("failed to get issues", "err", err) 306 - return 307 - } 308 - if len(issues) != 1 { 309 - log.Println("got incorrect number of issues", "len(issuse)", len(issues)) 306 + mw.pages.Error404(w) 310 307 return 311 308 } 312 - issue := issues[0] 313 309 314 - ctx := context.WithValue(r.Context(), "issue", &issue) 310 + ctx := context.WithValue(r.Context(), "issue", issue) 315 311 next.ServeHTTP(w, r.WithContext(ctx)) 316 312 }) 317 313 } ··· 332 328 return 333 329 } 334 330 335 - fullName := f.OwnerHandle() + "/" + f.Name 331 + fullName := reporesolver.GetBaseRepoPath(r, f) 336 332 337 333 if r.Header.Get("User-Agent") == "Go-http-client/1.1" { 338 334 if r.URL.Query().Get("go-get") == "1" {
+94 -34
appview/models/issue.go
··· 10 10 ) 11 11 12 12 type Issue struct { 13 - Id int64 14 - Did string 15 - Rkey string 16 - RepoAt syntax.ATURI 17 - IssueId int 18 - Created time.Time 19 - Edited *time.Time 20 - Deleted *time.Time 21 - Title string 22 - Body string 23 - Open bool 13 + Id int64 14 + Did string 15 + Rkey string 16 + RepoAt syntax.ATURI 17 + IssueId int 18 + Created time.Time 19 + Edited *time.Time 20 + Deleted *time.Time 21 + Title string 22 + Body string 23 + Open bool 24 + Mentions []syntax.DID 25 + References []syntax.ATURI 24 26 25 27 // optionally, populate this when querying for reverse mappings 26 28 // like comment counts, parent repo etc. ··· 34 36 } 35 37 36 38 func (i *Issue) AsRecord() tangled.RepoIssue { 39 + mentions := make([]string, len(i.Mentions)) 40 + for i, did := range i.Mentions { 41 + mentions[i] = string(did) 42 + } 43 + references := make([]string, len(i.References)) 44 + for i, uri := range i.References { 45 + references[i] = string(uri) 46 + } 37 47 return tangled.RepoIssue{ 38 - Repo: i.RepoAt.String(), 39 - Title: i.Title, 40 - Body: &i.Body, 41 - CreatedAt: i.Created.Format(time.RFC3339), 48 + Repo: i.RepoAt.String(), 49 + Title: i.Title, 50 + Body: &i.Body, 51 + Mentions: mentions, 52 + References: references, 53 + CreatedAt: i.Created.Format(time.RFC3339), 42 54 } 43 55 } 44 56 ··· 52 64 type CommentListItem struct { 53 65 Self *IssueComment 54 66 Replies []*IssueComment 67 + } 68 + 69 + func (it *CommentListItem) Participants() []syntax.DID { 70 + participantSet := make(map[syntax.DID]struct{}) 71 + participants := []syntax.DID{} 72 + 73 + addParticipant := func(did syntax.DID) { 74 + if _, exists := participantSet[did]; !exists { 75 + participantSet[did] = struct{}{} 76 + participants = append(participants, did) 77 + } 78 + } 79 + 80 + addParticipant(syntax.DID(it.Self.Did)) 81 + 82 + for _, c := range it.Replies { 83 + addParticipant(syntax.DID(c.Did)) 84 + } 85 + 86 + return participants 55 87 } 56 88 57 89 func (i *Issue) CommentList() []CommentListItem { ··· 141 173 } 142 174 143 175 type IssueComment struct { 144 - Id int64 145 - Did string 146 - Rkey string 147 - IssueAt string 148 - ReplyTo *string 149 - Body string 150 - Created time.Time 151 - Edited *time.Time 152 - Deleted *time.Time 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 153 187 } 154 188 155 189 func (i *IssueComment) AtUri() syntax.ATURI { ··· 157 191 } 158 192 159 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 + } 160 202 return tangled.RepoIssueComment{ 161 - Body: i.Body, 162 - Issue: i.IssueAt, 163 - CreatedAt: i.Created.Format(time.RFC3339), 164 - ReplyTo: i.ReplyTo, 203 + Body: i.Body, 204 + Issue: i.IssueAt, 205 + CreatedAt: i.Created.Format(time.RFC3339), 206 + ReplyTo: i.ReplyTo, 207 + Mentions: mentions, 208 + References: references, 165 209 } 166 210 } 167 211 168 212 func (i *IssueComment) IsTopLevel() bool { 169 213 return i.ReplyTo == nil 214 + } 215 + 216 + func (i *IssueComment) IsReply() bool { 217 + return i.ReplyTo != nil 170 218 } 171 219 172 220 func IssueCommentFromRecord(did, rkey string, record tangled.RepoIssueComment) (*IssueComment, error) { ··· 181 229 return nil, err 182 230 } 183 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 + 184 242 comment := IssueComment{ 185 - Did: ownerDid, 186 - Rkey: rkey, 187 - Body: record.Body, 188 - IssueAt: record.Issue, 189 - ReplyTo: record.ReplyTo, 190 - Created: created, 243 + Did: ownerDid, 244 + Rkey: rkey, 245 + Body: record.Body, 246 + IssueAt: record.Issue, 247 + ReplyTo: record.ReplyTo, 248 + Created: created, 249 + Mentions: mentions, 250 + References: references, 191 251 } 192 252 193 253 return &comment, nil
+25 -43
appview/models/label.go
··· 14 14 "github.com/bluesky-social/indigo/atproto/syntax" 15 15 "github.com/bluesky-social/indigo/xrpc" 16 16 "tangled.org/core/api/tangled" 17 - "tangled.org/core/consts" 18 17 "tangled.org/core/idresolver" 19 18 ) 20 19 ··· 461 460 return result 462 461 } 463 462 464 - var ( 465 - LabelWontfix = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "wontfix") 466 - LabelDuplicate = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "duplicate") 467 - LabelAssignee = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "assignee") 468 - LabelGoodFirstIssue = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "good-first-issue") 469 - LabelDocumentation = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "documentation") 470 - ) 463 + func FetchLabelDefs(r *idresolver.Resolver, aturis []string) ([]LabelDefinition, error) { 464 + var labelDefs []LabelDefinition 465 + ctx := context.Background() 471 466 472 - func DefaultLabelDefs() []string { 473 - return []string{ 474 - LabelWontfix, 475 - LabelDuplicate, 476 - LabelAssignee, 477 - LabelGoodFirstIssue, 478 - LabelDocumentation, 479 - } 480 - } 467 + for _, dl := range aturis { 468 + atUri, err := syntax.ParseATURI(dl) 469 + if err != nil { 470 + return nil, fmt.Errorf("failed to parse AT-URI %s: %v", dl, err) 471 + } 472 + if atUri.Collection() != tangled.LabelDefinitionNSID { 473 + return nil, fmt.Errorf("expected AT-URI pointing %s collection: %s", tangled.LabelDefinitionNSID, atUri) 474 + } 481 475 482 - func FetchDefaultDefs(r *idresolver.Resolver) ([]LabelDefinition, error) { 483 - resolved, err := r.ResolveIdent(context.Background(), consts.TangledDid) 484 - if err != nil { 485 - return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", consts.TangledDid, err) 486 - } 487 - pdsEndpoint := resolved.PDSEndpoint() 488 - if pdsEndpoint == "" { 489 - return nil, fmt.Errorf("no PDS endpoint found for tangled.sh DID %s", consts.TangledDid) 490 - } 491 - client := &xrpc.Client{ 492 - Host: pdsEndpoint, 493 - } 476 + owner, err := r.ResolveIdent(ctx, atUri.Authority().String()) 477 + if err != nil { 478 + return nil, fmt.Errorf("failed to resolve default label owner DID %s: %v", atUri.Authority(), err) 479 + } 494 480 495 - var labelDefs []LabelDefinition 481 + xrpcc := xrpc.Client{ 482 + Host: owner.PDSEndpoint(), 483 + } 496 484 497 - for _, dl := range DefaultLabelDefs() { 498 - atUri := syntax.ATURI(dl) 499 - parsedUri, err := syntax.ParseATURI(string(atUri)) 500 - if err != nil { 501 - return nil, fmt.Errorf("failed to parse AT-URI %s: %v", atUri, err) 502 - } 503 485 record, err := atproto.RepoGetRecord( 504 - context.Background(), 505 - client, 486 + ctx, 487 + &xrpcc, 506 488 "", 507 - parsedUri.Collection().String(), 508 - parsedUri.Authority().String(), 509 - parsedUri.RecordKey().String(), 489 + atUri.Collection().String(), 490 + atUri.Authority().String(), 491 + atUri.RecordKey().String(), 510 492 ) 511 493 if err != nil { 512 494 return nil, fmt.Errorf("failed to get record for %s: %v", atUri, err) ··· 526 508 } 527 509 528 510 labelDef, err := LabelDefinitionFromRecord( 529 - parsedUri.Authority().String(), 530 - parsedUri.RecordKey().String(), 511 + atUri.Authority().String(), 512 + atUri.RecordKey().String(), 531 513 labelRecord, 532 514 ) 533 515 if err != nil {
+60 -1
appview/models/notifications.go
··· 2 2 3 3 import ( 4 4 "time" 5 + 6 + "github.com/bluesky-social/indigo/atproto/syntax" 5 7 ) 6 8 7 9 type NotificationType string ··· 15 17 NotificationTypeFollowed NotificationType = "followed" 16 18 NotificationTypePullMerged NotificationType = "pull_merged" 17 19 NotificationTypeIssueClosed NotificationType = "issue_closed" 20 + NotificationTypeIssueReopen NotificationType = "issue_reopen" 18 21 NotificationTypePullClosed NotificationType = "pull_closed" 22 + NotificationTypePullReopen NotificationType = "pull_reopen" 23 + NotificationTypeUserMentioned NotificationType = "user_mentioned" 19 24 ) 20 25 21 26 type Notification struct { ··· 45 50 return "message-square" 46 51 case NotificationTypeIssueClosed: 47 52 return "ban" 53 + case NotificationTypeIssueReopen: 54 + return "circle-dot" 48 55 case NotificationTypePullCreated: 49 56 return "git-pull-request-create" 50 57 case NotificationTypePullCommented: ··· 53 60 return "git-merge" 54 61 case NotificationTypePullClosed: 55 62 return "git-pull-request-closed" 63 + case NotificationTypePullReopen: 64 + return "git-pull-request-create" 56 65 case NotificationTypeFollowed: 57 66 return "user-plus" 67 + case NotificationTypeUserMentioned: 68 + return "at-sign" 58 69 default: 59 70 return "" 60 71 } ··· 69 80 70 81 type NotificationPreferences struct { 71 82 ID int64 72 - UserDid string 83 + UserDid syntax.DID 73 84 RepoStarred bool 74 85 IssueCreated bool 75 86 IssueCommented bool 76 87 PullCreated bool 77 88 PullCommented bool 78 89 Followed bool 90 + UserMentioned bool 79 91 PullMerged bool 80 92 IssueClosed bool 81 93 EmailNotifications bool 82 94 } 95 + 96 + func (prefs *NotificationPreferences) ShouldNotify(t NotificationType) bool { 97 + switch t { 98 + case NotificationTypeRepoStarred: 99 + return prefs.RepoStarred 100 + case NotificationTypeIssueCreated: 101 + return prefs.IssueCreated 102 + case NotificationTypeIssueCommented: 103 + return prefs.IssueCommented 104 + case NotificationTypeIssueClosed: 105 + return prefs.IssueClosed 106 + case NotificationTypeIssueReopen: 107 + return prefs.IssueCreated // smae pref for now 108 + case NotificationTypePullCreated: 109 + return prefs.PullCreated 110 + case NotificationTypePullCommented: 111 + return prefs.PullCommented 112 + case NotificationTypePullMerged: 113 + return prefs.PullMerged 114 + case NotificationTypePullClosed: 115 + return prefs.PullMerged // same pref for now 116 + case NotificationTypePullReopen: 117 + return prefs.PullCreated // same pref for now 118 + case NotificationTypeFollowed: 119 + return prefs.Followed 120 + case NotificationTypeUserMentioned: 121 + return prefs.UserMentioned 122 + default: 123 + return false 124 + } 125 + } 126 + 127 + func DefaultNotificationPreferences(user syntax.DID) *NotificationPreferences { 128 + return &NotificationPreferences{ 129 + UserDid: user, 130 + RepoStarred: true, 131 + IssueCreated: true, 132 + IssueCommented: true, 133 + PullCreated: true, 134 + PullCommented: true, 135 + Followed: true, 136 + UserMentioned: true, 137 + PullMerged: true, 138 + IssueClosed: true, 139 + EmailNotifications: false, 140 + } 141 + }
+4 -1
appview/models/profile.go
··· 19 19 Links [5]string 20 20 Stats [2]VanityStat 21 21 PinnedRepos [6]syntax.ATURI 22 + Pronouns string 22 23 } 23 24 24 25 func (p Profile) IsLinksEmpty() bool { ··· 110 111 } 111 112 112 113 type ByMonth struct { 114 + Commits int 113 115 RepoEvents []RepoEvent 114 116 IssueEvents IssueEvents 115 117 PullEvents PullEvents ··· 118 120 func (b ByMonth) IsEmpty() bool { 119 121 return len(b.RepoEvents) == 0 && 120 122 len(b.IssueEvents.Items) == 0 && 121 - len(b.PullEvents.Items) == 0 123 + len(b.PullEvents.Items) == 0 && 124 + b.Commits == 0 122 125 } 123 126 124 127 type IssueEvents struct {
+72 -27
appview/models/pull.go
··· 66 66 TargetBranch string 67 67 State PullState 68 68 Submissions []*PullSubmission 69 + Mentions []syntax.DID 70 + References []syntax.ATURI 69 71 70 72 // stacking 71 73 StackId string // nullable string ··· 84 86 func (p Pull) AsRecord() tangled.RepoPull { 85 87 var source *tangled.RepoPull_Source 86 88 if p.PullSource != nil { 87 - s := p.PullSource.AsRecord() 88 - source = &s 89 + source = &tangled.RepoPull_Source{} 90 + source.Branch = p.PullSource.Branch 89 91 source.Sha = p.LatestSha() 92 + if p.PullSource.RepoAt != nil { 93 + s := p.PullSource.RepoAt.String() 94 + source.Repo = &s 95 + } 96 + } 97 + mentions := make([]string, len(p.Mentions)) 98 + for i, did := range p.Mentions { 99 + mentions[i] = string(did) 100 + } 101 + references := make([]string, len(p.References)) 102 + for i, uri := range p.References { 103 + references[i] = string(uri) 90 104 } 91 105 92 106 record := tangled.RepoPull{ 93 - Title: p.Title, 94 - Body: &p.Body, 95 - CreatedAt: p.Created.Format(time.RFC3339), 107 + Title: p.Title, 108 + Body: &p.Body, 109 + Mentions: mentions, 110 + References: references, 111 + CreatedAt: p.Created.Format(time.RFC3339), 96 112 Target: &tangled.RepoPull_Target{ 97 113 Repo: p.RepoAt.String(), 98 114 Branch: p.TargetBranch, ··· 111 127 Repo *Repo 112 128 } 113 129 114 - func (p PullSource) AsRecord() tangled.RepoPull_Source { 115 - var repoAt *string 116 - if p.RepoAt != nil { 117 - s := p.RepoAt.String() 118 - repoAt = &s 119 - } 120 - record := tangled.RepoPull_Source{ 121 - Branch: p.Branch, 122 - Repo: repoAt, 123 - } 124 - return record 125 - } 126 - 127 130 type PullSubmission struct { 128 131 // ids 129 132 ID int ··· 134 137 // content 135 138 RoundNumber int 136 139 Patch string 140 + Combined string 137 141 Comments []PullComment 138 142 SourceRev string // include the rev that was used to create this submission: only for branch/fork PRs 139 143 ··· 154 158 155 159 // content 156 160 Body string 161 + 162 + // meta 163 + Mentions []syntax.DID 164 + References []syntax.ATURI 157 165 158 166 // meta 159 167 Created time.Time 160 168 } 161 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 194 + } 195 + 196 + func (p *Pull) LatestSubmission() *PullSubmission { 197 + return p.Submissions[p.LastRoundNumber()] 198 + } 199 + 162 200 func (p *Pull) LatestPatch() string { 163 - latestSubmission := p.Submissions[p.LastRoundNumber()] 164 - return latestSubmission.Patch 201 + return p.LatestSubmission().Patch 165 202 } 166 203 167 204 func (p *Pull) LatestSha() string { 168 - latestSubmission := p.Submissions[p.LastRoundNumber()] 169 - return latestSubmission.SourceRev 205 + return p.LatestSubmission().SourceRev 170 206 } 171 207 172 - func (p *Pull) PullAt() syntax.ATURI { 208 + func (p *Pull) AtUri() syntax.ATURI { 173 209 return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.OwnerDid, tangled.RepoPullNSID, p.Rkey)) 174 - } 175 - 176 - func (p *Pull) LastRoundNumber() int { 177 - return len(p.Submissions) - 1 178 210 } 179 211 180 212 func (p *Pull) IsPatchBased() bool { ··· 263 295 return participants 264 296 } 265 297 298 + func (s PullSubmission) CombinedPatch() string { 299 + if s.Combined == "" { 300 + return s.Patch 301 + } 302 + 303 + return s.Combined 304 + } 305 + 266 306 type Stack []*Pull 267 307 268 308 // position of this pull in the stack ··· 350 390 351 391 return mergeable 352 392 } 393 + 394 + type BranchDeleteStatus struct { 395 + Repo *Repo 396 + Branch string 397 + }
+49
appview/models/reference.go
··· 1 + package models 2 + 3 + import "fmt" 4 + 5 + type RefKind int 6 + 7 + const ( 8 + RefKindIssue RefKind = iota 9 + RefKindPull 10 + ) 11 + 12 + func (k RefKind) String() string { 13 + if k == RefKindIssue { 14 + return "issues" 15 + } else { 16 + return "pulls" 17 + } 18 + } 19 + 20 + // /@alice.com/cool-proj/issues/123 21 + // /@alice.com/cool-proj/issues/123#comment-321 22 + type ReferenceLink struct { 23 + Handle string 24 + Repo string 25 + Kind RefKind 26 + SubjectId int 27 + CommentId *int 28 + } 29 + 30 + func (l ReferenceLink) String() string { 31 + comment := "" 32 + if l.CommentId != nil { 33 + comment = fmt.Sprintf("#comment-%d", *l.CommentId) 34 + } 35 + return fmt.Sprintf("/%s/%s/%s/%d%s", 36 + l.Handle, 37 + l.Repo, 38 + l.Kind.String(), 39 + l.SubjectId, 40 + comment, 41 + ) 42 + } 43 + 44 + type RichReferenceLink struct { 45 + ReferenceLink 46 + Title string 47 + // reusing PullState for both issue & PR 48 + State PullState 49 + }
+61 -1
appview/models/repo.go
··· 2 2 3 3 import ( 4 4 "fmt" 5 + "strings" 5 6 "time" 6 7 7 8 "github.com/bluesky-social/indigo/atproto/syntax" ··· 17 18 Rkey string 18 19 Created time.Time 19 20 Description string 21 + Website string 22 + Topics []string 20 23 Spindle string 21 24 Labels []string 22 25 ··· 28 31 } 29 32 30 33 func (r *Repo) AsRecord() tangled.Repo { 31 - var source, spindle, description *string 34 + var source, spindle, description, website *string 32 35 33 36 if r.Source != "" { 34 37 source = &r.Source ··· 42 45 description = &r.Description 43 46 } 44 47 48 + if r.Website != "" { 49 + website = &r.Website 50 + } 51 + 45 52 return tangled.Repo{ 46 53 Knot: r.Knot, 47 54 Name: r.Name, 48 55 Description: description, 56 + Website: website, 57 + Topics: r.Topics, 49 58 CreatedAt: r.Created.Format(time.RFC3339), 50 59 Source: source, 51 60 Spindle: spindle, ··· 60 69 func (r Repo) DidSlashRepo() string { 61 70 p, _ := securejoin.SecureJoin(r.Did, r.Name) 62 71 return p 72 + } 73 + 74 + func (r Repo) TopicStr() string { 75 + return strings.Join(r.Topics, " ") 63 76 } 64 77 65 78 type RepoStats struct { ··· 91 104 Repo *Repo 92 105 Issues []Issue 93 106 } 107 + 108 + type BlobContentType int 109 + 110 + const ( 111 + BlobContentTypeCode BlobContentType = iota 112 + BlobContentTypeMarkup 113 + BlobContentTypeImage 114 + BlobContentTypeSvg 115 + BlobContentTypeVideo 116 + BlobContentTypeSubmodule 117 + ) 118 + 119 + func (ty BlobContentType) IsCode() bool { return ty == BlobContentTypeCode } 120 + func (ty BlobContentType) IsMarkup() bool { return ty == BlobContentTypeMarkup } 121 + func (ty BlobContentType) IsImage() bool { return ty == BlobContentTypeImage } 122 + func (ty BlobContentType) IsSvg() bool { return ty == BlobContentTypeSvg } 123 + func (ty BlobContentType) IsVideo() bool { return ty == BlobContentTypeVideo } 124 + func (ty BlobContentType) IsSubmodule() bool { return ty == BlobContentTypeSubmodule } 125 + 126 + type BlobView struct { 127 + HasTextView bool // can show as code/text 128 + HasRenderedView bool // can show rendered (markup/image/video/submodule) 129 + HasRawView bool // can download raw (everything except submodule) 130 + 131 + // current display mode 132 + ShowingRendered bool // currently in rendered mode 133 + ShowingText bool // currently in text/code mode 134 + 135 + // content type flags 136 + ContentType BlobContentType 137 + 138 + // Content data 139 + Contents string 140 + ContentSrc string // URL for media files 141 + Lines int 142 + SizeHint uint64 143 + } 144 + 145 + // if both views are available, then show a toggle between them 146 + func (b BlobView) ShowToggle() bool { 147 + return b.HasTextView && b.HasRenderedView 148 + } 149 + 150 + func (b BlobView) IsUnsupported() bool { 151 + // no view available, only raw 152 + return !(b.HasRenderedView || b.HasTextView) 153 + }
+31
appview/models/search.go
··· 1 + package models 2 + 3 + import "tangled.org/core/appview/pagination" 4 + 5 + type IssueSearchOptions struct { 6 + Keyword string 7 + RepoAt string 8 + IsOpen bool 9 + 10 + Page pagination.Page 11 + } 12 + 13 + type PullSearchOptions struct { 14 + Keyword string 15 + RepoAt string 16 + State PullState 17 + 18 + Page pagination.Page 19 + } 20 + 21 + // func (so *SearchOptions) ToFilters() []filter { 22 + // var filters []filter 23 + // if so.IsOpen != nil { 24 + // openValue := 0 25 + // if *so.IsOpen { 26 + // openValue = 1 27 + // } 28 + // filters = append(filters, FilterEq("open", openValue)) 29 + // } 30 + // return filters 31 + // }
+14 -5
appview/models/star.go
··· 7 7 ) 8 8 9 9 type Star struct { 10 - StarredByDid string 11 - RepoAt syntax.ATURI 12 - Created time.Time 13 - Rkey string 10 + Did string 11 + RepoAt syntax.ATURI 12 + Created time.Time 13 + Rkey string 14 + } 14 15 15 - // optionally, populate this when querying for reverse mappings 16 + // RepoStar is used for reverse mapping to repos 17 + type RepoStar struct { 18 + Star 16 19 Repo *Repo 17 20 } 21 + 22 + // StringStar is used for reverse mapping to strings 23 + type StringStar struct { 24 + Star 25 + String *String 26 + }
+1 -1
appview/models/string.go
··· 22 22 Edited *time.Time 23 23 } 24 24 25 - func (s *String) StringAt() syntax.ATURI { 25 + func (s *String) AtUri() syntax.ATURI { 26 26 return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", s.Did, tangled.StringNSID, s.Rkey)) 27 27 } 28 28
+1 -1
appview/models/timeline.go
··· 5 5 type TimelineEvent struct { 6 6 *Repo 7 7 *Follow 8 - *Star 8 + *RepoStar 9 9 10 10 EventAt time.Time 11 11
+24 -24
appview/notifications/notifications.go
··· 1 1 package notifications 2 2 3 3 import ( 4 - "log" 4 + "log/slog" 5 5 "net/http" 6 6 "strconv" 7 7 ··· 11 11 "tangled.org/core/appview/oauth" 12 12 "tangled.org/core/appview/pages" 13 13 "tangled.org/core/appview/pagination" 14 + "tangled.org/core/orm" 14 15 ) 15 16 16 17 type Notifications struct { 17 - db *db.DB 18 - oauth *oauth.OAuth 19 - pages *pages.Pages 18 + db *db.DB 19 + oauth *oauth.OAuth 20 + pages *pages.Pages 21 + logger *slog.Logger 20 22 } 21 23 22 - func New(database *db.DB, oauthHandler *oauth.OAuth, pagesHandler *pages.Pages) *Notifications { 24 + func New(database *db.DB, oauthHandler *oauth.OAuth, pagesHandler *pages.Pages, logger *slog.Logger) *Notifications { 23 25 return &Notifications{ 24 - db: database, 25 - oauth: oauthHandler, 26 - pages: pagesHandler, 26 + db: database, 27 + oauth: oauthHandler, 28 + pages: pagesHandler, 29 + logger: logger, 27 30 } 28 31 } 29 32 ··· 44 47 } 45 48 46 49 func (n *Notifications) notificationsPage(w http.ResponseWriter, r *http.Request) { 50 + l := n.logger.With("handler", "notificationsPage") 47 51 user := n.oauth.GetUser(r) 48 52 49 - page, ok := r.Context().Value("page").(pagination.Page) 50 - if !ok { 51 - log.Println("failed to get page") 52 - page = pagination.FirstPage() 53 - } 53 + page := pagination.FromContext(r.Context()) 54 54 55 55 total, err := db.CountNotifications( 56 56 n.db, 57 - db.FilterEq("recipient_did", user.Did), 57 + orm.FilterEq("recipient_did", user.Did), 58 58 ) 59 59 if err != nil { 60 - log.Println("failed to get total notifications:", err) 60 + l.Error("failed to get total notifications", "err", err) 61 61 n.pages.Error500(w) 62 62 return 63 63 } ··· 65 65 notifications, err := db.GetNotificationsWithEntities( 66 66 n.db, 67 67 page, 68 - db.FilterEq("recipient_did", user.Did), 68 + orm.FilterEq("recipient_did", user.Did), 69 69 ) 70 70 if err != nil { 71 - log.Println("failed to get notifications:", err) 71 + l.Error("failed to get notifications", "err", err) 72 72 n.pages.Error500(w) 73 73 return 74 74 } 75 75 76 - err = n.db.MarkAllNotificationsRead(r.Context(), user.Did) 76 + err = db.MarkAllNotificationsRead(n.db, user.Did) 77 77 if err != nil { 78 - log.Println("failed to mark notifications as read:", err) 78 + l.Error("failed to mark notifications as read", "err", err) 79 79 } 80 80 81 81 unreadCount := 0 ··· 97 97 98 98 count, err := db.CountNotifications( 99 99 n.db, 100 - db.FilterEq("recipient_did", user.Did), 101 - db.FilterEq("read", 0), 100 + orm.FilterEq("recipient_did", user.Did), 101 + orm.FilterEq("read", 0), 102 102 ) 103 103 if err != nil { 104 104 http.Error(w, "Failed to get unread count", http.StatusInternalServerError) ··· 125 125 return 126 126 } 127 127 128 - err = n.db.MarkNotificationRead(r.Context(), notificationID, userDid) 128 + err = db.MarkNotificationRead(n.db, notificationID, userDid) 129 129 if err != nil { 130 130 http.Error(w, "Failed to mark notification as read", http.StatusInternalServerError) 131 131 return ··· 137 137 func (n *Notifications) markAllRead(w http.ResponseWriter, r *http.Request) { 138 138 userDid := n.oauth.GetDid(r) 139 139 140 - err := n.db.MarkAllNotificationsRead(r.Context(), userDid) 140 + err := db.MarkAllNotificationsRead(n.db, userDid) 141 141 if err != nil { 142 142 http.Error(w, "Failed to mark all notifications as read", http.StatusInternalServerError) 143 143 return ··· 156 156 return 157 157 } 158 158 159 - err = n.db.DeleteNotification(r.Context(), notificationID, userDid) 159 + err = db.DeleteNotification(n.db, notificationID, userDid) 160 160 if err != nil { 161 161 http.Error(w, "Failed to delete notification", http.StatusInternalServerError) 162 162 return
+337 -261
appview/notify/db/db.go
··· 3 3 import ( 4 4 "context" 5 5 "log" 6 + "slices" 6 7 8 + "github.com/bluesky-social/indigo/atproto/syntax" 9 + "tangled.org/core/api/tangled" 7 10 "tangled.org/core/appview/db" 8 11 "tangled.org/core/appview/models" 9 12 "tangled.org/core/appview/notify" 10 13 "tangled.org/core/idresolver" 14 + "tangled.org/core/orm" 15 + "tangled.org/core/sets" 16 + ) 17 + 18 + const ( 19 + maxMentions = 8 11 20 ) 12 21 13 22 type databaseNotifier struct { ··· 29 38 } 30 39 31 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 + } 32 45 var err error 33 - repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(star.RepoAt))) 46 + repo, err := db.GetRepo(n.db, orm.FilterEq("at_uri", string(star.RepoAt))) 34 47 if err != nil { 35 48 log.Printf("NewStar: failed to get repos: %v", err) 36 49 return 37 50 } 38 51 39 - // don't notify yourself 40 - if repo.Did == star.StarredByDid { 41 - return 42 - } 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() 57 + repoId := &repo.Id 58 + var issueId *int64 59 + var pullId *int64 43 60 44 - // check if user wants these notifications 45 - prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did) 46 - if err != nil { 47 - log.Printf("NewStar: failed to get notification preferences for %s: %v", repo.Did, err) 48 - return 49 - } 50 - if !prefs.RepoStarred { 51 - return 52 - } 53 - 54 - notification := &models.Notification{ 55 - RecipientDid: repo.Did, 56 - ActorDid: star.StarredByDid, 57 - Type: models.NotificationTypeRepoStarred, 58 - EntityType: "repo", 59 - EntityId: string(star.RepoAt), 60 - RepoId: &repo.Id, 61 - } 62 - err = n.db.CreateNotification(ctx, notification) 63 - if err != nil { 64 - log.Printf("NewStar: failed to create notification: %v", err) 65 - return 66 - } 61 + n.notifyEvent( 62 + actorDid, 63 + recipients, 64 + eventType, 65 + entityType, 66 + entityId, 67 + repoId, 68 + issueId, 69 + pullId, 70 + ) 67 71 } 68 72 69 73 func (n *databaseNotifier) DeleteStar(ctx context.Context, star *models.Star) { 70 74 // no-op 71 75 } 72 76 73 - func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue) { 74 - repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt))) 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())) 75 79 if err != nil { 76 - log.Printf("NewIssue: failed to get repos: %v", err) 77 - return 78 - } 79 - 80 - if repo.Did == issue.Did { 80 + log.Printf("failed to fetch collaborators: %v", err) 81 81 return 82 82 } 83 83 84 - prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did) 85 - if err != nil { 86 - log.Printf("NewIssue: failed to get notification preferences for %s: %v", repo.Did, err) 87 - return 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) 88 91 } 89 - if !prefs.IssueCreated { 90 - return 92 + for _, m := range mentions { 93 + recipients.Remove(m) 91 94 } 92 95 93 - notification := &models.Notification{ 94 - RecipientDid: repo.Did, 95 - ActorDid: issue.Did, 96 - Type: models.NotificationTypeIssueCreated, 97 - EntityType: "issue", 98 - EntityId: string(issue.AtUri()), 99 - RepoId: &repo.Id, 100 - IssueId: &issue.Id, 101 - } 96 + actorDid := syntax.DID(issue.Did) 97 + entityType := "issue" 98 + entityId := issue.AtUri().String() 99 + repoId := &issue.Repo.Id 100 + issueId := &issue.Id 101 + var pullId *int64 102 102 103 - err = n.db.CreateNotification(ctx, notification) 104 - if err != nil { 105 - log.Printf("NewIssue: failed to create notification: %v", err) 106 - return 107 - } 103 + n.notifyEvent( 104 + actorDid, 105 + recipients, 106 + models.NotificationTypeIssueCreated, 107 + entityType, 108 + entityId, 109 + repoId, 110 + issueId, 111 + pullId, 112 + ) 113 + n.notifyEvent( 114 + actorDid, 115 + sets.Collect(slices.Values(mentions)), 116 + models.NotificationTypeUserMentioned, 117 + entityType, 118 + entityId, 119 + repoId, 120 + issueId, 121 + pullId, 122 + ) 108 123 } 109 124 110 - func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) { 111 - issues, err := db.GetIssues(n.db, db.FilterEq("at_uri", comment.IssueAt)) 125 + func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { 126 + issues, err := db.GetIssues(n.db, orm.FilterEq("at_uri", comment.IssueAt)) 112 127 if err != nil { 113 128 log.Printf("NewIssueComment: failed to get issues: %v", err) 114 129 return ··· 119 134 } 120 135 issue := issues[0] 121 136 122 - repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt))) 123 - if err != nil { 124 - log.Printf("NewIssueComment: failed to get repos: %v", err) 125 - return 126 - } 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)) 127 143 128 - recipients := make(map[string]bool) 144 + if comment.IsReply() { 145 + // if this comment is a reply, then notify everybody in that thread 146 + parentAtUri := *comment.ReplyTo 129 147 130 - // notify issue author (if not the commenter) 131 - if issue.Did != comment.Did { 132 - prefs, err := n.db.GetNotificationPreferences(ctx, issue.Did) 133 - if err == nil && prefs.IssueCommented { 134 - recipients[issue.Did] = true 135 - } else if err != nil { 136 - log.Printf("NewIssueComment: failed to get preferences for issue author %s: %v", issue.Did, err) 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 + } 137 155 } 156 + } else { 157 + // not a reply, notify just the issue author 158 + recipients.Insert(syntax.DID(issue.Did)) 138 159 } 139 160 140 - // notify repo owner (if not the commenter and not already added) 141 - if repo.Did != comment.Did && repo.Did != issue.Did { 142 - prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did) 143 - if err == nil && prefs.IssueCommented { 144 - recipients[repo.Did] = true 145 - } else if err != nil { 146 - log.Printf("NewIssueComment: failed to get preferences for repo owner %s: %v", repo.Did, err) 147 - } 161 + for _, m := range mentions { 162 + recipients.Remove(m) 148 163 } 149 164 150 - // create notifications for all recipients 151 - for recipientDid := range recipients { 152 - notification := &models.Notification{ 153 - RecipientDid: recipientDid, 154 - ActorDid: comment.Did, 155 - Type: models.NotificationTypeIssueCommented, 156 - EntityType: "issue", 157 - EntityId: string(issue.AtUri()), 158 - RepoId: &repo.Id, 159 - IssueId: &issue.Id, 160 - } 165 + actorDid := syntax.DID(comment.Did) 166 + entityType := "issue" 167 + entityId := issue.AtUri().String() 168 + repoId := &issue.Repo.Id 169 + issueId := &issue.Id 170 + var pullId *int64 171 + 172 + n.notifyEvent( 173 + actorDid, 174 + recipients, 175 + models.NotificationTypeIssueCommented, 176 + entityType, 177 + entityId, 178 + repoId, 179 + issueId, 180 + pullId, 181 + ) 182 + n.notifyEvent( 183 + actorDid, 184 + sets.Collect(slices.Values(mentions)), 185 + models.NotificationTypeUserMentioned, 186 + entityType, 187 + entityId, 188 + repoId, 189 + issueId, 190 + pullId, 191 + ) 192 + } 161 193 162 - err = n.db.CreateNotification(ctx, notification) 163 - if err != nil { 164 - log.Printf("NewIssueComment: failed to create notification for %s: %v", recipientDid, err) 165 - } 166 - } 194 + func (n *databaseNotifier) DeleteIssue(ctx context.Context, issue *models.Issue) { 195 + // no-op for now 167 196 } 168 197 169 198 func (n *databaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) { 170 - prefs, err := n.db.GetNotificationPreferences(ctx, follow.SubjectDid) 171 - if err != nil { 172 - log.Printf("NewFollow: failed to get notification preferences for %s: %v", follow.SubjectDid, err) 173 - return 174 - } 175 - if !prefs.Followed { 176 - return 177 - } 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 204 + var repoId, issueId, pullId *int64 178 205 179 - notification := &models.Notification{ 180 - RecipientDid: follow.SubjectDid, 181 - ActorDid: follow.UserDid, 182 - Type: models.NotificationTypeFollowed, 183 - EntityType: "follow", 184 - EntityId: follow.UserDid, 185 - } 186 - 187 - err = n.db.CreateNotification(ctx, notification) 188 - if err != nil { 189 - log.Printf("NewFollow: failed to create notification: %v", err) 190 - return 191 - } 206 + n.notifyEvent( 207 + actorDid, 208 + recipients, 209 + eventType, 210 + entityType, 211 + entityId, 212 + repoId, 213 + issueId, 214 + pullId, 215 + ) 192 216 } 193 217 194 218 func (n *databaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) { ··· 196 220 } 197 221 198 222 func (n *databaseNotifier) NewPull(ctx context.Context, pull *models.Pull) { 199 - repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt))) 223 + repo, err := db.GetRepo(n.db, orm.FilterEq("at_uri", string(pull.RepoAt))) 200 224 if err != nil { 201 225 log.Printf("NewPull: failed to get repos: %v", err) 202 226 return 203 227 } 204 - 205 - if repo.Did == pull.OwnerDid { 206 - return 207 - } 208 - 209 - prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did) 228 + collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_at", repo.RepoAt())) 210 229 if err != nil { 211 - log.Printf("NewPull: failed to get notification preferences for %s: %v", repo.Did, err) 230 + log.Printf("failed to fetch collaborators: %v", err) 212 231 return 213 232 } 214 - if !prefs.PullCreated { 215 - return 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) 216 240 } 217 241 218 - notification := &models.Notification{ 219 - RecipientDid: repo.Did, 220 - ActorDid: pull.OwnerDid, 221 - Type: models.NotificationTypePullCreated, 222 - EntityType: "pull", 223 - EntityId: string(pull.RepoAt), 224 - RepoId: &repo.Id, 225 - PullId: func() *int64 { id := int64(pull.ID); return &id }(), 226 - } 242 + actorDid := syntax.DID(pull.OwnerDid) 243 + eventType := models.NotificationTypePullCreated 244 + entityType := "pull" 245 + entityId := pull.AtUri().String() 246 + repoId := &repo.Id 247 + var issueId *int64 248 + p := int64(pull.ID) 249 + pullId := &p 227 250 228 - err = n.db.CreateNotification(ctx, notification) 229 - if err != nil { 230 - log.Printf("NewPull: failed to create notification: %v", err) 231 - return 232 - } 251 + n.notifyEvent( 252 + actorDid, 253 + recipients, 254 + eventType, 255 + entityType, 256 + entityId, 257 + repoId, 258 + issueId, 259 + pullId, 260 + ) 233 261 } 234 262 235 - func (n *databaseNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) { 236 - pulls, err := db.GetPulls(n.db, 237 - db.FilterEq("repo_at", comment.RepoAt), 238 - db.FilterEq("pull_id", comment.PullId)) 263 + func (n *databaseNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) { 264 + pull, err := db.GetPull(n.db, 265 + syntax.ATURI(comment.RepoAt), 266 + comment.PullId, 267 + ) 239 268 if err != nil { 240 269 log.Printf("NewPullComment: failed to get pulls: %v", err) 241 270 return 242 271 } 243 - if len(pulls) == 0 { 244 - log.Printf("NewPullComment: no pull found for %s PR %d", comment.RepoAt, comment.PullId) 245 - return 246 - } 247 - pull := pulls[0] 248 272 249 - repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", comment.RepoAt)) 273 + repo, err := db.GetRepo(n.db, orm.FilterEq("at_uri", comment.RepoAt)) 250 274 if err != nil { 251 275 log.Printf("NewPullComment: failed to get repos: %v", err) 252 276 return 253 277 } 254 278 255 - recipients := make(map[string]bool) 256 - 257 - // notify pull request author (if not the commenter) 258 - if pull.OwnerDid != comment.OwnerDid { 259 - prefs, err := n.db.GetNotificationPreferences(ctx, pull.OwnerDid) 260 - if err == nil && prefs.PullCommented { 261 - recipients[pull.OwnerDid] = true 262 - } else if err != nil { 263 - log.Printf("NewPullComment: failed to get preferences for pull author %s: %v", pull.OwnerDid, err) 264 - } 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)) 265 286 } 266 - 267 - // notify repo owner (if not the commenter and not already added) 268 - if repo.Did != comment.OwnerDid && repo.Did != pull.OwnerDid { 269 - prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did) 270 - if err == nil && prefs.PullCommented { 271 - recipients[repo.Did] = true 272 - } else if err != nil { 273 - log.Printf("NewPullComment: failed to get preferences for repo owner %s: %v", repo.Did, err) 274 - } 287 + for _, m := range mentions { 288 + recipients.Remove(m) 275 289 } 276 290 277 - for recipientDid := range recipients { 278 - notification := &models.Notification{ 279 - RecipientDid: recipientDid, 280 - ActorDid: comment.OwnerDid, 281 - Type: models.NotificationTypePullCommented, 282 - EntityType: "pull", 283 - EntityId: comment.RepoAt, 284 - RepoId: &repo.Id, 285 - PullId: func() *int64 { id := int64(pull.ID); return &id }(), 286 - } 291 + actorDid := syntax.DID(comment.OwnerDid) 292 + eventType := models.NotificationTypePullCommented 293 + entityType := "pull" 294 + entityId := pull.AtUri().String() 295 + repoId := &repo.Id 296 + var issueId *int64 297 + p := int64(pull.ID) 298 + pullId := &p 287 299 288 - err = n.db.CreateNotification(ctx, notification) 289 - if err != nil { 290 - log.Printf("NewPullComment: failed to create notification for %s: %v", recipientDid, err) 291 - } 292 - } 300 + n.notifyEvent( 301 + actorDid, 302 + recipients, 303 + eventType, 304 + entityType, 305 + entityId, 306 + repoId, 307 + issueId, 308 + pullId, 309 + ) 310 + n.notifyEvent( 311 + actorDid, 312 + sets.Collect(slices.Values(mentions)), 313 + models.NotificationTypeUserMentioned, 314 + entityType, 315 + entityId, 316 + repoId, 317 + issueId, 318 + pullId, 319 + ) 293 320 } 294 321 295 322 func (n *databaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) { ··· 308 335 // no-op 309 336 } 310 337 311 - func (n *databaseNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) { 312 - // Get repo details 313 - repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt))) 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())) 314 340 if err != nil { 315 - log.Printf("NewIssueClosed: failed to get repos: %v", err) 341 + log.Printf("failed to fetch collaborators: %v", err) 316 342 return 317 343 } 318 344 319 - // Don't notify yourself 320 - if repo.Did == issue.Did { 321 - return 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)) 322 355 } 323 356 324 - // Check if user wants these notifications 325 - prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did) 326 - if err != nil { 327 - log.Printf("NewIssueClosed: failed to get notification preferences for %s: %v", repo.Did, err) 328 - return 329 - } 330 - if !prefs.IssueClosed { 331 - return 332 - } 357 + entityType := "pull" 358 + entityId := issue.AtUri().String() 359 + repoId := &issue.Repo.Id 360 + issueId := &issue.Id 361 + var pullId *int64 362 + var eventType models.NotificationType 333 363 334 - notification := &models.Notification{ 335 - RecipientDid: repo.Did, 336 - ActorDid: issue.Did, 337 - Type: models.NotificationTypeIssueClosed, 338 - EntityType: "issue", 339 - EntityId: string(issue.AtUri()), 340 - RepoId: &repo.Id, 341 - IssueId: &issue.Id, 364 + if issue.Open { 365 + eventType = models.NotificationTypeIssueReopen 366 + } else { 367 + eventType = models.NotificationTypeIssueClosed 342 368 } 343 369 344 - err = n.db.CreateNotification(ctx, notification) 345 - if err != nil { 346 - log.Printf("NewIssueClosed: failed to create notification: %v", err) 347 - return 348 - } 370 + n.notifyEvent( 371 + actor, 372 + recipients, 373 + eventType, 374 + entityType, 375 + entityId, 376 + repoId, 377 + issueId, 378 + pullId, 379 + ) 349 380 } 350 381 351 - func (n *databaseNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) { 382 + func (n *databaseNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) { 352 383 // Get repo details 353 - repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt))) 384 + repo, err := db.GetRepo(n.db, orm.FilterEq("at_uri", string(pull.RepoAt))) 354 385 if err != nil { 355 - log.Printf("NewPullMerged: failed to get repos: %v", err) 386 + log.Printf("NewPullState: failed to get repos: %v", err) 356 387 return 357 388 } 358 389 359 - // Don't notify yourself 360 - if repo.Did == pull.OwnerDid { 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) 361 393 return 362 394 } 363 395 364 - // Check if user wants these notifications 365 - prefs, err := n.db.GetNotificationPreferences(ctx, pull.OwnerDid) 366 - if err != nil { 367 - log.Printf("NewPullMerged: failed to get notification preferences for %s: %v", pull.OwnerDid, err) 368 - return 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) 369 402 } 370 - if !prefs.PullMerged { 371 - return 403 + for _, p := range pull.Participants() { 404 + recipients.Insert(syntax.DID(p)) 372 405 } 373 406 374 - notification := &models.Notification{ 375 - RecipientDid: pull.OwnerDid, 376 - ActorDid: repo.Did, 377 - Type: models.NotificationTypePullMerged, 378 - EntityType: "pull", 379 - EntityId: string(pull.RepoAt), 380 - RepoId: &repo.Id, 381 - PullId: func() *int64 { id := int64(pull.ID); return &id }(), 407 + entityType := "pull" 408 + entityId := pull.AtUri().String() 409 + repoId := &repo.Id 410 + var issueId *int64 411 + var eventType models.NotificationType 412 + switch pull.State { 413 + case models.PullClosed: 414 + eventType = models.NotificationTypePullClosed 415 + case models.PullOpen: 416 + eventType = models.NotificationTypePullReopen 417 + case models.PullMerged: 418 + eventType = models.NotificationTypePullMerged 419 + default: 420 + log.Println("NewPullState: unexpected new PR state:", pull.State) 421 + return 382 422 } 423 + p := int64(pull.ID) 424 + pullId := &p 383 425 384 - err = n.db.CreateNotification(ctx, notification) 385 - if err != nil { 386 - log.Printf("NewPullMerged: failed to create notification: %v", err) 387 - return 388 - } 426 + n.notifyEvent( 427 + actor, 428 + recipients, 429 + eventType, 430 + entityType, 431 + entityId, 432 + repoId, 433 + issueId, 434 + pullId, 435 + ) 389 436 } 390 437 391 - func (n *databaseNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) { 392 - // Get repo details 393 - repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt))) 394 - if err != nil { 395 - log.Printf("NewPullClosed: failed to get repos: %v", err) 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, 444 + repoId *int64, 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 { 396 450 return 397 451 } 398 452 399 - // Don't notify yourself 400 - if repo.Did == pull.OwnerDid { 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 401 461 return 402 462 } 403 463 404 - // Check if user wants these notifications - reuse pull_merged preference for now 405 - prefs, err := n.db.GetNotificationPreferences(ctx, pull.OwnerDid) 464 + // create a transaction for bulk notification storage 465 + tx, err := n.db.Begin() 406 466 if err != nil { 407 - log.Printf("NewPullClosed: failed to get notification preferences for %s: %v", pull.OwnerDid, err) 467 + // failed to start tx 408 468 return 409 469 } 410 - if !prefs.PullMerged { 411 - return 412 - } 470 + defer tx.Rollback() 413 471 414 - notification := &models.Notification{ 415 - RecipientDid: pull.OwnerDid, 416 - ActorDid: repo.Did, 417 - Type: models.NotificationTypePullClosed, 418 - EntityType: "pull", 419 - EntityId: string(pull.RepoAt), 420 - RepoId: &repo.Id, 421 - PullId: func() *int64 { id := int64(pull.ID); return &id }(), 472 + // filter based on preferences 473 + for recipientDid := range recipients.All() { 474 + prefs, ok := prefMap[recipientDid] 475 + if !ok { 476 + prefs = models.DefaultNotificationPreferences(recipientDid) 477 + } 478 + 479 + // skip users who donโ€™t want this type 480 + if !prefs.ShouldNotify(eventType) { 481 + continue 482 + } 483 + 484 + // create notification 485 + notif := &models.Notification{ 486 + RecipientDid: recipientDid.String(), 487 + ActorDid: actorDid.String(), 488 + Type: eventType, 489 + EntityType: entityType, 490 + EntityId: entityId, 491 + RepoId: repoId, 492 + IssueId: issueId, 493 + PullId: pullId, 494 + } 495 + 496 + if err := db.CreateNotification(tx, notif); err != nil { 497 + log.Printf("notifyEvent: failed to create notification for %s: %v", recipientDid, err) 498 + } 422 499 } 423 500 424 - err = n.db.CreateNotification(ctx, notification) 425 - if err != nil { 426 - log.Printf("NewPullClosed: failed to create notification: %v", err) 501 + if err := tx.Commit(); err != nil { 502 + // failed to commit 427 503 return 428 504 } 429 505 }
+56 -59
appview/notify/merged_notifier.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "log/slog" 6 + "reflect" 7 + "sync" 5 8 9 + "github.com/bluesky-social/indigo/atproto/syntax" 6 10 "tangled.org/core/appview/models" 11 + "tangled.org/core/log" 7 12 ) 8 13 9 14 type mergedNotifier struct { 10 15 notifiers []Notifier 16 + logger *slog.Logger 11 17 } 12 18 13 - func NewMergedNotifier(notifiers ...Notifier) Notifier { 14 - return &mergedNotifier{notifiers} 19 + func NewMergedNotifier(notifiers []Notifier, logger *slog.Logger) Notifier { 20 + return &mergedNotifier{notifiers, logger} 15 21 } 16 22 17 23 var _ Notifier = &mergedNotifier{} 18 24 19 - func (m *mergedNotifier) NewRepo(ctx context.Context, repo *models.Repo) { 20 - for _, notifier := range m.notifiers { 21 - notifier.NewRepo(ctx, repo) 25 + // fanout calls the same method on all notifiers concurrently 26 + func (m *mergedNotifier) fanout(method string, ctx context.Context, args ...any) { 27 + ctx = log.IntoContext(ctx, m.logger.With("method", method)) 28 + var wg sync.WaitGroup 29 + for _, n := range m.notifiers { 30 + wg.Add(1) 31 + go func(notifier Notifier) { 32 + defer wg.Done() 33 + v := reflect.ValueOf(notifier).MethodByName(method) 34 + in := make([]reflect.Value, len(args)+1) 35 + in[0] = reflect.ValueOf(ctx) 36 + for i, arg := range args { 37 + in[i+1] = reflect.ValueOf(arg) 38 + } 39 + v.Call(in) 40 + }(n) 22 41 } 23 42 } 24 43 44 + func (m *mergedNotifier) NewRepo(ctx context.Context, repo *models.Repo) { 45 + m.fanout("NewRepo", ctx, repo) 46 + } 47 + 25 48 func (m *mergedNotifier) NewStar(ctx context.Context, star *models.Star) { 26 - for _, notifier := range m.notifiers { 27 - notifier.NewStar(ctx, star) 28 - } 49 + m.fanout("NewStar", ctx, star) 29 50 } 51 + 30 52 func (m *mergedNotifier) DeleteStar(ctx context.Context, star *models.Star) { 31 - for _, notifier := range m.notifiers { 32 - notifier.DeleteStar(ctx, star) 33 - } 53 + m.fanout("DeleteStar", ctx, star) 54 + } 55 + 56 + func (m *mergedNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) { 57 + m.fanout("NewIssue", ctx, issue, mentions) 34 58 } 35 59 36 - func (m *mergedNotifier) NewIssue(ctx context.Context, issue *models.Issue) { 37 - for _, notifier := range m.notifiers { 38 - notifier.NewIssue(ctx, issue) 39 - } 60 + func (m *mergedNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { 61 + m.fanout("NewIssueComment", ctx, comment, mentions) 40 62 } 41 - func (m *mergedNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) { 42 - for _, notifier := range m.notifiers { 43 - notifier.NewIssueComment(ctx, comment) 44 - } 63 + 64 + func (m *mergedNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) { 65 + m.fanout("NewIssueState", ctx, actor, issue) 45 66 } 46 67 47 - func (m *mergedNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) { 48 - for _, notifier := range m.notifiers { 49 - notifier.NewIssueClosed(ctx, issue) 50 - } 68 + func (m *mergedNotifier) DeleteIssue(ctx context.Context, issue *models.Issue) { 69 + m.fanout("DeleteIssue", ctx, issue) 51 70 } 52 71 53 72 func (m *mergedNotifier) NewFollow(ctx context.Context, follow *models.Follow) { 54 - for _, notifier := range m.notifiers { 55 - notifier.NewFollow(ctx, follow) 56 - } 73 + m.fanout("NewFollow", ctx, follow) 57 74 } 75 + 58 76 func (m *mergedNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) { 59 - for _, notifier := range m.notifiers { 60 - notifier.DeleteFollow(ctx, follow) 61 - } 77 + m.fanout("DeleteFollow", ctx, follow) 62 78 } 63 79 64 80 func (m *mergedNotifier) NewPull(ctx context.Context, pull *models.Pull) { 65 - for _, notifier := range m.notifiers { 66 - notifier.NewPull(ctx, pull) 67 - } 68 - } 69 - func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) { 70 - for _, notifier := range m.notifiers { 71 - notifier.NewPullComment(ctx, comment) 72 - } 81 + m.fanout("NewPull", ctx, pull) 73 82 } 74 83 75 - func (m *mergedNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) { 76 - for _, notifier := range m.notifiers { 77 - notifier.NewPullMerged(ctx, pull) 78 - } 84 + func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) { 85 + m.fanout("NewPullComment", ctx, comment, mentions) 79 86 } 80 87 81 - func (m *mergedNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) { 82 - for _, notifier := range m.notifiers { 83 - notifier.NewPullClosed(ctx, pull) 84 - } 88 + func (m *mergedNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) { 89 + m.fanout("NewPullState", ctx, actor, pull) 85 90 } 86 91 87 92 func (m *mergedNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) { 88 - for _, notifier := range m.notifiers { 89 - notifier.UpdateProfile(ctx, profile) 90 - } 93 + m.fanout("UpdateProfile", ctx, profile) 91 94 } 92 95 93 - func (m *mergedNotifier) NewString(ctx context.Context, string *models.String) { 94 - for _, notifier := range m.notifiers { 95 - notifier.NewString(ctx, string) 96 - } 96 + func (m *mergedNotifier) NewString(ctx context.Context, s *models.String) { 97 + m.fanout("NewString", ctx, s) 97 98 } 98 99 99 - func (m *mergedNotifier) EditString(ctx context.Context, string *models.String) { 100 - for _, notifier := range m.notifiers { 101 - notifier.EditString(ctx, string) 102 - } 100 + func (m *mergedNotifier) EditString(ctx context.Context, s *models.String) { 101 + m.fanout("EditString", ctx, s) 103 102 } 104 103 105 104 func (m *mergedNotifier) DeleteString(ctx context.Context, did, rkey string) { 106 - for _, notifier := range m.notifiers { 107 - notifier.DeleteString(ctx, did, rkey) 108 - } 105 + m.fanout("DeleteString", ctx, did, rkey) 109 106 }
+16 -13
appview/notify/notifier.go
··· 3 3 import ( 4 4 "context" 5 5 6 + "github.com/bluesky-social/indigo/atproto/syntax" 6 7 "tangled.org/core/appview/models" 7 8 ) 8 9 ··· 12 13 NewStar(ctx context.Context, star *models.Star) 13 14 DeleteStar(ctx context.Context, star *models.Star) 14 15 15 - NewIssue(ctx context.Context, issue *models.Issue) 16 - NewIssueComment(ctx context.Context, comment *models.IssueComment) 17 - NewIssueClosed(ctx context.Context, issue *models.Issue) 16 + NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) 17 + NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) 18 + NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) 19 + DeleteIssue(ctx context.Context, issue *models.Issue) 18 20 19 21 NewFollow(ctx context.Context, follow *models.Follow) 20 22 DeleteFollow(ctx context.Context, follow *models.Follow) 21 23 22 24 NewPull(ctx context.Context, pull *models.Pull) 23 - NewPullComment(ctx context.Context, comment *models.PullComment) 24 - NewPullMerged(ctx context.Context, pull *models.Pull) 25 - NewPullClosed(ctx context.Context, pull *models.Pull) 25 + NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) 26 + NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) 26 27 27 28 UpdateProfile(ctx context.Context, profile *models.Profile) 28 29 ··· 41 42 func (m *BaseNotifier) NewStar(ctx context.Context, star *models.Star) {} 42 43 func (m *BaseNotifier) DeleteStar(ctx context.Context, star *models.Star) {} 43 44 44 - func (m *BaseNotifier) NewIssue(ctx context.Context, issue *models.Issue) {} 45 - func (m *BaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) {} 46 - func (m *BaseNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) {} 45 + func (m *BaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {} 46 + func (m *BaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { 47 + } 48 + func (m *BaseNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) {} 49 + func (m *BaseNotifier) DeleteIssue(ctx context.Context, issue *models.Issue) {} 47 50 48 51 func (m *BaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) {} 49 52 func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {} 50 53 51 - func (m *BaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {} 52 - func (m *BaseNotifier) NewPullComment(ctx context.Context, models *models.PullComment) {} 53 - func (m *BaseNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) {} 54 - func (m *BaseNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) {} 54 + func (m *BaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {} 55 + func (m *BaseNotifier) NewPullComment(ctx context.Context, models *models.PullComment, mentions []syntax.DID) { 56 + } 57 + func (m *BaseNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) {} 55 58 56 59 func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {} 57 60
+35 -11
appview/notify/posthog/notifier.go
··· 4 4 "context" 5 5 "log" 6 6 7 + "github.com/bluesky-social/indigo/atproto/syntax" 7 8 "github.com/posthog/posthog-go" 8 9 "tangled.org/core/appview/models" 9 10 "tangled.org/core/appview/notify" ··· 36 37 37 38 func (n *posthogNotifier) NewStar(ctx context.Context, star *models.Star) { 38 39 err := n.client.Enqueue(posthog.Capture{ 39 - DistinctId: star.StarredByDid, 40 + DistinctId: star.Did, 40 41 Event: "star", 41 42 Properties: posthog.Properties{"repo_at": star.RepoAt.String()}, 42 43 }) ··· 47 48 48 49 func (n *posthogNotifier) DeleteStar(ctx context.Context, star *models.Star) { 49 50 err := n.client.Enqueue(posthog.Capture{ 50 - DistinctId: star.StarredByDid, 51 + DistinctId: star.Did, 51 52 Event: "unstar", 52 53 Properties: posthog.Properties{"repo_at": star.RepoAt.String()}, 53 54 }) ··· 56 57 } 57 58 } 58 59 59 - func (n *posthogNotifier) NewIssue(ctx context.Context, issue *models.Issue) { 60 + func (n *posthogNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) { 60 61 err := n.client.Enqueue(posthog.Capture{ 61 62 DistinctId: issue.Did, 62 63 Event: "new_issue", 63 64 Properties: posthog.Properties{ 64 65 "repo_at": issue.RepoAt.String(), 65 66 "issue_id": issue.IssueId, 67 + "mentions": mentions, 66 68 }, 67 69 }) 68 70 if err != nil { ··· 84 86 } 85 87 } 86 88 87 - func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) { 89 + func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) { 88 90 err := n.client.Enqueue(posthog.Capture{ 89 91 DistinctId: comment.OwnerDid, 90 92 Event: "new_pull_comment", 91 93 Properties: posthog.Properties{ 92 - "repo_at": comment.RepoAt, 93 - "pull_id": comment.PullId, 94 + "repo_at": comment.RepoAt, 95 + "pull_id": comment.PullId, 96 + "mentions": mentions, 94 97 }, 95 98 }) 96 99 if err != nil { ··· 177 180 } 178 181 } 179 182 180 - func (n *posthogNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) { 183 + func (n *posthogNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { 181 184 err := n.client.Enqueue(posthog.Capture{ 182 185 DistinctId: comment.Did, 183 186 Event: "new_issue_comment", 184 187 Properties: posthog.Properties{ 185 188 "issue_at": comment.IssueAt, 189 + "mentions": mentions, 186 190 }, 187 191 }) 188 192 if err != nil { ··· 190 194 } 191 195 } 192 196 193 - func (n *posthogNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) { 197 + func (n *posthogNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) { 198 + var event string 199 + if issue.Open { 200 + event = "issue_reopen" 201 + } else { 202 + event = "issue_closed" 203 + } 194 204 err := n.client.Enqueue(posthog.Capture{ 195 205 DistinctId: issue.Did, 196 - Event: "issue_closed", 206 + Event: event, 197 207 Properties: posthog.Properties{ 198 208 "repo_at": issue.RepoAt.String(), 209 + "actor": actor, 199 210 "issue_id": issue.IssueId, 200 211 }, 201 212 }) ··· 204 215 } 205 216 } 206 217 207 - func (n *posthogNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) { 218 + func (n *posthogNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) { 219 + var event string 220 + switch pull.State { 221 + case models.PullClosed: 222 + event = "pull_closed" 223 + case models.PullOpen: 224 + event = "pull_reopen" 225 + case models.PullMerged: 226 + event = "pull_merged" 227 + default: 228 + log.Println("posthog: unexpected new PR state:", pull.State) 229 + return 230 + } 208 231 err := n.client.Enqueue(posthog.Capture{ 209 232 DistinctId: pull.OwnerDid, 210 - Event: "pull_merged", 233 + Event: event, 211 234 Properties: posthog.Properties{ 212 235 "repo_at": pull.RepoAt, 213 236 "pull_id": pull.PullId, 237 + "actor": actor, 214 238 }, 215 239 }) 216 240 if err != nil {
+230 -16
appview/oauth/handler.go
··· 1 1 package oauth 2 2 3 3 import ( 4 + "bytes" 5 + "context" 4 6 "encoding/json" 5 - "log" 7 + "errors" 8 + "fmt" 6 9 "net/http" 10 + "slices" 11 + "time" 7 12 13 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 8 14 "github.com/go-chi/chi/v5" 9 - "github.com/lestrrat-go/jwx/v2/jwk" 15 + "github.com/posthog/posthog-go" 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" 10 21 ) 11 22 12 23 func (o *OAuth) Router() http.Handler { ··· 21 32 func (o *OAuth) clientMetadata(w http.ResponseWriter, r *http.Request) { 22 33 doc := o.ClientApp.Config.ClientMetadata() 23 34 doc.JWKSURI = &o.JwksUri 35 + doc.ClientName = &o.ClientName 36 + doc.ClientURI = &o.ClientUri 24 37 25 38 w.Header().Set("Content-Type", "application/json") 26 39 if err := json.NewEncoder(w).Encode(doc); err != nil { ··· 30 43 } 31 44 32 45 func (o *OAuth) jwks(w http.ResponseWriter, r *http.Request) { 33 - jwks := o.Config.OAuth.Jwks 34 - pubKey, err := pubKeyFromJwk(jwks) 35 - if err != nil { 36 - log.Printf("error parsing public key: %v", err) 46 + w.Header().Set("Content-Type", "application/json") 47 + body := o.ClientApp.Config.PublicJWKS() 48 + if err := json.NewEncoder(w).Encode(body); err != nil { 37 49 http.Error(w, err.Error(), http.StatusInternalServerError) 38 50 return 39 51 } 40 - 41 - response := map[string]any{ 42 - "keys": []jwk.Key{pubKey}, 43 - } 44 - 45 - w.Header().Set("Content-Type", "application/json") 46 - w.WriteHeader(http.StatusOK) 47 - json.NewEncoder(w).Encode(response) 48 52 } 49 53 50 54 func (o *OAuth) callback(w http.ResponseWriter, r *http.Request) { 51 55 ctx := r.Context() 56 + l := o.Logger.With("query", r.URL.Query()) 52 57 53 58 sessData, err := o.ClientApp.ProcessCallback(ctx, r.URL.Query()) 54 59 if err != nil { 55 - http.Error(w, err.Error(), http.StatusInternalServerError) 60 + var callbackErr *oauth.AuthRequestCallbackError 61 + if errors.As(err, &callbackErr) { 62 + l.Debug("callback error", "err", callbackErr) 63 + http.Redirect(w, r, fmt.Sprintf("/login?error=%s", callbackErr.ErrorCode), http.StatusFound) 64 + return 65 + } 66 + l.Error("failed to process callback", "err", err) 67 + http.Redirect(w, r, "/login?error=oauth", http.StatusFound) 56 68 return 57 69 } 58 70 59 71 if err := o.SaveSession(w, r, sessData); err != nil { 60 - http.Error(w, err.Error(), http.StatusInternalServerError) 72 + l.Error("failed to save session", "data", sessData, "err", err) 73 + http.Redirect(w, r, "/login?error=session", http.StatusFound) 61 74 return 62 75 } 63 76 77 + o.Logger.Debug("session saved successfully") 78 + go o.addToDefaultKnot(sessData.AccountDID.String()) 79 + go o.addToDefaultSpindle(sessData.AccountDID.String()) 80 + 81 + if !o.Config.Core.Dev { 82 + err = o.Posthog.Enqueue(posthog.Capture{ 83 + DistinctId: sessData.AccountDID.String(), 84 + Event: "signin", 85 + }) 86 + if err != nil { 87 + o.Logger.Error("failed to enqueue posthog event", "err", err) 88 + } 89 + } 90 + 64 91 http.Redirect(w, r, "/", http.StatusFound) 65 92 } 93 + 94 + func (o *OAuth) addToDefaultSpindle(did string) { 95 + l := o.Logger.With("subject", did) 96 + 97 + // use the tangled.sh app password to get an accessJwt 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) 106 + return 107 + } 108 + 109 + if len(spindleMembers) != 0 { 110 + l.Warn("already a member of the default spindle") 111 + return 112 + } 113 + 114 + l.Debug("adding to default spindle") 115 + session, err := o.createAppPasswordSession(o.Config.Core.AppPassword, consts.TangledDid) 116 + if err != nil { 117 + l.Error("failed to create session", "err", err) 118 + return 119 + } 120 + 121 + record := tangled.SpindleMember{ 122 + LexiconTypeID: "sh.tangled.spindle.member", 123 + Subject: did, 124 + Instance: consts.DefaultSpindle, 125 + CreatedAt: time.Now().Format(time.RFC3339), 126 + } 127 + 128 + if err := session.putRecord(record, tangled.SpindleMemberNSID); err != nil { 129 + l.Error("failed to add to default spindle", "err", err) 130 + return 131 + } 132 + 133 + l.Debug("successfully added to default spindle", "did", did) 134 + } 135 + 136 + func (o *OAuth) addToDefaultKnot(did string) { 137 + l := o.Logger.With("subject", did) 138 + 139 + // use the tangled.sh app password to get an accessJwt 140 + // and create an sh.tangled.spindle.member record with that 141 + 142 + allKnots, err := o.Enforcer.GetKnotsForUser(did) 143 + if err != nil { 144 + l.Error("failed to get knot members for did", "err", err) 145 + return 146 + } 147 + 148 + if slices.Contains(allKnots, consts.DefaultKnot) { 149 + l.Warn("already a member of the default knot") 150 + return 151 + } 152 + 153 + l.Debug("addings to default knot") 154 + session, err := o.createAppPasswordSession(o.Config.Core.TmpAltAppPassword, consts.IcyDid) 155 + if err != nil { 156 + l.Error("failed to create session", "err", err) 157 + return 158 + } 159 + 160 + record := tangled.KnotMember{ 161 + LexiconTypeID: "sh.tangled.knot.member", 162 + Subject: did, 163 + Domain: consts.DefaultKnot, 164 + CreatedAt: time.Now().Format(time.RFC3339), 165 + } 166 + 167 + if err := session.putRecord(record, tangled.KnotMemberNSID); err != nil { 168 + l.Error("failed to add to default knot", "err", err) 169 + return 170 + } 171 + 172 + if err := o.Enforcer.AddKnotMember(consts.DefaultKnot, did); err != nil { 173 + l.Error("failed to set up enforcer rules", "err", err) 174 + return 175 + } 176 + 177 + l.Debug("successfully addeds to default Knot") 178 + } 179 + 180 + // create a session using apppasswords 181 + type session struct { 182 + AccessJwt string `json:"accessJwt"` 183 + PdsEndpoint string 184 + Did string 185 + } 186 + 187 + func (o *OAuth) createAppPasswordSession(appPassword, did string) (*session, error) { 188 + if appPassword == "" { 189 + return nil, fmt.Errorf("no app password configured, skipping member addition") 190 + } 191 + 192 + resolved, err := o.IdResolver.ResolveIdent(context.Background(), did) 193 + if err != nil { 194 + return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", did, err) 195 + } 196 + 197 + pdsEndpoint := resolved.PDSEndpoint() 198 + if pdsEndpoint == "" { 199 + return nil, fmt.Errorf("no PDS endpoint found for tangled.sh DID %s", did) 200 + } 201 + 202 + sessionPayload := map[string]string{ 203 + "identifier": did, 204 + "password": appPassword, 205 + } 206 + sessionBytes, err := json.Marshal(sessionPayload) 207 + if err != nil { 208 + return nil, fmt.Errorf("failed to marshal session payload: %v", err) 209 + } 210 + 211 + sessionURL := pdsEndpoint + "/xrpc/com.atproto.server.createSession" 212 + sessionReq, err := http.NewRequestWithContext(context.Background(), "POST", sessionURL, bytes.NewBuffer(sessionBytes)) 213 + if err != nil { 214 + return nil, fmt.Errorf("failed to create session request: %v", err) 215 + } 216 + sessionReq.Header.Set("Content-Type", "application/json") 217 + 218 + client := &http.Client{Timeout: 30 * time.Second} 219 + sessionResp, err := client.Do(sessionReq) 220 + if err != nil { 221 + return nil, fmt.Errorf("failed to create session: %v", err) 222 + } 223 + defer sessionResp.Body.Close() 224 + 225 + if sessionResp.StatusCode != http.StatusOK { 226 + return nil, fmt.Errorf("failed to create session: HTTP %d", sessionResp.StatusCode) 227 + } 228 + 229 + var session session 230 + if err := json.NewDecoder(sessionResp.Body).Decode(&session); err != nil { 231 + return nil, fmt.Errorf("failed to decode session response: %v", err) 232 + } 233 + 234 + session.PdsEndpoint = pdsEndpoint 235 + session.Did = did 236 + 237 + return &session, nil 238 + } 239 + 240 + func (s *session) putRecord(record any, collection string) error { 241 + recordBytes, err := json.Marshal(record) 242 + if err != nil { 243 + return fmt.Errorf("failed to marshal knot member record: %w", err) 244 + } 245 + 246 + payload := map[string]any{ 247 + "repo": s.Did, 248 + "collection": collection, 249 + "rkey": tid.TID(), 250 + "record": json.RawMessage(recordBytes), 251 + } 252 + 253 + payloadBytes, err := json.Marshal(payload) 254 + if err != nil { 255 + return fmt.Errorf("failed to marshal request payload: %w", err) 256 + } 257 + 258 + url := s.PdsEndpoint + "/xrpc/com.atproto.repo.putRecord" 259 + req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(payloadBytes)) 260 + if err != nil { 261 + return fmt.Errorf("failed to create HTTP request: %w", err) 262 + } 263 + 264 + req.Header.Set("Content-Type", "application/json") 265 + req.Header.Set("Authorization", "Bearer "+s.AccessJwt) 266 + 267 + client := &http.Client{Timeout: 30 * time.Second} 268 + resp, err := client.Do(req) 269 + if err != nil { 270 + return fmt.Errorf("failed to add user to default service: %w", err) 271 + } 272 + defer resp.Body.Close() 273 + 274 + if resp.StatusCode != http.StatusOK { 275 + return fmt.Errorf("failed to add user to default service: HTTP %d", resp.StatusCode) 276 + } 277 + 278 + return nil 279 + }
+75 -34
appview/oauth/oauth.go
··· 3 3 import ( 4 4 "errors" 5 5 "fmt" 6 + "log/slog" 6 7 "net/http" 7 8 "time" 8 9 9 10 comatproto "github.com/bluesky-social/indigo/api/atproto" 10 11 "github.com/bluesky-social/indigo/atproto/auth/oauth" 11 12 atpclient "github.com/bluesky-social/indigo/atproto/client" 13 + atcrypto "github.com/bluesky-social/indigo/atproto/crypto" 12 14 "github.com/bluesky-social/indigo/atproto/syntax" 13 15 xrpc "github.com/bluesky-social/indigo/xrpc" 14 16 "github.com/gorilla/sessions" 15 - "github.com/lestrrat-go/jwx/v2/jwk" 17 + "github.com/posthog/posthog-go" 16 18 "tangled.org/core/appview/config" 19 + "tangled.org/core/appview/db" 20 + "tangled.org/core/idresolver" 21 + "tangled.org/core/rbac" 17 22 ) 18 23 19 - func New(config *config.Config) (*OAuth, error) { 24 + type OAuth struct { 25 + ClientApp *oauth.ClientApp 26 + SessStore *sessions.CookieStore 27 + Config *config.Config 28 + JwksUri string 29 + ClientName string 30 + ClientUri string 31 + Posthog posthog.Client 32 + Db *db.DB 33 + Enforcer *rbac.Enforcer 34 + IdResolver *idresolver.Resolver 35 + Logger *slog.Logger 36 + } 20 37 38 + func New(config *config.Config, ph posthog.Client, db *db.DB, enforcer *rbac.Enforcer, res *idresolver.Resolver, logger *slog.Logger) (*OAuth, error) { 21 39 var oauthConfig oauth.ClientConfig 22 40 var clientUri string 23 - 24 41 if config.Core.Dev { 25 42 clientUri = "http://127.0.0.1:3000" 26 43 callbackUri := clientUri + "/oauth/callback" ··· 32 49 oauthConfig = oauth.NewPublicConfig(clientId, callbackUri, []string{"atproto", "transition:generic"}) 33 50 } 34 51 52 + // configure client secret 53 + priv, err := atcrypto.ParsePrivateMultibase(config.OAuth.ClientSecret) 54 + if err != nil { 55 + return nil, err 56 + } 57 + if err := oauthConfig.SetClientSecret(priv, config.OAuth.ClientKid); err != nil { 58 + return nil, err 59 + } 60 + 35 61 jwksUri := clientUri + "/oauth/jwks.json" 36 62 37 - authStore, err := NewRedisStore(config.Redis.ToURL()) 63 + authStore, err := NewRedisStore(&RedisStoreConfig{ 64 + RedisURL: config.Redis.ToURL(), 65 + SessionExpiryDuration: time.Hour * 24 * 90, 66 + SessionInactivityDuration: time.Hour * 24 * 14, 67 + AuthRequestExpiryDuration: time.Minute * 30, 68 + }) 38 69 if err != nil { 39 70 return nil, err 40 71 } 41 72 42 73 sessStore := sessions.NewCookieStore([]byte(config.Core.CookieSecret)) 43 74 75 + clientApp := oauth.NewClientApp(&oauthConfig, authStore) 76 + clientApp.Dir = res.Directory() 77 + // allow non-public transports in dev mode 78 + if config.Core.Dev { 79 + clientApp.Resolver.Client.Transport = http.DefaultTransport 80 + } 81 + 82 + clientName := config.Core.AppviewName 83 + 84 + logger.Info("oauth setup successfully", "IsConfidential", clientApp.Config.IsConfidential()) 44 85 return &OAuth{ 45 - ClientApp: oauth.NewClientApp(&oauthConfig, authStore), 46 - Config: config, 47 - SessStore: sessStore, 48 - JwksUri: jwksUri, 86 + ClientApp: clientApp, 87 + Config: config, 88 + SessStore: sessStore, 89 + JwksUri: jwksUri, 90 + ClientName: clientName, 91 + ClientUri: clientUri, 92 + Posthog: ph, 93 + Db: db, 94 + Enforcer: enforcer, 95 + IdResolver: res, 96 + Logger: logger, 49 97 }, nil 50 - } 51 - 52 - type OAuth struct { 53 - ClientApp *oauth.ClientApp 54 - SessStore *sessions.CookieStore 55 - Config *config.Config 56 - JwksUri string 57 98 } 58 99 59 100 func (o *OAuth) SaveSession(w http.ResponseWriter, r *http.Request, sessData *oauth.ClientSessionData) error { ··· 122 163 return errors.Join(err1, err2) 123 164 } 124 165 125 - func pubKeyFromJwk(jwks string) (jwk.Key, error) { 126 - k, err := jwk.ParseKey([]byte(jwks)) 127 - if err != nil { 128 - return nil, err 129 - } 130 - pubKey, err := k.PublicKey() 131 - if err != nil { 132 - return nil, err 133 - } 134 - return pubKey, nil 135 - } 136 - 137 166 type User struct { 138 167 Did string 139 168 Pds string 140 169 } 141 170 142 171 func (o *OAuth) GetUser(r *http.Request) *User { 143 - sess, err := o.SessStore.Get(r, SessionName) 144 - 145 - if err != nil || sess.IsNew { 172 + sess, err := o.ResumeSession(r) 173 + if err != nil { 146 174 return nil 147 175 } 148 176 149 177 return &User{ 150 - Did: sess.Values[SessionDid].(string), 151 - Pds: sess.Values[SessionPds].(string), 178 + Did: sess.Data.AccountDID.String(), 179 + Pds: sess.Data.HostURL, 152 180 } 153 181 } 154 182 ··· 174 202 exp int64 175 203 lxm string 176 204 dev bool 205 + timeout time.Duration 177 206 } 178 207 179 208 type ServiceClientOpt func(*ServiceClientOpts) 180 209 210 + func DefaultServiceClientOpts() ServiceClientOpts { 211 + return ServiceClientOpts{ 212 + timeout: time.Second * 5, 213 + } 214 + } 215 + 181 216 func WithService(service string) ServiceClientOpt { 182 217 return func(s *ServiceClientOpts) { 183 218 s.service = service ··· 205 240 } 206 241 } 207 242 243 + func WithTimeout(timeout time.Duration) ServiceClientOpt { 244 + return func(s *ServiceClientOpts) { 245 + s.timeout = timeout 246 + } 247 + } 248 + 208 249 func (s *ServiceClientOpts) Audience() string { 209 250 return fmt.Sprintf("did:web:%s", s.service) 210 251 } ··· 219 260 } 220 261 221 262 func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*xrpc.Client, error) { 222 - opts := ServiceClientOpts{} 263 + opts := DefaultServiceClientOpts() 223 264 for _, o := range os { 224 265 o(&opts) 225 266 } ··· 246 287 }, 247 288 Host: opts.Host(), 248 289 Client: &http.Client{ 249 - Timeout: time.Second * 5, 290 + Timeout: opts.timeout, 250 291 }, 251 292 }, nil 252 293 }
+110 -11
appview/oauth/store.go
··· 11 11 "github.com/redis/go-redis/v9" 12 12 ) 13 13 14 + type RedisStoreConfig struct { 15 + RedisURL string 16 + 17 + // The purpose of these limits is to avoid dead sessions hanging around in the db indefinitely. 18 + // The durations here should be *at least as long as* the expected duration of the oauth session itself. 19 + SessionExpiryDuration time.Duration // duration since session creation (max TTL) 20 + SessionInactivityDuration time.Duration // duration since last session update 21 + AuthRequestExpiryDuration time.Duration // duration since auth request creation 22 + } 23 + 14 24 // redis-backed implementation of ClientAuthStore. 15 25 type RedisStore struct { 16 - client *redis.Client 17 - SessionTTL time.Duration 18 - AuthRequestTTL time.Duration 26 + client *redis.Client 27 + cfg *RedisStoreConfig 19 28 } 20 29 21 30 var _ oauth.ClientAuthStore = &RedisStore{} 22 31 23 - func NewRedisStore(redisURL string) (*RedisStore, error) { 24 - opts, err := redis.ParseURL(redisURL) 32 + type sessionMetadata struct { 33 + CreatedAt time.Time `json:"created_at"` 34 + UpdatedAt time.Time `json:"updated_at"` 35 + } 36 + 37 + func NewRedisStore(cfg *RedisStoreConfig) (*RedisStore, error) { 38 + if cfg == nil { 39 + return nil, fmt.Errorf("missing cfg") 40 + } 41 + if cfg.RedisURL == "" { 42 + return nil, fmt.Errorf("missing RedisURL") 43 + } 44 + if cfg.SessionExpiryDuration == 0 { 45 + return nil, fmt.Errorf("missing SessionExpiryDuration") 46 + } 47 + if cfg.SessionInactivityDuration == 0 { 48 + return nil, fmt.Errorf("missing SessionInactivityDuration") 49 + } 50 + if cfg.AuthRequestExpiryDuration == 0 { 51 + return nil, fmt.Errorf("missing AuthRequestExpiryDuration") 52 + } 53 + 54 + opts, err := redis.ParseURL(cfg.RedisURL) 25 55 if err != nil { 26 56 return nil, fmt.Errorf("failed to parse redis URL: %w", err) 27 57 } ··· 37 67 } 38 68 39 69 return &RedisStore{ 40 - client: client, 41 - SessionTTL: 30 * 24 * time.Hour, // 30 days 42 - AuthRequestTTL: 10 * time.Minute, // 10 minutes 70 + client: client, 71 + cfg: cfg, 43 72 }, nil 44 73 } 45 74 ··· 51 80 return fmt.Sprintf("oauth:session:%s:%s", did, sessionID) 52 81 } 53 82 83 + func sessionMetadataKey(did syntax.DID, sessionID string) string { 84 + return fmt.Sprintf("oauth:session_meta:%s:%s", did, sessionID) 85 + } 86 + 54 87 func authRequestKey(state string) string { 55 88 return fmt.Sprintf("oauth:auth_request:%s", state) 56 89 } 57 90 58 91 func (r *RedisStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) { 59 92 key := sessionKey(did, sessionID) 93 + metaKey := sessionMetadataKey(did, sessionID) 94 + 95 + // Check metadata for inactivity expiry 96 + metaData, err := r.client.Get(ctx, metaKey).Bytes() 97 + if err == redis.Nil { 98 + return nil, fmt.Errorf("session not found: %s", did) 99 + } 100 + if err != nil { 101 + return nil, fmt.Errorf("failed to get session metadata: %w", err) 102 + } 103 + 104 + var meta sessionMetadata 105 + if err := json.Unmarshal(metaData, &meta); err != nil { 106 + return nil, fmt.Errorf("failed to unmarshal session metadata: %w", err) 107 + } 108 + 109 + // Check if session has been inactive for too long 110 + inactiveThreshold := time.Now().Add(-r.cfg.SessionInactivityDuration) 111 + if meta.UpdatedAt.Before(inactiveThreshold) { 112 + // Session is inactive, delete it 113 + r.client.Del(ctx, key, metaKey) 114 + return nil, fmt.Errorf("session expired due to inactivity: %s", did) 115 + } 116 + 117 + // Get the actual session data 60 118 data, err := r.client.Get(ctx, key).Bytes() 61 119 if err == redis.Nil { 62 120 return nil, fmt.Errorf("session not found: %s", did) ··· 75 133 76 134 func (r *RedisStore) SaveSession(ctx context.Context, sess oauth.ClientSessionData) error { 77 135 key := sessionKey(sess.AccountDID, sess.SessionID) 136 + metaKey := sessionMetadataKey(sess.AccountDID, sess.SessionID) 78 137 79 138 data, err := json.Marshal(sess) 80 139 if err != nil { 81 140 return fmt.Errorf("failed to marshal session: %w", err) 82 141 } 83 142 84 - if err := r.client.Set(ctx, key, data, r.SessionTTL).Err(); err != nil { 143 + // Check if session already exists to preserve CreatedAt 144 + var meta sessionMetadata 145 + existingMetaData, err := r.client.Get(ctx, metaKey).Bytes() 146 + if err == redis.Nil { 147 + // New session 148 + meta = sessionMetadata{ 149 + CreatedAt: time.Now(), 150 + UpdatedAt: time.Now(), 151 + } 152 + } else if err != nil { 153 + return fmt.Errorf("failed to check existing session metadata: %w", err) 154 + } else { 155 + // Existing session - preserve CreatedAt, update UpdatedAt 156 + if err := json.Unmarshal(existingMetaData, &meta); err != nil { 157 + return fmt.Errorf("failed to unmarshal existing session metadata: %w", err) 158 + } 159 + meta.UpdatedAt = time.Now() 160 + } 161 + 162 + // Calculate remaining TTL based on creation time 163 + remainingTTL := r.cfg.SessionExpiryDuration - time.Since(meta.CreatedAt) 164 + if remainingTTL <= 0 { 165 + return fmt.Errorf("session has expired") 166 + } 167 + 168 + // Use the shorter of: remaining TTL or inactivity duration 169 + ttl := min(r.cfg.SessionInactivityDuration, remainingTTL) 170 + 171 + // Save session data 172 + if err := r.client.Set(ctx, key, data, ttl).Err(); err != nil { 85 173 return fmt.Errorf("failed to save session: %w", err) 86 174 } 87 175 176 + // Save metadata 177 + metaData, err := json.Marshal(meta) 178 + if err != nil { 179 + return fmt.Errorf("failed to marshal session metadata: %w", err) 180 + } 181 + if err := r.client.Set(ctx, metaKey, metaData, ttl).Err(); err != nil { 182 + return fmt.Errorf("failed to save session metadata: %w", err) 183 + } 184 + 88 185 return nil 89 186 } 90 187 91 188 func (r *RedisStore) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error { 92 189 key := sessionKey(did, sessionID) 93 - if err := r.client.Del(ctx, key).Err(); err != nil { 190 + metaKey := sessionMetadataKey(did, sessionID) 191 + 192 + if err := r.client.Del(ctx, key, metaKey).Err(); err != nil { 94 193 return fmt.Errorf("failed to delete session: %w", err) 95 194 } 96 195 return nil ··· 131 230 return fmt.Errorf("failed to marshal auth request: %w", err) 132 231 } 133 232 134 - if err := r.client.Set(ctx, key, data, r.AuthRequestTTL).Err(); err != nil { 233 + if err := r.client.Set(ctx, key, data, r.cfg.AuthRequestExpiryDuration).Err(); err != nil { 135 234 return fmt.Errorf("failed to save auth request: %w", err) 136 235 } 137 236
+584
appview/ogcard/card.go
··· 1 + // Copyright 2024 The Forgejo Authors. All rights reserved. 2 + // Copyright 2025 The Tangled Authors -- repurposed for Tangled use. 3 + // SPDX-License-Identifier: MIT 4 + 5 + package ogcard 6 + 7 + import ( 8 + "bytes" 9 + "fmt" 10 + "html/template" 11 + "image" 12 + "image/color" 13 + "io" 14 + "log" 15 + "math" 16 + "net/http" 17 + "strings" 18 + "sync" 19 + "time" 20 + 21 + "github.com/goki/freetype" 22 + "github.com/goki/freetype/truetype" 23 + "github.com/srwiley/oksvg" 24 + "github.com/srwiley/rasterx" 25 + "golang.org/x/image/draw" 26 + "golang.org/x/image/font" 27 + "tangled.org/core/appview/pages" 28 + 29 + _ "golang.org/x/image/webp" // for processing webp images 30 + ) 31 + 32 + type Card struct { 33 + Img *image.RGBA 34 + Font *truetype.Font 35 + Margin int 36 + Width int 37 + Height int 38 + } 39 + 40 + var fontCache = sync.OnceValues(func() (*truetype.Font, error) { 41 + interVar, err := pages.Files.ReadFile("static/fonts/InterVariable.ttf") 42 + if err != nil { 43 + return nil, err 44 + } 45 + return truetype.Parse(interVar) 46 + }) 47 + 48 + // DefaultSize returns the default size for a card 49 + func DefaultSize() (int, int) { 50 + return 1200, 630 51 + } 52 + 53 + // NewCard creates a new card with the given dimensions in pixels 54 + func NewCard(width, height int) (*Card, error) { 55 + img := image.NewRGBA(image.Rect(0, 0, width, height)) 56 + draw.Draw(img, img.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src) 57 + 58 + font, err := fontCache() 59 + if err != nil { 60 + return nil, err 61 + } 62 + 63 + return &Card{ 64 + Img: img, 65 + Font: font, 66 + Margin: 0, 67 + Width: width, 68 + Height: height, 69 + }, nil 70 + } 71 + 72 + // Split splits the card horizontally or vertically by a given percentage; the first card returned has the percentage 73 + // size, and the second card has the remainder. Both cards draw to a subsection of the same image buffer. 74 + func (c *Card) Split(vertical bool, percentage int) (*Card, *Card) { 75 + bounds := c.Img.Bounds() 76 + bounds = image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin) 77 + if vertical { 78 + mid := (bounds.Dx() * percentage / 100) + bounds.Min.X 79 + subleft := c.Img.SubImage(image.Rect(bounds.Min.X, bounds.Min.Y, mid, bounds.Max.Y)).(*image.RGBA) 80 + subright := c.Img.SubImage(image.Rect(mid, bounds.Min.Y, bounds.Max.X, bounds.Max.Y)).(*image.RGBA) 81 + return &Card{Img: subleft, Font: c.Font, Width: subleft.Bounds().Dx(), Height: subleft.Bounds().Dy()}, 82 + &Card{Img: subright, Font: c.Font, Width: subright.Bounds().Dx(), Height: subright.Bounds().Dy()} 83 + } 84 + mid := (bounds.Dy() * percentage / 100) + bounds.Min.Y 85 + subtop := c.Img.SubImage(image.Rect(bounds.Min.X, bounds.Min.Y, bounds.Max.X, mid)).(*image.RGBA) 86 + subbottom := c.Img.SubImage(image.Rect(bounds.Min.X, mid, bounds.Max.X, bounds.Max.Y)).(*image.RGBA) 87 + return &Card{Img: subtop, Font: c.Font, Width: subtop.Bounds().Dx(), Height: subtop.Bounds().Dy()}, 88 + &Card{Img: subbottom, Font: c.Font, Width: subbottom.Bounds().Dx(), Height: subbottom.Bounds().Dy()} 89 + } 90 + 91 + // SetMargin sets the margins for the card 92 + func (c *Card) SetMargin(margin int) { 93 + c.Margin = margin 94 + } 95 + 96 + type ( 97 + VAlign int64 98 + HAlign int64 99 + ) 100 + 101 + const ( 102 + Top VAlign = iota 103 + Middle 104 + Bottom 105 + ) 106 + 107 + const ( 108 + Left HAlign = iota 109 + Center 110 + Right 111 + ) 112 + 113 + // DrawText draws text within the card, respecting margins and alignment 114 + func (c *Card) DrawText(text string, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) ([]string, error) { 115 + ft := freetype.NewContext() 116 + ft.SetDPI(72) 117 + ft.SetFont(c.Font) 118 + ft.SetFontSize(sizePt) 119 + ft.SetClip(c.Img.Bounds()) 120 + ft.SetDst(c.Img) 121 + ft.SetSrc(image.NewUniform(textColor)) 122 + 123 + face := truetype.NewFace(c.Font, &truetype.Options{Size: sizePt, DPI: 72}) 124 + fontHeight := ft.PointToFixed(sizePt).Ceil() 125 + 126 + bounds := c.Img.Bounds() 127 + bounds = image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin) 128 + boxWidth, boxHeight := bounds.Size().X, bounds.Size().Y 129 + // draw.Draw(c.Img, bounds, image.NewUniform(color.Gray{128}), image.Point{}, draw.Src) // Debug draw box 130 + 131 + // Try to apply wrapping to this text; we'll find the most text that will fit into one line, record that line, move 132 + // on. We precalculate each line before drawing so that we can support valign="middle" correctly which requires 133 + // knowing the total height, which is related to how many lines we'll have. 134 + lines := make([]string, 0) 135 + textWords := strings.Split(text, " ") 136 + currentLine := "" 137 + heightTotal := 0 138 + 139 + for { 140 + if len(textWords) == 0 { 141 + // Ran out of words. 142 + if currentLine != "" { 143 + heightTotal += fontHeight 144 + lines = append(lines, currentLine) 145 + } 146 + break 147 + } 148 + 149 + nextWord := textWords[0] 150 + proposedLine := currentLine 151 + if proposedLine != "" { 152 + proposedLine += " " 153 + } 154 + proposedLine += nextWord 155 + 156 + proposedLineWidth := font.MeasureString(face, proposedLine) 157 + if proposedLineWidth.Ceil() > boxWidth { 158 + // no, proposed line is too big; we'll use the last "currentLine" 159 + heightTotal += fontHeight 160 + if currentLine != "" { 161 + lines = append(lines, currentLine) 162 + currentLine = "" 163 + // leave nextWord in textWords and keep going 164 + } else { 165 + // just nextWord by itself doesn't fit on a line; well, we can't skip it, but we'll consume it 166 + // regardless as a line by itself. It will be clipped by the drawing routine. 167 + lines = append(lines, nextWord) 168 + textWords = textWords[1:] 169 + } 170 + } else { 171 + // yes, it will fit 172 + currentLine = proposedLine 173 + textWords = textWords[1:] 174 + } 175 + } 176 + 177 + textY := 0 178 + switch valign { 179 + case Top: 180 + textY = fontHeight 181 + case Bottom: 182 + textY = boxHeight - heightTotal + fontHeight 183 + case Middle: 184 + textY = ((boxHeight - heightTotal) / 2) + fontHeight 185 + } 186 + 187 + for _, line := range lines { 188 + lineWidth := font.MeasureString(face, line) 189 + 190 + textX := 0 191 + switch halign { 192 + case Left: 193 + textX = 0 194 + case Right: 195 + textX = boxWidth - lineWidth.Ceil() 196 + case Center: 197 + textX = (boxWidth - lineWidth.Ceil()) / 2 198 + } 199 + 200 + pt := freetype.Pt(bounds.Min.X+textX, bounds.Min.Y+textY) 201 + _, err := ft.DrawString(line, pt) 202 + if err != nil { 203 + return nil, err 204 + } 205 + 206 + textY += fontHeight 207 + } 208 + 209 + return lines, nil 210 + } 211 + 212 + // DrawTextAt draws text at a specific position with the given alignment 213 + func (c *Card) DrawTextAt(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) error { 214 + _, err := c.DrawTextAtWithWidth(text, x, y, textColor, sizePt, valign, halign) 215 + return err 216 + } 217 + 218 + // DrawTextAtWithWidth draws text at a specific position and returns the text width 219 + func (c *Card) DrawTextAtWithWidth(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) (int, error) { 220 + ft := freetype.NewContext() 221 + ft.SetDPI(72) 222 + ft.SetFont(c.Font) 223 + ft.SetFontSize(sizePt) 224 + ft.SetClip(c.Img.Bounds()) 225 + ft.SetDst(c.Img) 226 + ft.SetSrc(image.NewUniform(textColor)) 227 + 228 + face := truetype.NewFace(c.Font, &truetype.Options{Size: sizePt, DPI: 72}) 229 + fontHeight := ft.PointToFixed(sizePt).Ceil() 230 + lineWidth := font.MeasureString(face, text) 231 + textWidth := lineWidth.Ceil() 232 + 233 + // Adjust position based on alignment 234 + adjustedX := x 235 + adjustedY := y 236 + 237 + switch halign { 238 + case Left: 239 + // x is already at the left position 240 + case Right: 241 + adjustedX = x - textWidth 242 + case Center: 243 + adjustedX = x - textWidth/2 244 + } 245 + 246 + switch valign { 247 + case Top: 248 + adjustedY = y + fontHeight 249 + case Bottom: 250 + adjustedY = y 251 + case Middle: 252 + adjustedY = y + fontHeight/2 253 + } 254 + 255 + pt := freetype.Pt(adjustedX, adjustedY) 256 + _, err := ft.DrawString(text, pt) 257 + return textWidth, err 258 + } 259 + 260 + // DrawBoldText draws bold text by rendering multiple times with slight offsets 261 + func (c *Card) DrawBoldText(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) (int, error) { 262 + // Draw the text multiple times with slight offsets to create bold effect 263 + offsets := []struct{ dx, dy int }{ 264 + {0, 0}, // original 265 + {1, 0}, // right 266 + {0, 1}, // down 267 + {1, 1}, // diagonal 268 + } 269 + 270 + var width int 271 + for _, offset := range offsets { 272 + w, err := c.DrawTextAtWithWidth(text, x+offset.dx, y+offset.dy, textColor, sizePt, valign, halign) 273 + if err != nil { 274 + return 0, err 275 + } 276 + if width == 0 { 277 + width = w 278 + } 279 + } 280 + return width, nil 281 + } 282 + 283 + func BuildSVGIconFromData(svgData []byte, iconColor color.Color) (*oksvg.SvgIcon, error) { 284 + // Convert color to hex string for SVG 285 + rgba, isRGBA := iconColor.(color.RGBA) 286 + if !isRGBA { 287 + r, g, b, a := iconColor.RGBA() 288 + rgba = color.RGBA{uint8(r >> 8), uint8(g >> 8), uint8(b >> 8), uint8(a >> 8)} 289 + } 290 + colorHex := fmt.Sprintf("#%02x%02x%02x", rgba.R, rgba.G, rgba.B) 291 + 292 + // Replace currentColor with our desired color in the SVG 293 + svgString := string(svgData) 294 + svgString = strings.ReplaceAll(svgString, "currentColor", colorHex) 295 + 296 + // Make the stroke thicker 297 + svgString = strings.ReplaceAll(svgString, `stroke-width="2"`, `stroke-width="3"`) 298 + 299 + // Parse SVG 300 + icon, err := oksvg.ReadIconStream(strings.NewReader(svgString)) 301 + if err != nil { 302 + return nil, fmt.Errorf("failed to parse SVG: %w", err) 303 + } 304 + 305 + return icon, nil 306 + } 307 + 308 + func BuildSVGIconFromPath(svgPath string, iconColor color.Color) (*oksvg.SvgIcon, error) { 309 + svgData, err := pages.Files.ReadFile(svgPath) 310 + if err != nil { 311 + return nil, fmt.Errorf("failed to read SVG file %s: %w", svgPath, err) 312 + } 313 + 314 + icon, err := BuildSVGIconFromData(svgData, iconColor) 315 + if err != nil { 316 + return nil, fmt.Errorf("failed to build SVG icon %s: %w", svgPath, err) 317 + } 318 + 319 + return icon, nil 320 + } 321 + 322 + func BuildLucideIcon(name string, iconColor color.Color) (*oksvg.SvgIcon, error) { 323 + return BuildSVGIconFromPath(fmt.Sprintf("static/icons/%s.svg", name), iconColor) 324 + } 325 + 326 + func (c *Card) DrawLucideIcon(name string, x, y, size int, iconColor color.Color) error { 327 + icon, err := BuildSVGIconFromPath(fmt.Sprintf("static/icons/%s.svg", name), iconColor) 328 + if err != nil { 329 + return err 330 + } 331 + 332 + c.DrawSVGIcon(icon, x, y, size) 333 + 334 + return nil 335 + } 336 + 337 + func (c *Card) 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) 350 + if err != nil { 351 + return err 352 + } 353 + 354 + c.DrawSVGIcon(icon, x, y, size) 355 + 356 + return nil 357 + } 358 + 359 + // DrawSVGIcon draws an SVG icon from the embedded files at the specified position 360 + func (c *Card) DrawSVGIcon(icon *oksvg.SvgIcon, x, y, size int) { 361 + // Set the icon size 362 + w, h := float64(size), float64(size) 363 + icon.SetTarget(0, 0, w, h) 364 + 365 + // Create a temporary RGBA image for the icon 366 + iconImg := image.NewRGBA(image.Rect(0, 0, size, size)) 367 + 368 + // Create scanner and rasterizer 369 + scanner := rasterx.NewScannerGV(size, size, iconImg, iconImg.Bounds()) 370 + raster := rasterx.NewDasher(size, size, scanner) 371 + 372 + // Draw the icon 373 + icon.Draw(raster, 1.0) 374 + 375 + // Draw the icon onto the card at the specified position 376 + bounds := c.Img.Bounds() 377 + destRect := image.Rect(x, y, x+size, y+size) 378 + 379 + // Make sure we don't draw outside the card bounds 380 + if destRect.Max.X > bounds.Max.X { 381 + destRect.Max.X = bounds.Max.X 382 + } 383 + if destRect.Max.Y > bounds.Max.Y { 384 + destRect.Max.Y = bounds.Max.Y 385 + } 386 + 387 + draw.Draw(c.Img, destRect, iconImg, image.Point{}, draw.Over) 388 + } 389 + 390 + // DrawImage fills the card with an image, scaled to maintain the original aspect ratio and centered with respect to the non-filled dimension 391 + func (c *Card) DrawImage(img image.Image) { 392 + bounds := c.Img.Bounds() 393 + targetRect := image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin) 394 + srcBounds := img.Bounds() 395 + srcAspect := float64(srcBounds.Dx()) / float64(srcBounds.Dy()) 396 + targetAspect := float64(targetRect.Dx()) / float64(targetRect.Dy()) 397 + 398 + var scale float64 399 + if srcAspect > targetAspect { 400 + // Image is wider than target, scale by width 401 + scale = float64(targetRect.Dx()) / float64(srcBounds.Dx()) 402 + } else { 403 + // Image is taller or equal, scale by height 404 + scale = float64(targetRect.Dy()) / float64(srcBounds.Dy()) 405 + } 406 + 407 + newWidth := int(math.Round(float64(srcBounds.Dx()) * scale)) 408 + newHeight := int(math.Round(float64(srcBounds.Dy()) * scale)) 409 + 410 + // Center the image within the target rectangle 411 + offsetX := (targetRect.Dx() - newWidth) / 2 412 + offsetY := (targetRect.Dy() - newHeight) / 2 413 + 414 + scaledRect := image.Rect(targetRect.Min.X+offsetX, targetRect.Min.Y+offsetY, targetRect.Min.X+offsetX+newWidth, targetRect.Min.Y+offsetY+newHeight) 415 + draw.CatmullRom.Scale(c.Img, scaledRect, img, srcBounds, draw.Over, nil) 416 + } 417 + 418 + func fallbackImage() image.Image { 419 + // can't usage image.Uniform(color.White) because it's infinitely sized causing a panic in the scaler in DrawImage 420 + img := image.NewRGBA(image.Rect(0, 0, 1, 1)) 421 + img.Set(0, 0, color.White) 422 + return img 423 + } 424 + 425 + // As defensively as possible, attempt to load an image from a presumed external and untrusted URL 426 + func (c *Card) fetchExternalImage(url string) (image.Image, bool) { 427 + // Use a short timeout; in the event of any failure we'll be logging and returning a placeholder, but we don't want 428 + // this rendering process to be slowed down 429 + client := &http.Client{ 430 + Timeout: 1 * time.Second, // 1 second timeout 431 + } 432 + 433 + resp, err := client.Get(url) 434 + if err != nil { 435 + log.Printf("error when fetching external image from %s: %v", url, err) 436 + return nil, false 437 + } 438 + defer resp.Body.Close() 439 + 440 + if resp.StatusCode != http.StatusOK { 441 + log.Printf("non-OK error code when fetching external image from %s: %s", url, resp.Status) 442 + return nil, false 443 + } 444 + 445 + contentType := resp.Header.Get("Content-Type") 446 + 447 + body := resp.Body 448 + bodyBytes, err := io.ReadAll(body) 449 + if err != nil { 450 + log.Printf("error when fetching external image from %s: %v", url, err) 451 + return nil, false 452 + } 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 460 + if contentType != "image/png" && contentType != "image/jpeg" && contentType != "image/gif" && contentType != "image/webp" { 461 + log.Printf("fetching external image returned unsupported Content-Type which was ignored: %s", contentType) 462 + return nil, false 463 + } 464 + 465 + bodyBuffer := bytes.NewReader(bodyBytes) 466 + _, imgType, err := image.DecodeConfig(bodyBuffer) 467 + if err != nil { 468 + log.Printf("error when decoding external image from %s: %v", url, err) 469 + return nil, false 470 + } 471 + 472 + // Verify that we have a match between actual data understood in the image body and the reported Content-Type 473 + if (contentType == "image/png" && imgType != "png") || 474 + (contentType == "image/jpeg" && imgType != "jpeg") || 475 + (contentType == "image/gif" && imgType != "gif") || 476 + (contentType == "image/webp" && imgType != "webp") { 477 + log.Printf("while fetching external image, mismatched image body (%s) and Content-Type (%s)", imgType, contentType) 478 + return nil, false 479 + } 480 + 481 + _, err = bodyBuffer.Seek(0, io.SeekStart) // reset for actual decode 482 + if err != nil { 483 + log.Printf("error w/ bodyBuffer.Seek") 484 + return nil, false 485 + } 486 + img, _, err := image.Decode(bodyBuffer) 487 + if err != nil { 488 + log.Printf("error when decoding external image from %s: %v", url, err) 489 + return nil, false 490 + } 491 + 492 + return img, true 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 { 500 + log.Printf("error parsing SVG: %v", err) 501 + return nil, false 502 + } 503 + 504 + // Set a reasonable size for the rasterized image 505 + width := 256 506 + height := 256 507 + icon.SetTarget(0, 0, float64(width), float64(height)) 508 + 509 + // Create an image to draw on 510 + rgba := image.NewRGBA(image.Rect(0, 0, width, height)) 511 + 512 + // Fill with white background 513 + draw.Draw(rgba, rgba.Bounds(), &image.Uniform{color.White}, image.Point{}, draw.Src) 514 + 515 + // Create a scanner and rasterize the SVG 516 + scanner := rasterx.NewScannerGV(width, height, rgba, rgba.Bounds()) 517 + raster := rasterx.NewDasher(width, height, scanner) 518 + 519 + icon.Draw(raster, 1.0) 520 + 521 + return rgba, true 522 + } 523 + 524 + func (c *Card) DrawExternalImage(url string) { 525 + image, ok := c.fetchExternalImage(url) 526 + if !ok { 527 + image = fallbackImage() 528 + } 529 + c.DrawImage(image) 530 + } 531 + 532 + // DrawCircularExternalImage draws an external image as a circle at the specified position 533 + func (c *Card) DrawCircularExternalImage(url string, x, y, size int) error { 534 + img, ok := c.fetchExternalImage(url) 535 + if !ok { 536 + img = fallbackImage() 537 + } 538 + 539 + // Create a circular mask 540 + circle := image.NewRGBA(image.Rect(0, 0, size, size)) 541 + center := size / 2 542 + radius := float64(size / 2) 543 + 544 + // Scale the source image to fit the circle 545 + srcBounds := img.Bounds() 546 + scaledImg := image.NewRGBA(image.Rect(0, 0, size, size)) 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) 555 + distance := math.Sqrt(dx*dx + dy*dy) 556 + 557 + // Only draw pixels within the circle 558 + if distance <= radius { 559 + circle.Set(cx, cy, scaledImg.At(cx, cy)) 560 + } 561 + } 562 + } 563 + 564 + // Draw the circle onto the card 565 + bounds := c.Img.Bounds() 566 + destRect := image.Rect(x, y, x+size, y+size) 567 + 568 + // Make sure we don't draw outside the card bounds 569 + if destRect.Max.X > bounds.Max.X { 570 + destRect.Max.X = bounds.Max.X 571 + } 572 + if destRect.Max.Y > bounds.Max.Y { 573 + destRect.Max.Y = bounds.Max.Y 574 + } 575 + 576 + draw.Draw(c.Img, destRect, circle, image.Point{}, draw.Over) 577 + 578 + return nil 579 + } 580 + 581 + // DrawRect draws a rect with the given color 582 + func (c *Card) DrawRect(startX, startY, endX, endY int, color color.Color) { 583 + draw.Draw(c.Img, image.Rect(startX, startY, endX, endY), &image.Uniform{color}, image.Point{}, draw.Src) 584 + }
+106 -17
appview/pages/funcmap.go
··· 1 1 package pages 2 2 3 3 import ( 4 + "bytes" 4 5 "context" 5 6 "crypto/hmac" 6 7 "crypto/sha256" ··· 17 18 "strings" 18 19 "time" 19 20 21 + "github.com/alecthomas/chroma/v2" 22 + chromahtml "github.com/alecthomas/chroma/v2/formatters/html" 23 + "github.com/alecthomas/chroma/v2/lexers" 24 + "github.com/alecthomas/chroma/v2/styles" 20 25 "github.com/dustin/go-humanize" 21 26 "github.com/go-enry/go-enry/v2" 27 + "github.com/yuin/goldmark" 22 28 "tangled.org/core/appview/filetree" 29 + "tangled.org/core/appview/models" 23 30 "tangled.org/core/appview/pages/markup" 24 31 "tangled.org/core/crypto" 25 32 ) ··· 38 45 "contains": func(s string, target string) bool { 39 46 return strings.Contains(s, target) 40 47 }, 48 + "stripPort": func(hostname string) string { 49 + if strings.Contains(hostname, ":") { 50 + return strings.Split(hostname, ":")[0] 51 + } 52 + return hostname 53 + }, 41 54 "mapContains": func(m any, key any) bool { 42 55 mapValue := reflect.ValueOf(m) 43 56 if mapValue.Kind() != reflect.Map { ··· 57 70 return "handle.invalid" 58 71 } 59 72 60 - return "@" + identity.Handle.String() 73 + return identity.Handle.String() 74 + }, 75 + "ownerSlashRepo": func(repo *models.Repo) string { 76 + ownerId, err := p.resolver.ResolveIdent(context.Background(), repo.Did) 77 + if err != nil { 78 + return repo.DidSlashRepo() 79 + } 80 + handle := ownerId.Handle 81 + if handle != "" && !handle.IsInvalidHandle() { 82 + return string(handle) + "/" + repo.Name 83 + } 84 + return repo.DidSlashRepo() 61 85 }, 62 86 "truncateAt30": func(s string) string { 63 87 if len(s) <= 30 { ··· 67 91 }, 68 92 "splitOn": func(s, sep string) []string { 69 93 return strings.Split(s, sep) 94 + }, 95 + "string": func(v any) string { 96 + return fmt.Sprint(v) 70 97 }, 71 98 "int64": func(a int) int64 { 72 99 return int64(a) ··· 84 111 "sub": func(a, b int) int { 85 112 return a - b 86 113 }, 114 + "mul": func(a, b int) int { 115 + return a * b 116 + }, 117 + "div": func(a, b int) int { 118 + return a / b 119 + }, 120 + "mod": func(a, b int) int { 121 + return a % b 122 + }, 87 123 "f64": func(a int) float64 { 88 124 return float64(a) 89 125 }, ··· 116 152 117 153 return b 118 154 }, 119 - "didOrHandle": func(did, handle string) string { 120 - if handle != "" { 121 - return fmt.Sprintf("@%s", handle) 122 - } else { 123 - return did 124 - } 125 - }, 126 155 "assoc": func(values ...string) ([][]string, error) { 127 156 if len(values)%2 != 0 { 128 157 return nil, fmt.Errorf("invalid assoc call, must have an even number of arguments") ··· 133 162 } 134 163 return pairs, nil 135 164 }, 136 - "append": func(s []string, values ...string) []string { 165 + "append": func(s []any, values ...any) []any { 137 166 s = append(s, values...) 138 167 return s 139 168 }, ··· 232 261 }, 233 262 "description": func(text string) template.HTML { 234 263 p.rctx.RendererType = markup.RendererTypeDefault 235 - htmlString := p.rctx.RenderMarkdown(text) 264 + htmlString := p.rctx.RenderMarkdownWith(text, goldmark.New()) 236 265 sanitized := p.rctx.SanitizeDescription(htmlString) 237 266 return template.HTML(sanitized) 238 267 }, 268 + "readme": func(text string) template.HTML { 269 + p.rctx.RendererType = markup.RendererTypeRepoMarkdown 270 + htmlString := p.rctx.RenderMarkdown(text) 271 + sanitized := p.rctx.SanitizeDefault(htmlString) 272 + return template.HTML(sanitized) 273 + }, 274 + "code": func(content, path string) string { 275 + var style *chroma.Style = styles.Get("catpuccin-latte") 276 + formatter := chromahtml.New( 277 + chromahtml.InlineCode(false), 278 + chromahtml.WithLineNumbers(true), 279 + chromahtml.WithLinkableLineNumbers(true, "L"), 280 + chromahtml.Standalone(false), 281 + chromahtml.WithClasses(true), 282 + ) 283 + 284 + lexer := lexers.Get(filepath.Base(path)) 285 + if lexer == nil { 286 + lexer = lexers.Fallback 287 + } 288 + 289 + iterator, err := lexer.Tokenise(nil, content) 290 + if err != nil { 291 + p.logger.Error("chroma tokenize", "err", "err") 292 + return "" 293 + } 294 + 295 + var code bytes.Buffer 296 + err = formatter.Format(&code, style, iterator) 297 + if err != nil { 298 + p.logger.Error("chroma format", "err", "err") 299 + return "" 300 + } 301 + 302 + return code.String() 303 + }, 304 + "trimUriScheme": func(text string) string { 305 + text = strings.TrimPrefix(text, "https://") 306 + text = strings.TrimPrefix(text, "http://") 307 + return text 308 + }, 239 309 "isNil": func(t any) bool { 240 310 // returns false for other "zero" values 241 311 return t == nil ··· 265 335 return nil 266 336 }, 267 337 "i": func(name string, classes ...string) template.HTML { 268 - data, err := icon(name, classes) 338 + data, err := p.icon(name, classes) 269 339 if err != nil { 270 340 log.Printf("icon %s does not exist", name) 271 - data, _ = icon("airplay", classes) 341 + data, _ = p.icon("airplay", classes) 272 342 } 273 343 return template.HTML(data) 274 344 }, 275 - "cssContentHash": CssContentHash, 345 + "cssContentHash": p.CssContentHash, 276 346 "fileTree": filetree.FileTree, 277 347 "pathEscape": func(s string) string { 278 348 return url.PathEscape(s) ··· 281 351 u, _ := url.PathUnescape(s) 282 352 return u 283 353 }, 284 - 354 + "safeUrl": func(s string) template.URL { 355 + return template.URL(s) 356 + }, 285 357 "tinyAvatar": func(handle string) string { 286 358 return p.AvatarUrl(handle, "tiny") 287 359 }, ··· 297 369 }, 298 370 299 371 "normalizeForHtmlId": func(s string) string { 300 - // TODO: extend this to handle other cases? 301 - return strings.ReplaceAll(s, ":", "_") 372 + normalized := strings.ReplaceAll(s, ":", "_") 373 + normalized = strings.ReplaceAll(normalized, ".", "_") 374 + return normalized 302 375 }, 303 376 "sshFingerprint": func(pubKey string) string { 304 377 fp, err := crypto.SSHFingerprint(pubKey) ··· 310 383 } 311 384 } 312 385 386 + func (p *Pages) resolveDid(did string) string { 387 + identity, err := p.resolver.ResolveIdent(context.Background(), did) 388 + 389 + if err != nil { 390 + return did 391 + } 392 + 393 + if identity.Handle.IsInvalidHandle() { 394 + return "handle.invalid" 395 + } 396 + 397 + return identity.Handle.String() 398 + } 399 + 313 400 func (p *Pages) AvatarUrl(handle, size string) string { 314 401 handle = strings.TrimPrefix(handle, "@") 402 + 403 + handle = p.resolveDid(handle) 315 404 316 405 secret := p.avatar.SharedSecret 317 406 h := hmac.New(sha256.New, []byte(secret)) ··· 325 414 return fmt.Sprintf("%s/%s/%s?%s", p.avatar.Host, signature, handle, sizeArg) 326 415 } 327 416 328 - func icon(name string, classes []string) (template.HTML, error) { 417 + func (p *Pages) icon(name string, classes []string) (template.HTML, error) { 329 418 iconPath := filepath.Join("static", "icons", name) 330 419 331 420 if filepath.Ext(name) == "" {
+5 -2
appview/pages/funcmap_test.go
··· 2 2 3 3 import ( 4 4 "html/template" 5 + "log/slog" 6 + "testing" 7 + 5 8 "tangled.org/core/appview/config" 6 9 "tangled.org/core/idresolver" 7 - "testing" 8 10 ) 9 11 10 12 func TestPages_funcMap(t *testing.T) { ··· 13 15 // Named input parameters for receiver constructor. 14 16 config *config.Config 15 17 res *idresolver.Resolver 18 + l *slog.Logger 16 19 want template.FuncMap 17 20 }{ 18 21 // TODO: Add test cases. 19 22 } 20 23 for _, tt := range tests { 21 24 t.Run(tt.name, func(t *testing.T) { 22 - p := NewPages(tt.config, tt.res) 25 + p := NewPages(tt.config, tt.res, tt.l) 23 26 got := p.funcMap() 24 27 // TODO: update the condition below to compare got with tt.want. 25 28 if true {
+125
appview/pages/markup/extension/atlink.go
··· 1 + // heavily inspired by: https://github.com/kaleocheng/goldmark-extensions 2 + 3 + package extension 4 + 5 + import ( 6 + "regexp" 7 + 8 + "github.com/yuin/goldmark" 9 + "github.com/yuin/goldmark/ast" 10 + "github.com/yuin/goldmark/parser" 11 + "github.com/yuin/goldmark/renderer" 12 + "github.com/yuin/goldmark/renderer/html" 13 + "github.com/yuin/goldmark/text" 14 + "github.com/yuin/goldmark/util" 15 + ) 16 + 17 + // An AtNode struct represents an AtNode 18 + type AtNode struct { 19 + Handle string 20 + ast.BaseInline 21 + } 22 + 23 + var _ ast.Node = &AtNode{} 24 + 25 + // Dump implements Node.Dump. 26 + func (n *AtNode) Dump(source []byte, level int) { 27 + ast.DumpHelper(n, source, level, nil, nil) 28 + } 29 + 30 + // KindAt is a NodeKind of the At node. 31 + var KindAt = ast.NewNodeKind("At") 32 + 33 + // Kind implements Node.Kind. 34 + func (n *AtNode) Kind() ast.NodeKind { 35 + return KindAt 36 + } 37 + 38 + var atRegexp = regexp.MustCompile(`(^|\s|\()(@)([a-zA-Z0-9.-]+)(\b)`) 39 + var markdownLinkRegexp = regexp.MustCompile(`(?ms)\[.*\]\(.*\)`) 40 + 41 + type atParser struct{} 42 + 43 + // NewAtParser return a new InlineParser that parses 44 + // at expressions. 45 + func NewAtParser() parser.InlineParser { 46 + return &atParser{} 47 + } 48 + 49 + func (s *atParser) Trigger() []byte { 50 + return []byte{'@'} 51 + } 52 + 53 + func (s *atParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node { 54 + line, segment := block.PeekLine() 55 + m := atRegexp.FindSubmatchIndex(line) 56 + if m == nil { 57 + return nil 58 + } 59 + 60 + if !util.IsSpaceRune(block.PrecendingCharacter()) { 61 + return nil 62 + } 63 + 64 + // Check for all links in the markdown to see if the handle found is inside one 65 + linksIndexes := markdownLinkRegexp.FindAllIndex(block.Source(), -1) 66 + for _, linkMatch := range linksIndexes { 67 + if linkMatch[0] < segment.Start && segment.Start < linkMatch[1] { 68 + return nil 69 + } 70 + } 71 + 72 + atSegment := text.NewSegment(segment.Start, segment.Start+m[1]) 73 + block.Advance(m[1]) 74 + node := &AtNode{} 75 + node.AppendChild(node, ast.NewTextSegment(atSegment)) 76 + node.Handle = string(atSegment.Value(block.Source())[1:]) 77 + return node 78 + } 79 + 80 + // atHtmlRenderer is a renderer.NodeRenderer implementation that 81 + // renders At nodes. 82 + type atHtmlRenderer struct { 83 + html.Config 84 + } 85 + 86 + // NewAtHTMLRenderer returns a new AtHTMLRenderer. 87 + func NewAtHTMLRenderer(opts ...html.Option) renderer.NodeRenderer { 88 + r := &atHtmlRenderer{ 89 + Config: html.NewConfig(), 90 + } 91 + for _, opt := range opts { 92 + opt.SetHTMLOption(&r.Config) 93 + } 94 + return r 95 + } 96 + 97 + // RegisterFuncs implements renderer.NodeRenderer.RegisterFuncs. 98 + func (r *atHtmlRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { 99 + reg.Register(KindAt, r.renderAt) 100 + } 101 + 102 + func (r *atHtmlRenderer) renderAt(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) { 103 + if entering { 104 + w.WriteString(`<a href="/@`) 105 + w.WriteString(n.(*AtNode).Handle) 106 + w.WriteString(`" class="mention font-bold">`) 107 + } else { 108 + w.WriteString("</a>") 109 + } 110 + return ast.WalkContinue, nil 111 + } 112 + 113 + type atExt struct{} 114 + 115 + // At is an extension that allow you to use at expression like '@user.bsky.social' . 116 + var AtExt = &atExt{} 117 + 118 + func (e *atExt) Extend(m goldmark.Markdown) { 119 + m.Parser().AddOptions(parser.WithInlineParsers( 120 + util.Prioritized(NewAtParser(), 500), 121 + )) 122 + m.Renderer().AddOptions(renderer.WithNodeRenderers( 123 + util.Prioritized(NewAtHTMLRenderer(), 500), 124 + )) 125 + }
+17 -5
appview/pages/markup/markdown.go
··· 5 5 "bytes" 6 6 "fmt" 7 7 "io" 8 + "io/fs" 8 9 "net/url" 9 10 "path" 10 11 "strings" 11 12 12 13 chromahtml "github.com/alecthomas/chroma/v2/formatters/html" 13 14 "github.com/alecthomas/chroma/v2/styles" 14 - treeblood "github.com/wyatt915/goldmark-treeblood" 15 15 "github.com/yuin/goldmark" 16 16 highlighting "github.com/yuin/goldmark-highlighting/v2" 17 17 "github.com/yuin/goldmark/ast" ··· 20 20 "github.com/yuin/goldmark/renderer/html" 21 21 "github.com/yuin/goldmark/text" 22 22 "github.com/yuin/goldmark/util" 23 + callout "gitlab.com/staticnoise/goldmark-callout" 23 24 htmlparse "golang.org/x/net/html" 24 25 25 26 "tangled.org/core/api/tangled" 27 + textension "tangled.org/core/appview/pages/markup/extension" 26 28 "tangled.org/core/appview/pages/repoinfo" 27 29 ) 28 30 ··· 45 47 IsDev bool 46 48 RendererType RendererType 47 49 Sanitizer Sanitizer 50 + Files fs.FS 48 51 } 49 52 50 - func (rctx *RenderContext) RenderMarkdown(source string) string { 53 + func NewMarkdown() goldmark.Markdown { 51 54 md := goldmark.New( 52 55 goldmark.WithExtensions( 53 56 extension.GFM, ··· 61 64 extension.NewFootnote( 62 65 extension.WithFootnoteIDPrefix([]byte("footnote")), 63 66 ), 64 - treeblood.MathML(), 67 + callout.CalloutExtention, 68 + textension.AtExt, 65 69 ), 66 70 goldmark.WithParserOptions( 67 71 parser.WithAutoHeadingID(), 68 72 ), 69 73 goldmark.WithRendererOptions(html.WithUnsafe()), 70 74 ) 75 + return md 76 + } 71 77 78 + func (rctx *RenderContext) RenderMarkdown(source string) string { 79 + return rctx.RenderMarkdownWith(source, NewMarkdown()) 80 + } 81 + 82 + func (rctx *RenderContext) RenderMarkdownWith(source string, md goldmark.Markdown) string { 72 83 if rctx != nil { 73 84 var transformers []util.PrioritizedValue 74 85 ··· 140 151 func visitNode(ctx *RenderContext, node *htmlparse.Node) { 141 152 switch node.Type { 142 153 case htmlparse.ElementNode: 143 - if node.Data == "img" || node.Data == "source" { 154 + switch node.Data { 155 + case "img", "source": 144 156 for i, attr := range node.Attr { 145 157 if attr.Key != "src" { 146 158 continue ··· 235 247 repoName := fmt.Sprintf("%s/%s", rctx.RepoInfo.OwnerDid, rctx.RepoInfo.Name) 236 248 237 249 query := fmt.Sprintf("repo=%s&ref=%s&path=%s&raw=true", 238 - url.PathEscape(repoName), url.PathEscape(rctx.RepoInfo.Ref), actualPath) 250 + url.QueryEscape(repoName), url.QueryEscape(rctx.RepoInfo.Ref), actualPath) 239 251 240 252 parsedURL := &url.URL{ 241 253 Scheme: scheme,
+124
appview/pages/markup/reference_link.go
··· 1 + package markup 2 + 3 + import ( 4 + "maps" 5 + "net/url" 6 + "path" 7 + "slices" 8 + "strconv" 9 + "strings" 10 + 11 + "github.com/yuin/goldmark/ast" 12 + "github.com/yuin/goldmark/text" 13 + "tangled.org/core/appview/models" 14 + textension "tangled.org/core/appview/pages/markup/extension" 15 + ) 16 + 17 + // FindReferences collects all links referencing tangled-related objects 18 + // like issues, PRs, comments or even @-mentions 19 + // This funciton doesn't actually check for the existence of records in the DB 20 + // or the PDS; it merely returns a list of what are presumed to be references. 21 + func FindReferences(baseUrl string, source string) ([]string, []models.ReferenceLink) { 22 + var ( 23 + refLinkSet = make(map[models.ReferenceLink]struct{}) 24 + mentionsSet = make(map[string]struct{}) 25 + md = NewMarkdown() 26 + sourceBytes = []byte(source) 27 + root = md.Parser().Parse(text.NewReader(sourceBytes)) 28 + ) 29 + // trim url scheme. the SSL shouldn't matter 30 + baseUrl = strings.TrimPrefix(baseUrl, "https://") 31 + baseUrl = strings.TrimPrefix(baseUrl, "http://") 32 + 33 + ast.Walk(root, func(n ast.Node, entering bool) (ast.WalkStatus, error) { 34 + if !entering { 35 + return ast.WalkContinue, nil 36 + } 37 + switch n.Kind() { 38 + case textension.KindAt: 39 + handle := n.(*textension.AtNode).Handle 40 + mentionsSet[handle] = struct{}{} 41 + return ast.WalkSkipChildren, nil 42 + case ast.KindLink: 43 + dest := string(n.(*ast.Link).Destination) 44 + ref := parseTangledLink(baseUrl, dest) 45 + if ref != nil { 46 + refLinkSet[*ref] = struct{}{} 47 + } 48 + return ast.WalkSkipChildren, nil 49 + case ast.KindAutoLink: 50 + an := n.(*ast.AutoLink) 51 + if an.AutoLinkType == ast.AutoLinkURL { 52 + dest := string(an.URL(sourceBytes)) 53 + ref := parseTangledLink(baseUrl, dest) 54 + if ref != nil { 55 + refLinkSet[*ref] = struct{}{} 56 + } 57 + } 58 + return ast.WalkSkipChildren, nil 59 + } 60 + return ast.WalkContinue, nil 61 + }) 62 + mentions := slices.Collect(maps.Keys(mentionsSet)) 63 + references := slices.Collect(maps.Keys(refLinkSet)) 64 + return mentions, references 65 + } 66 + 67 + func parseTangledLink(baseHost string, urlStr string) *models.ReferenceLink { 68 + u, err := url.Parse(urlStr) 69 + if err != nil { 70 + return nil 71 + } 72 + 73 + if u.Host != "" && !strings.EqualFold(u.Host, baseHost) { 74 + return nil 75 + } 76 + 77 + p := path.Clean(u.Path) 78 + parts := strings.FieldsFunc(p, func(r rune) bool { return r == '/' }) 79 + if len(parts) < 4 { 80 + // need at least: handle / repo / kind / id 81 + return nil 82 + } 83 + 84 + var ( 85 + handle = parts[0] 86 + repo = parts[1] 87 + kindSeg = parts[2] 88 + subjectSeg = parts[3] 89 + ) 90 + 91 + handle = strings.TrimPrefix(handle, "@") 92 + 93 + var kind models.RefKind 94 + switch kindSeg { 95 + case "issues": 96 + kind = models.RefKindIssue 97 + case "pulls": 98 + kind = models.RefKindPull 99 + default: 100 + return nil 101 + } 102 + 103 + subjectId, err := strconv.Atoi(subjectSeg) 104 + if err != nil { 105 + return nil 106 + } 107 + var commentId *int 108 + if u.Fragment != "" { 109 + if strings.HasPrefix(u.Fragment, "comment-") { 110 + commentIdStr := u.Fragment[len("comment-"):] 111 + if id, err := strconv.Atoi(commentIdStr); err == nil { 112 + commentId = &id 113 + } 114 + } 115 + } 116 + 117 + return &models.ReferenceLink{ 118 + Handle: handle, 119 + Repo: repo, 120 + Kind: kind, 121 + SubjectId: subjectId, 122 + CommentId: commentId, 123 + } 124 + }
+6
appview/pages/markup/sanitizer.go
··· 77 77 policy.AllowAttrs("class").Matching(regexp.MustCompile(`heading`)).OnElements("h1", "h2", "h3", "h4", "h5", "h6", "h7", "h8") 78 78 policy.AllowAttrs("class").Matching(regexp.MustCompile(strings.Join(slices.Collect(maps.Values(chroma.StandardTypes)), "|"))).OnElements("span") 79 79 80 + // at-mentions 81 + policy.AllowAttrs("class").Matching(regexp.MustCompile(`mention`)).OnElements("a") 82 + 80 83 // centering content 81 84 policy.AllowElements("center") 82 85 ··· 113 116 } 114 117 policy.AllowNoAttrs().OnElements(mathElements...) 115 118 policy.AllowAttrs(mathAttrs...).OnElements(mathElements...) 119 + 120 + // goldmark-callout 121 + policy.AllowAttrs("data-callout").OnElements("details") 116 122 117 123 return policy 118 124 }
+99 -169
appview/pages/pages.go
··· 1 1 package pages 2 2 3 3 import ( 4 - "bytes" 5 4 "crypto/sha256" 6 5 "embed" 7 6 "encoding/hex" ··· 15 14 "path/filepath" 16 15 "strings" 17 16 "sync" 17 + "time" 18 18 19 19 "tangled.org/core/api/tangled" 20 20 "tangled.org/core/appview/commitverify" ··· 28 28 "tangled.org/core/patchutil" 29 29 "tangled.org/core/types" 30 30 31 - "github.com/alecthomas/chroma/v2" 32 - chromahtml "github.com/alecthomas/chroma/v2/formatters/html" 33 - "github.com/alecthomas/chroma/v2/lexers" 34 - "github.com/alecthomas/chroma/v2/styles" 35 31 "github.com/bluesky-social/indigo/atproto/identity" 36 32 "github.com/bluesky-social/indigo/atproto/syntax" 37 33 "github.com/go-git/go-git/v5/plumbing" 38 - "github.com/go-git/go-git/v5/plumbing/object" 39 34 ) 40 35 41 36 //go:embed templates/* static legal ··· 54 49 logger *slog.Logger 55 50 } 56 51 57 - func NewPages(config *config.Config, res *idresolver.Resolver) *Pages { 52 + func NewPages(config *config.Config, res *idresolver.Resolver, logger *slog.Logger) *Pages { 58 53 // initialized with safe defaults, can be overriden per use 59 54 rctx := &markup.RenderContext{ 60 55 IsDev: config.Core.Dev, 61 56 CamoUrl: config.Camo.Host, 62 57 CamoSecret: config.Camo.SharedSecret, 63 58 Sanitizer: markup.NewSanitizer(), 59 + Files: Files, 64 60 } 65 61 66 62 p := &Pages{ ··· 71 67 rctx: rctx, 72 68 resolver: res, 73 69 templateDir: "appview/pages", 74 - logger: slog.Default().With("component", "pages"), 70 + logger: logger, 75 71 } 76 72 77 73 if p.dev { ··· 220 216 221 217 type LoginParams struct { 222 218 ReturnUrl string 219 + ErrorCode string 223 220 } 224 221 225 222 func (p *Pages) Login(w io.Writer, params LoginParams) error { ··· 409 406 type KnotsParams struct { 410 407 LoggedInUser *oauth.User 411 408 Registrations []models.Registration 409 + Tabs []map[string]any 410 + Tab string 412 411 } 413 412 414 413 func (p *Pages) Knots(w io.Writer, params KnotsParams) error { ··· 421 420 Members []string 422 421 Repos map[string][]models.Repo 423 422 IsOwner bool 423 + Tabs []map[string]any 424 + Tab string 424 425 } 425 426 426 427 func (p *Pages) Knot(w io.Writer, params KnotParams) error { ··· 438 439 type SpindlesParams struct { 439 440 LoggedInUser *oauth.User 440 441 Spindles []models.Spindle 442 + Tabs []map[string]any 443 + Tab string 441 444 } 442 445 443 446 func (p *Pages) Spindles(w io.Writer, params SpindlesParams) error { ··· 446 449 447 450 type SpindleListingParams struct { 448 451 models.Spindle 452 + Tabs []map[string]any 453 + Tab string 449 454 } 450 455 451 456 func (p *Pages) SpindleListing(w io.Writer, params SpindleListingParams) error { ··· 457 462 Spindle models.Spindle 458 463 Members []string 459 464 Repos map[string][]models.Repo 465 + Tabs []map[string]any 466 + Tab string 460 467 } 461 468 462 469 func (p *Pages) SpindleDashboard(w io.Writer, params SpindleDashboardParams) error { ··· 484 491 485 492 type ProfileCard struct { 486 493 UserDid string 487 - UserHandle string 488 494 FollowStatus models.FollowStatus 489 495 Punchcard *models.Punchcard 490 496 Profile *models.Profile ··· 627 633 return p.executePlain("user/fragments/editPins", w, params) 628 634 } 629 635 630 - type RepoStarFragmentParams struct { 636 + type StarBtnFragmentParams struct { 631 637 IsStarred bool 632 - RepoAt syntax.ATURI 633 - Stats models.RepoStats 638 + SubjectAt syntax.ATURI 639 + StarCount int 634 640 } 635 641 636 - func (p *Pages) RepoStarFragment(w io.Writer, params RepoStarFragmentParams) error { 637 - return p.executePlain("repo/fragments/repoStar", w, params) 638 - } 639 - 640 - type RepoDescriptionParams struct { 641 - RepoInfo repoinfo.RepoInfo 642 - } 643 - 644 - func (p *Pages) EditRepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error { 645 - return p.executePlain("repo/fragments/editRepoDescription", w, params) 646 - } 647 - 648 - func (p *Pages) RepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error { 649 - return p.executePlain("repo/fragments/repoDescription", w, params) 642 + func (p *Pages) StarBtnFragment(w io.Writer, params StarBtnFragmentParams) error { 643 + return p.executePlain("fragments/starBtn-oob", w, params) 650 644 } 651 645 652 646 type RepoIndexParams struct { ··· 654 648 RepoInfo repoinfo.RepoInfo 655 649 Active string 656 650 TagMap map[string][]string 657 - CommitsTrunc []*object.Commit 651 + CommitsTrunc []types.Commit 658 652 TagsTrunc []*types.TagReference 659 653 BranchesTrunc []types.Branch 660 654 // ForkInfo *types.ForkInfo 661 - HTMLReadme template.HTML 662 - Raw bool 663 - EmailToDidOrHandle map[string]string 664 - VerifiedCommits commitverify.VerifiedCommits 665 - Languages []types.RepoLanguageDetails 666 - Pipelines map[string]models.Pipeline 667 - NeedsKnotUpgrade bool 655 + HTMLReadme template.HTML 656 + Raw bool 657 + EmailToDid map[string]string 658 + VerifiedCommits commitverify.VerifiedCommits 659 + Languages []types.RepoLanguageDetails 660 + Pipelines map[string]models.Pipeline 661 + NeedsKnotUpgrade bool 668 662 types.RepoIndexResponse 669 663 } 670 664 ··· 699 693 } 700 694 701 695 type RepoLogParams struct { 702 - LoggedInUser *oauth.User 703 - RepoInfo repoinfo.RepoInfo 704 - TagMap map[string][]string 696 + LoggedInUser *oauth.User 697 + RepoInfo repoinfo.RepoInfo 698 + TagMap map[string][]string 699 + Active string 700 + EmailToDid map[string]string 701 + VerifiedCommits commitverify.VerifiedCommits 702 + Pipelines map[string]models.Pipeline 703 + 705 704 types.RepoLogResponse 706 - Active string 707 - EmailToDidOrHandle map[string]string 708 - VerifiedCommits commitverify.VerifiedCommits 709 - Pipelines map[string]models.Pipeline 710 705 } 711 706 712 707 func (p *Pages) RepoLog(w io.Writer, params RepoLogParams) error { ··· 715 710 } 716 711 717 712 type RepoCommitParams struct { 718 - LoggedInUser *oauth.User 719 - RepoInfo repoinfo.RepoInfo 720 - Active string 721 - EmailToDidOrHandle map[string]string 722 - Pipeline *models.Pipeline 723 - DiffOpts types.DiffOpts 713 + LoggedInUser *oauth.User 714 + RepoInfo repoinfo.RepoInfo 715 + Active string 716 + EmailToDid map[string]string 717 + Pipeline *models.Pipeline 718 + DiffOpts types.DiffOpts 724 719 725 720 // singular because it's always going to be just one 726 721 VerifiedCommit commitverify.VerifiedCommits ··· 752 747 func (r RepoTreeParams) TreeStats() RepoTreeStats { 753 748 numFolders, numFiles := 0, 0 754 749 for _, f := range r.Files { 755 - if !f.IsFile { 750 + if !f.IsFile() { 756 751 numFolders += 1 757 - } else if f.IsFile { 752 + } else if f.IsFile() { 758 753 numFiles += 1 759 754 } 760 755 } ··· 825 820 } 826 821 827 822 type RepoBlobParams struct { 828 - LoggedInUser *oauth.User 829 - RepoInfo repoinfo.RepoInfo 830 - Active string 831 - Unsupported bool 832 - IsImage bool 833 - IsVideo bool 834 - ContentSrc string 835 - BreadCrumbs [][]string 836 - ShowRendered bool 837 - RenderToggle bool 838 - RenderedContents template.HTML 823 + LoggedInUser *oauth.User 824 + RepoInfo repoinfo.RepoInfo 825 + Active string 826 + BreadCrumbs [][]string 827 + BlobView models.BlobView 839 828 *tangled.RepoBlob_Output 840 - // Computed fields for template compatibility 841 - Contents string 842 - Lines int 843 - SizeHint uint64 844 - IsBinary bool 845 829 } 846 830 847 831 func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error { 848 - var style *chroma.Style = styles.Get("catpuccin-latte") 849 - 850 - if params.ShowRendered { 851 - switch markup.GetFormat(params.Path) { 852 - case markup.FormatMarkdown: 853 - p.rctx.RepoInfo = params.RepoInfo 854 - p.rctx.RendererType = markup.RendererTypeRepoMarkdown 855 - htmlString := p.rctx.RenderMarkdown(params.Contents) 856 - sanitized := p.rctx.SanitizeDefault(htmlString) 857 - params.RenderedContents = template.HTML(sanitized) 858 - } 859 - } 860 - 861 - c := params.Contents 862 - formatter := chromahtml.New( 863 - chromahtml.InlineCode(false), 864 - chromahtml.WithLineNumbers(true), 865 - chromahtml.WithLinkableLineNumbers(true, "L"), 866 - chromahtml.Standalone(false), 867 - chromahtml.WithClasses(true), 868 - ) 869 - 870 - lexer := lexers.Get(filepath.Base(params.Path)) 871 - if lexer == nil { 872 - lexer = lexers.Fallback 873 - } 874 - 875 - iterator, err := lexer.Tokenise(nil, c) 876 - if err != nil { 877 - return fmt.Errorf("chroma tokenize: %w", err) 878 - } 879 - 880 - var code bytes.Buffer 881 - err = formatter.Format(&code, style, iterator) 882 - if err != nil { 883 - return fmt.Errorf("chroma format: %w", err) 832 + switch params.BlobView.ContentType { 833 + case models.BlobContentTypeMarkup: 834 + p.rctx.RepoInfo = params.RepoInfo 884 835 } 885 836 886 - params.Contents = code.String() 887 837 params.Active = "overview" 888 838 return p.executeRepo("repo/blob", w, params) 889 839 } 890 840 891 841 type Collaborator struct { 892 - Did string 893 - Handle string 894 - Role string 842 + Did string 843 + Role string 895 844 } 896 845 897 846 type RepoSettingsParams struct { ··· 966 915 RepoInfo repoinfo.RepoInfo 967 916 Active string 968 917 Issues []models.Issue 918 + IssueCount int 969 919 LabelDefs map[string]*models.LabelDefinition 970 920 Page pagination.Page 971 921 FilteringByOpen bool 922 + FilterQuery string 972 923 } 973 924 974 925 func (p *Pages) RepoIssues(w io.Writer, params RepoIssuesParams) error { ··· 982 933 Active string 983 934 Issue *models.Issue 984 935 CommentList []models.CommentListItem 936 + Backlinks []models.RichReferenceLink 985 937 LabelDefs map[string]*models.LabelDefinition 986 938 987 939 OrderedReactionKinds []models.ReactionKind ··· 1099 1051 Pulls []*models.Pull 1100 1052 Active string 1101 1053 FilteringBy models.PullState 1054 + FilterQuery string 1102 1055 Stacks map[string]models.Stack 1103 1056 Pipelines map[string]models.Pipeline 1104 1057 LabelDefs map[string]*models.LabelDefinition ··· 1128 1081 } 1129 1082 1130 1083 type RepoSinglePullParams struct { 1131 - LoggedInUser *oauth.User 1132 - RepoInfo repoinfo.RepoInfo 1133 - Active string 1134 - Pull *models.Pull 1135 - Stack models.Stack 1136 - AbandonedPulls []*models.Pull 1137 - MergeCheck types.MergeCheckResponse 1138 - ResubmitCheck ResubmitResult 1139 - Pipelines map[string]models.Pipeline 1084 + LoggedInUser *oauth.User 1085 + RepoInfo repoinfo.RepoInfo 1086 + Active string 1087 + Pull *models.Pull 1088 + Stack models.Stack 1089 + AbandonedPulls []*models.Pull 1090 + Backlinks []models.RichReferenceLink 1091 + BranchDeleteStatus *models.BranchDeleteStatus 1092 + MergeCheck types.MergeCheckResponse 1093 + ResubmitCheck ResubmitResult 1094 + Pipelines map[string]models.Pipeline 1140 1095 1141 1096 OrderedReactionKinds []models.ReactionKind 1142 1097 Reactions map[models.ReactionKind]models.ReactionDisplayData ··· 1232 1187 } 1233 1188 1234 1189 type PullActionsParams struct { 1235 - LoggedInUser *oauth.User 1236 - RepoInfo repoinfo.RepoInfo 1237 - Pull *models.Pull 1238 - RoundNumber int 1239 - MergeCheck types.MergeCheckResponse 1240 - ResubmitCheck ResubmitResult 1241 - Stack models.Stack 1190 + LoggedInUser *oauth.User 1191 + RepoInfo repoinfo.RepoInfo 1192 + Pull *models.Pull 1193 + RoundNumber int 1194 + MergeCheck types.MergeCheckResponse 1195 + ResubmitCheck ResubmitResult 1196 + BranchDeleteStatus *models.BranchDeleteStatus 1197 + Stack models.Stack 1242 1198 } 1243 1199 1244 1200 func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error { ··· 1303 1259 return p.executePlain("repo/fragments/compareAllowPull", w, params) 1304 1260 } 1305 1261 1306 - type RepoCompareDiffParams struct { 1307 - LoggedInUser *oauth.User 1308 - RepoInfo repoinfo.RepoInfo 1309 - Diff types.NiceDiff 1262 + type RepoCompareDiffFragmentParams struct { 1263 + Diff types.NiceDiff 1264 + DiffOpts types.DiffOpts 1310 1265 } 1311 1266 1312 - func (p *Pages) RepoCompareDiff(w io.Writer, params RepoCompareDiffParams) error { 1313 - return p.executePlain("repo/fragments/diff", w, []any{params.RepoInfo.FullName, &params.Diff}) 1267 + func (p *Pages) RepoCompareDiffFragment(w io.Writer, params RepoCompareDiffFragmentParams) error { 1268 + return p.executePlain("repo/fragments/diff", w, []any{&params.Diff, &params.DiffOpts}) 1314 1269 } 1315 1270 1316 1271 type LabelPanelParams struct { ··· 1354 1309 Name string 1355 1310 Command string 1356 1311 Collapsed bool 1312 + StartTime time.Time 1357 1313 } 1358 1314 1359 1315 func (p *Pages) LogBlock(w io.Writer, params LogBlockParams) error { 1360 1316 return p.executePlain("repo/pipelines/fragments/logBlock", w, params) 1361 1317 } 1362 1318 1319 + type LogBlockEndParams struct { 1320 + Id int 1321 + StartTime time.Time 1322 + EndTime time.Time 1323 + } 1324 + 1325 + func (p *Pages) LogBlockEnd(w io.Writer, params LogBlockEndParams) error { 1326 + return p.executePlain("repo/pipelines/fragments/logBlockEnd", w, params) 1327 + } 1328 + 1363 1329 type LogLineParams struct { 1364 1330 Id int 1365 1331 Content string ··· 1419 1385 ShowRendered bool 1420 1386 RenderToggle bool 1421 1387 RenderedContents template.HTML 1422 - String models.String 1388 + String *models.String 1423 1389 Stats models.StringStats 1390 + IsStarred bool 1391 + StarCount int 1424 1392 Owner identity.Identity 1425 1393 } 1426 1394 1427 1395 func (p *Pages) SingleString(w io.Writer, params SingleStringParams) error { 1428 - var style *chroma.Style = styles.Get("catpuccin-latte") 1429 - 1430 - if params.ShowRendered { 1431 - switch markup.GetFormat(params.String.Filename) { 1432 - case markup.FormatMarkdown: 1433 - p.rctx.RendererType = markup.RendererTypeRepoMarkdown 1434 - htmlString := p.rctx.RenderMarkdown(params.String.Contents) 1435 - sanitized := p.rctx.SanitizeDefault(htmlString) 1436 - params.RenderedContents = template.HTML(sanitized) 1437 - } 1438 - } 1439 - 1440 - c := params.String.Contents 1441 - formatter := chromahtml.New( 1442 - chromahtml.InlineCode(false), 1443 - chromahtml.WithLineNumbers(true), 1444 - chromahtml.WithLinkableLineNumbers(true, "L"), 1445 - chromahtml.Standalone(false), 1446 - chromahtml.WithClasses(true), 1447 - ) 1448 - 1449 - lexer := lexers.Get(filepath.Base(params.String.Filename)) 1450 - if lexer == nil { 1451 - lexer = lexers.Fallback 1452 - } 1453 - 1454 - iterator, err := lexer.Tokenise(nil, c) 1455 - if err != nil { 1456 - return fmt.Errorf("chroma tokenize: %w", err) 1457 - } 1458 - 1459 - var code bytes.Buffer 1460 - err = formatter.Format(&code, style, iterator) 1461 - if err != nil { 1462 - return fmt.Errorf("chroma format: %w", err) 1463 - } 1464 - 1465 - params.String.Contents = code.String() 1466 1396 return p.execute("strings/string", w, params) 1467 1397 } 1468 1398 ··· 1475 1405 return http.StripPrefix("/static/", http.FileServer(http.Dir("appview/pages/static"))) 1476 1406 } 1477 1407 1478 - sub, err := fs.Sub(Files, "static") 1408 + sub, err := fs.Sub(p.embedFS, "static") 1479 1409 if err != nil { 1480 1410 p.logger.Error("no static dir found? that's crazy", "err", err) 1481 1411 panic(err) ··· 1498 1428 }) 1499 1429 } 1500 1430 1501 - func CssContentHash() string { 1502 - cssFile, err := Files.Open("static/tw.css") 1431 + func (p *Pages) CssContentHash() string { 1432 + cssFile, err := p.embedFS.Open("static/tw.css") 1503 1433 if err != nil { 1504 1434 slog.Debug("Error opening CSS file", "err", err) 1505 1435 return ""
+27 -24
appview/pages/repoinfo/repoinfo.go
··· 4 4 "fmt" 5 5 "path" 6 6 "slices" 7 - "strings" 8 7 9 8 "github.com/bluesky-social/indigo/atproto/syntax" 9 + "tangled.org/core/api/tangled" 10 10 "tangled.org/core/appview/models" 11 11 "tangled.org/core/appview/state/userutil" 12 12 ) 13 13 14 - func (r RepoInfo) OwnerWithAt() string { 14 + func (r RepoInfo) owner() string { 15 15 if r.OwnerHandle != "" { 16 - return fmt.Sprintf("@%s", r.OwnerHandle) 16 + return r.OwnerHandle 17 17 } else { 18 18 return r.OwnerDid 19 19 } 20 20 } 21 21 22 22 func (r RepoInfo) FullName() string { 23 - return path.Join(r.OwnerWithAt(), r.Name) 23 + return path.Join(r.owner(), r.Name) 24 24 } 25 25 26 - func (r RepoInfo) OwnerWithoutAt() string { 27 - if after, ok := strings.CutPrefix(r.OwnerWithAt(), "@"); ok { 28 - return after 26 + func (r RepoInfo) ownerWithoutAt() string { 27 + if r.OwnerHandle != "" { 28 + return r.OwnerHandle 29 29 } else { 30 30 return userutil.FlattenDid(r.OwnerDid) 31 31 } 32 32 } 33 33 34 34 func (r RepoInfo) FullNameWithoutAt() string { 35 - return path.Join(r.OwnerWithoutAt(), r.Name) 35 + return path.Join(r.ownerWithoutAt(), r.Name) 36 36 } 37 37 38 38 func (r RepoInfo) GetTabs() [][]string { ··· 48 48 } 49 49 50 50 return tabs 51 + } 52 + 53 + func (r RepoInfo) RepoAt() syntax.ATURI { 54 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", r.OwnerDid, tangled.RepoNSID, r.Rkey)) 51 55 } 52 56 53 57 type RepoInfo struct { 54 - Name string 55 - Rkey string 56 - OwnerDid string 57 - OwnerHandle string 58 - Description string 59 - Knot string 60 - Spindle string 61 - RepoAt syntax.ATURI 62 - IsStarred bool 63 - Stats models.RepoStats 64 - Roles RolesInRepo 65 - Source *models.Repo 66 - SourceHandle string 67 - Ref string 68 - DisableFork bool 69 - CurrentDir string 58 + Name string 59 + Rkey string 60 + OwnerDid string 61 + OwnerHandle string 62 + Description string 63 + Website string 64 + Topics []string 65 + Knot string 66 + Spindle string 67 + IsStarred bool 68 + Stats models.RepoStats 69 + Roles RolesInRepo 70 + Source *models.Repo 71 + Ref string 72 + CurrentDir string 70 73 } 71 74 72 75 // each tab on a repo could have some metadata:
+82 -54
appview/pages/templates/fragments/dolly/logo.html
··· 1 1 {{ define "fragments/dolly/logo" }} 2 - <svg 3 - version="1.1" 4 - id="svg1" 5 - class="{{.}}" 6 - width="25" 7 - height="25" 8 - viewBox="0 0 25 25" 9 - sodipodi:docname="tangled_dolly_face_only.png" 10 - xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" 11 - xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" 12 - xmlns:xlink="http://www.w3.org/1999/xlink" 13 - xmlns="http://www.w3.org/2000/svg" 14 - xmlns:svg="http://www.w3.org/2000/svg"> 15 - <title>Dolly</title> 16 - <defs 17 - id="defs1" /> 18 - <sodipodi:namedview 19 - id="namedview1" 20 - pagecolor="#ffffff" 21 - bordercolor="#000000" 22 - borderopacity="0.25" 23 - inkscape:showpageshadow="2" 24 - inkscape:pageopacity="0.0" 25 - inkscape:pagecheckerboard="true" 26 - inkscape:deskcolor="#d5d5d5"> 27 - <inkscape:page 28 - x="0" 29 - y="0" 30 - width="25" 31 - height="25" 32 - id="page2" 33 - margin="0" 34 - bleed="0" /> 35 - </sodipodi:namedview> 36 - <g 37 - inkscape:groupmode="layer" 38 - inkscape:label="Image" 39 - id="g1"> 40 - <image 41 - width="252.48" 42 - height="248.96001" 43 - preserveAspectRatio="none" 44 - xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAxUAAAMKCAYAAADznWlEAAABg2lDQ1BJQ0MgcHJvZmlsZQAAKJF9&#10;kT1Iw0AcxV9TpVoqDmYQcchQneyiIo61CkWoEGqFVh1MLv2CJi1Jiouj4Fpw8GOx6uDirKuDqyAI&#10;foC4C06KLlLi/5JCixgPjvvx7t7j7h0gNCtMt3rigG7YZjqZkLK5VSn0in6EISKGsMKs2pwsp+A7&#10;vu4R4OtdjGf5n/tzDGh5iwEBiTjOaqZNvEE8s2nXOO8Ti6ykaMTnxBMmXZD4keuqx2+ciy4LPFM0&#10;M+l5YpFYKnax2sWsZOrE08RRTTcoX8h6rHHe4qxX6qx9T/7CSN5YWeY6zVEksYglyJCgoo4yKrCp&#10;rzIMUiykaT/h4x9x/TK5VHKVwcixgCp0KK4f/A9+d2sVpia9pEgC6H1xnI8xILQLtBqO833sOK0T&#10;IPgMXBkdf7UJzH6S3uho0SNgcBu4uO5o6h5wuQMMP9UUU3GlIE2hUADez+ibcsDQLRBe83pr7+P0&#10;AchQV6kb4OAQGC9S9rrPu/u6e/v3TLu/H4tGcrDFxPPTAAAABmJLR0QAAAAAAAD5Q7t/AAAACXBI&#10;WXMAAC4jAAAuIwF4pT92AAAAB3RJTUUH6QkPFQ8jl/6e6wAAABl0RVh0Q29tbWVudABDcmVhdGVk&#10;IHdpdGggR0lNUFeBDhcAACAASURBVHic7N3nc5xXmqb564UjCXoripShvKNMqVSSynWXmZ6emd39&#10;NhH7R+6HjdiN6d3emZ7uru6ukqpKXUbeUKIMJZKitwBhzn64z1uZpEiJIAGkea9fRAZIgCCTQCLz&#10;3O95nueAJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmS&#10;JEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmS&#10;JEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmS&#10;JEmSJEmSJEmSJEmSJOkONYO+A5IkjYtSSgNM0Ht9LX036vvbj7fvX+77K/pfl0vTNAVJGgGGCkmS&#10;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&#10;U2QU7On6+9OkT6L5/hwwWxRFZ13erbQJGVRIknSFpqampn7961/vuf/++/ft27fvjsnJyVvKsrxp&#10;YWFh79LS0r52u33L2NjY9vHx8ZGhoSGSyNBVWgYuAB8Dh0h50wfACRJAnCc7J+bsmZDWj1c5SZKu&#10;0muvvVa0Wq3ij3/84/D09PT2bdu23XPnnXd+7Y477nhg7969e3fv3r1lbGxsBBiqqmq4LMtxYKrd&#10;bt/QarXGi6Jwq3eUZI/EMslILJKdEvMk63AceIfsljhMMhKz9d9f4m4bad05zkKSpKv06KOPVkA1&#10;MjKy9KMf/ejM+Pj4+bIs36iqarjT6QzNzMwMTU9PD09PT08uLCzsHB0dvWPbtm1f3bVr1wOTk5P7&#10;hoeHd5CSqWbpXtME3qLu1+j5vp97Nio+2wfRvJpAYh64RHZJNFuuT5JsxCf199Ok/Gmp/nMGEtIG&#10;0q8XJ0mS+snQD3/4w5Ef//jHE/fdd9/Wffv2TW3ZsmViaGhotCzL4bIsR1qt1mir1RpvtVpbgJ3A&#10;rcC++utNwBSfDUD6Qe9kpjlSxnSW9D+crl9n69dFElhcIlmIebKUbqH+8ys2XEsbl0GFJEnrr7V/&#10;//6he++9d2R8fHyCBBA3kuBiB7ANGCdlVC2gKMuyqKqqVZZlM962KIqiaLVaFEXRvJrMRxsYrf8Z&#10;k/XXYYCyLKuyLKmqqirLsvefUbXb7SaT0DQ8T9TvZ0/93qZI1UNJt1zpEgkQLtSvi6Tv4QIpZTpf&#10;f21+b5YEHIskC2HwIPUhgwpJkvpPb5lUqyiK1k9/+tPioYceKu6++2727dvH1q1bi8nJyWJoaKhF&#10;HvzHSRAwBdxAPalqaWmJxcXFanl5uVpZWSlarVZreHiYkZGRamxsbLlufl6u/503ALcAdwN3kgzK&#10;KAkoLvKPWYhpulmIWbpZhw4JRD77H2U5k9S37KmQJKn/VEDnBz/4QTk1NcXu3bu5/fbb2b59O+Pj&#10;4wwNDdFkLGoFyRAUn3v9g38yqar3Qb8gQcRWkknZTrIgcyRDMUMChyVghbr3ge5uiQp7IaSBZKZC&#10;kiR9aVVVNVmSofpV0G267gAdgwZJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ&#10;kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ&#10;kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ&#10;kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ&#10;kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ&#10;kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ&#10;kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJktbC&#10;/wcO9A7eMaXQEQAAAABJRU5ErkJggg==&#10;" 45 - id="image1" 46 - x="-233.6257" 47 - y="10.383364" 48 - style="display:none" /> 49 - <path 50 - fill="currentColor" 51 - style="stroke-width:0.111183" 52 - d="m 16.348974,24.09935 -0.06485,-0.03766 -0.202005,-0.0106 -0.202008,-0.01048 -0.275736,-0.02601 -0.275734,-0.02602 v -0.02649 -0.02648 l -0.204577,-0.04019 -0.204578,-0.04019 -0.167616,-0.08035 -0.167617,-0.08035 -0.0014,-0.04137 -0.0014,-0.04137 -0.266473,-0.143735 -0.266475,-0.143735 -0.276098,-0.20335 -0.2761,-0.203347 -0.262064,-0.251949 -0.262064,-0.25195 -0.22095,-0.284628 -0.220948,-0.284629 -0.170253,-0.284631 -0.170252,-0.284628 -0.01341,-0.0144 -0.0134,-0.0144 -0.141982,0.161297 -0.14198,0.1613 -0.22313,0.21426 -0.223132,0.214264 -0.186025,0.146053 -0.186023,0.14605 -0.252501,0.163342 -0.252502,0.163342 -0.249014,0.115348 -0.249013,0.115336 0.0053,0.03241 0.0053,0.03241 -0.1716725,0.04599 -0.171669,0.046 -0.3379966,0.101058 -0.3379972,0.101058 -0.1778925,0.04506 -0.1778935,0.04508 -0.3913655,0.02601 -0.3913643,0.02603 -0.3557868,-0.03514 -0.3557863,-0.03514 -0.037426,-0.03029 -0.037427,-0.03029 -0.076924,0.02011 -0.076924,0.02011 -0.050508,-0.05051 -0.050405,-0.05056 L 6.6604532,23.110188 6.451745,23.063961 6.1546135,22.960559 5.8574835,22.857156 5.5319879,22.694039 5.2064938,22.530922 4.8793922,22.302961 4.5522905,22.075005 4.247598,21.786585 3.9429055,21.49817 3.7185335,21.208777 3.4941628,20.919385 3.3669822,20.705914 3.239803,20.492443 3.1335213,20.278969 3.0272397,20.065499 2.9015252,19.7275 2.7758105,19.389504 2.6925225,18.998139 2.6092345,18.606774 2.6096814,17.91299 2.6101284,17.219208 2.6744634,16.90029 2.7387984,16.581374 2.8474286,16.242088 2.9560588,15.9028 3.1137374,15.583492 3.2714148,15.264182 3.3415068,15.150766 3.4115988,15.03735 3.3127798,14.96945 3.2139618,14.90157 3.0360685,14.800239 2.8581753,14.698908 2.5913347,14.503228 2.3244955,14.307547 2.0621238,14.055599 1.7997507,13.803651 1.6111953,13.56878 1.4226411,13.333906 1.2632237,13.087474 1.1038089,12.841042 0.97442,12.575195 0.8450307,12.30935 0.724603,11.971351 0.6041766,11.633356 0.52150365,11.241991 0.4388285,10.850626 0.44091592,10.156842 0.44300333,9.4630594 0.54235911,9.0369608 0.6417149,8.6108622 0.7741173,8.2694368 0.9065196,7.9280115 1.0736303,7.6214262 1.2407515,7.3148397 1.45931,7.0191718 1.6778685,6.7235039 1.9300326,6.4611321 2.1821966,6.1987592 2.4134579,6.0137228 2.6447193,5.8286865 2.8759792,5.6776409 3.1072406,5.526594 3.4282004,5.3713977 3.7491603,5.2162016 3.9263009,5.1508695 4.1034416,5.0855373 4.2813348,4.7481598 4.4592292,4.4107823 4.6718,4.108422 4.8843733,3.8060618 5.198353,3.4805372 5.5123313,3.155014 5.7685095,2.9596425 6.0246877,2.7642722 6.329187,2.5851365 6.6336863,2.406002 6.9497657,2.2751596 7.2658453,2.1443184 7.4756394,2.0772947 7.6854348,2.01027 8.0825241,1.931086 8.4796139,1.851902 l 0.5870477,0.00291 0.5870469,0.00291 0.4447315,0.092455 0.444734,0.092455 0.302419,0.1105495 0.302417,0.1105495 0.329929,0.1646046 0.32993,0.1646033 0.239329,-0.2316919 0.239329,-0.2316919 0.160103,-0.1256767 0.160105,-0.1256767 0.160102,-0.1021909 0.160105,-0.1021899 0.142315,-0.082328 0.142314,-0.082328 0.231262,-0.1090091 0.231259,-0.1090091 0.26684,-0.098743 0.266839,-0.098743 0.320208,-0.073514 0.320209,-0.073527 0.355787,-0.041833 0.355785,-0.041834 0.426942,0.023827 0.426945,0.023828 0.355785,0.071179 0.355788,0.0711791 0.284627,0.09267 0.284629,0.09267 0.28514,0.1310267 0.28514,0.1310255 0.238179,0.1446969 0.238174,0.1446979 0.259413,0.1955332 0.259413,0.1955319 0.290757,0.296774 0.290758,0.2967753 0.151736,0.1941581 0.151734,0.1941594 0.135326,0.2149951 0.135327,0.2149952 0.154755,0.3202073 0.154758,0.3202085 0.09409,0.2677358 0.09409,0.267737 0.06948,0.3319087 0.06948,0.3319099 0.01111,0.00808 0.01111,0.00808 0.444734,0.2173653 0.444734,0.2173665 0.309499,0.2161102 0.309497,0.2161101 0.309694,0.2930023 0.309694,0.2930037 0.18752,0.2348726 0.187524,0.2348727 0.166516,0.2574092 0.166519,0.2574108 0.15273,0.3260252 0.152734,0.3260262 0.08972,0.2668403 0.08971,0.2668391 0.08295,0.3913655 0.08295,0.3913652 -6.21e-4,0.6582049 -6.21e-4,0.658204 -0.06362,0.315725 -0.06362,0.315725 -0.09046,0.289112 -0.09046,0.289112 -0.122759,0.281358 -0.12276,0.281356 -0.146626,0.252323 -0.146629,0.252322 -0.190443,0.258668 -0.190448,0.258671 -0.254911,0.268356 -0.254911,0.268355 -0.286872,0.223127 -0.286874,0.223127 -0.320203,0.187693 -0.320209,0.187693 -0.04347,0.03519 -0.04347,0.03521 0.0564,0.12989 0.0564,0.129892 0.08728,0.213472 0.08728,0.213471 0.189755,0.729363 0.189753,0.729362 0.0652,0.302417 0.0652,0.302419 -0.0018,0.675994 -0.0018,0.675995 -0.0801,0.373573 -0.08009,0.373577 -0.09,0.266839 -0.09,0.26684 -0.190389,0.391364 -0.19039,0.391366 -0.223169,0.320207 -0.223167,0.320209 -0.303585,0.315294 -0.303584,0.315291 -0.284631,0.220665 -0.284629,0.220663 -0.220128,0.132359 -0.220127,0.132358 -0.242395,0.106698 -0.242394,0.106699 -0.08895,0.04734 -0.08895,0.04733 -0.249052,0.07247 -0.24905,0.07247 -0.322042,0.0574 -0.322044,0.0574 -0.282794,-0.003 -0.282795,-0.003 -0.07115,-0.0031 -0.07115,-0.0031 -0.177894,-0.0033 -0.177893,-0.0033 -0.124528,0.02555 -0.124528,0.02555 z m -4.470079,-5.349839 0.214838,-0.01739 0.206601,-0.06782 0.206602,-0.06782 0.244389,-0.117874 0.244393,-0.11786 0.274473,-0.206822 0.27447,-0.20682 0.229308,-0.257201 0.229306,-0.2572 0.219161,-0.28463 0.219159,-0.284629 0.188541,-0.284628 0.188543,-0.28463 0.214594,-0.373574 0.214593,-0.373577 0.133861,-0.312006 0.133865,-0.312007 0.02861,-0.01769 0.02861,-0.01769 0.197275,0.26212 0.197278,0.262119 0.163613,0.150814 0.163614,0.150814 0.201914,0.09276 0.201914,0.09276 0.302417,0.01421 0.302418,0.01421 0.213472,-0.08025 0.213471,-0.08025 0.200606,-0.204641 0.200606,-0.204642 0.09242,-0.278887 0.09241,-0.278888 0.05765,-0.302418 0.05764,-0.302416 L 18.41327,13.768114 18.39502,13.34117 18.31849,12.915185 18.24196,12.4892 18.15595,12.168033 18.06994,11.846867 17.928869,11.444534 17.787801,11.042201 17.621278,10.73296 17.454757,10.423723 17.337388,10.263619 17.220021,10.103516 17.095645,9.9837986 16.971268,9.8640816 16.990048,9.6813736 17.008828,9.4986654 16.947568,9.249616 16.886308,9.0005655 16.752419,8.7159355 16.618521,8.4313217 16.435707,8.2294676 16.252892,8.0276114 16.079629,7.9004245 15.906366,7.773238 l -0.20429,0.1230127 -0.204289,0.1230121 -0.26702,0.059413 -0.267022,0.059413 -0.205761,-0.021508 -0.205766,-0.021508 -0.23495,-0.08844 -0.234953,-0.08844 -0.118429,-0.090334 -0.118428,-0.090333 h -0.03944 -0.03944 L 13.711268,7.8540732 13.655958,7.9706205 13.497227,8.1520709 13.338499,8.3335203 13.168394,8.4419112 12.998289,8.550301 12.777045,8.624223 12.5558,8.698155 H 12.275611 11.995429 L 11.799973,8.6309015 11.604513,8.5636472 11.491311,8.5051061 11.37811,8.446565 11.138172,8.2254579 10.898231,8.0043497 l -0.09565,-0.084618 -0.09565,-0.084613 -0.218822,0.198024 -0.218822,0.1980231 -0.165392,0.078387 -0.1653925,0.078387 -0.177894,0.047948 -0.177892,0.047948 L 9.3635263,8.4842631 9.144328,8.4846889 8.9195029,8.4147138 8.6946778,8.3447386 8.5931214,8.4414036 8.491565,8.5380686 8.3707618,8.7019598 8.2499597,8.8658478 8.0802403,8.9290726 7.9105231,8.9922974 7.7952769,9.0780061 7.6800299,9.1637148 7.5706169,9.2778257 7.4612038,9.3919481 7.1059768,9.9205267 6.7507497,10.449105 l -0.2159851,0.449834 -0.2159839,0.449834 -0.2216572,0.462522 -0.2216559,0.462523 -0.1459343,0.337996 -0.1459342,0.337998 -0.055483,0.220042 -0.055483,0.220041 -0.015885,0.206903 -0.015872,0.206901 0.034307,0.242939 0.034307,0.24294 0.096281,0.196632 0.096281,0.196634 0.143607,0.125222 0.1436071,0.125222 0.1873143,0.08737 0.1873141,0.08737 0.2752084,0.002 0.2752084,0.002 0.2312297,-0.09773 0.231231,-0.09772 0.1067615,-0.07603 0.1067614,-0.07603 0.3679062,-0.29377 0.3679065,-0.293771 0.026804,0.01656 0.026804,0.01656 0.023626,0.466819 0.023626,0.466815 0.088326,0.513195 0.088326,0.513193 0.08897,0.364413 0.08897,0.364411 0.1315362,0.302418 0.1315352,0.302418 0.1051964,0.160105 0.1051954,0.160103 0.1104741,0.11877 0.1104731,0.118769 0.2846284,0.205644 0.2846305,0.205642 0.144448,0.07312 0.144448,0.07312 0.214787,0.05566 0.214787,0.05566 0.245601,0.03075 0.245602,0.03075 0.204577,-0.0125 0.204578,-0.0125 z m 0.686342,-3.497495 -0.11281,-0.06077 -0.106155,-0.134033 -0.106155,-0.134031 -0.04406,-0.18371 -0.04406,-0.183707 0.02417,-0.553937 0.02417,-0.553936 0.03513,-0.426945 0.03513,-0.426942 0.07225,-0.373576 0.07225,-0.373575 0.05417,-0.211338 0.05417,-0.211339 0.0674,-0.132112 0.0674,-0.132112 0.132437,-0.10916 0.132437,-0.109161 0.187436,-0.04195 0.187438,-0.04195 0.170366,0.06469 0.170364,0.06469 0.114312,0.124073 0.114313,0.124086 0.04139,0.18495 0.04139,0.184951 -0.111218,0.459845 -0.111219,0.459844 -0.03383,0.26584 -0.03382,0.265841 -0.03986,0.818307 -0.03986,0.818309 -0.0378,0.15162 -0.03779,0.151621 -0.11089,0.110562 -0.110891,0.110561 -0.114489,0.04913 -0.114489,0.04913 -0.187932,-0.0016 -0.187929,-0.0016 z m -2.8087655,-0.358124 -0.146445,-0.06848 -0.088025,-0.119502 -0.088024,-0.119502 -0.038581,-0.106736 -0.038581,-0.106736 -0.02237,-0.134956 -0.02239,-0.134957 -0.031955,-0.46988 -0.031955,-0.469881 0.036203,-0.444733 0.036203,-0.444731 0.048862,-0.215257 0.048862,-0.215255 0.076082,-0.203349 0.076081,-0.203348 0.0936,-0.111244 0.0936,-0.111245 0.143787,-0.06531 0.1437865,-0.06532 h 0.142315 0.142314 l 0.142314,0.06588 0.142316,0.06588 0.093,0.102325 0.093,0.102325 0.04042,0.120942 0.04042,0.120942 v 0.152479 0.152477 l -0.03347,0.08804 -0.03347,0.08805 -0.05693,0.275653 -0.05693,0.275651 2.11e-4,0.430246 2.12e-4,0.430243 0.04294,0.392646 0.04295,0.392647 -0.09189,0.200702 -0.09189,0.200702 -0.148688,0.0984 -0.148687,0.0984 -0.20136,0.01212 -0.2013595,0.01212 z" 53 - id="path4" /> 54 - </g> 55 - </svg> 2 + <svg 3 + version="1.1" 4 + id="svg1" 5 + class="{{ . }}" 6 + width="25" 7 + height="25" 8 + viewBox="0 0 25 25" 9 + sodipodi:docname="tangled_dolly_face_only_black_on_trans.svg" 10 + inkscape:export-filename="tangled_logotype_black_on_trans.svg" 11 + inkscape:export-xdpi="96" 12 + inkscape:export-ydpi="96" 13 + inkscape:version="1.4 (e7c3feb100, 2024-10-09)" 14 + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" 15 + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" 16 + xmlns="http://www.w3.org/2000/svg" 17 + xmlns:svg="http://www.w3.org/2000/svg" 18 + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" 19 + xmlns:cc="http://creativecommons.org/ns#"> 20 + <sodipodi:namedview 21 + id="namedview1" 22 + pagecolor="#ffffff" 23 + bordercolor="#000000" 24 + borderopacity="0.25" 25 + inkscape:showpageshadow="2" 26 + inkscape:pageopacity="0.0" 27 + inkscape:pagecheckerboard="true" 28 + inkscape:deskcolor="#d5d5d5" 29 + inkscape:zoom="45.254834" 30 + inkscape:cx="3.1377863" 31 + inkscape:cy="8.9382717" 32 + inkscape:window-width="3840" 33 + inkscape:window-height="2160" 34 + inkscape:window-x="0" 35 + inkscape:window-y="0" 36 + inkscape:window-maximized="0" 37 + inkscape:current-layer="g1" 38 + borderlayer="true"> 39 + <inkscape:page 40 + x="0" 41 + y="0" 42 + width="25" 43 + height="25" 44 + id="page2" 45 + margin="0" 46 + bleed="0" /> 47 + </sodipodi:namedview> 48 + <g 49 + inkscape:groupmode="layer" 50 + inkscape:label="Image" 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" 58 + sodipodi:nodetypes="sccccccccccccccccccsscccccccccsccccccccccccccccccccccc" /> 59 + </g> 60 + <metadata 61 + id="metadata1"> 62 + <rdf:RDF> 63 + <cc:Work 64 + rdf:about=""> 65 + <cc:license 66 + rdf:resource="http://creativecommons.org/licenses/by/4.0/" /> 67 + </cc:Work> 68 + <cc:License 69 + rdf:about="http://creativecommons.org/licenses/by/4.0/"> 70 + <cc:permits 71 + rdf:resource="http://creativecommons.org/ns#Reproduction" /> 72 + <cc:permits 73 + rdf:resource="http://creativecommons.org/ns#Distribution" /> 74 + <cc:requires 75 + rdf:resource="http://creativecommons.org/ns#Notice" /> 76 + <cc:requires 77 + rdf:resource="http://creativecommons.org/ns#Attribution" /> 78 + <cc:permits 79 + rdf:resource="http://creativecommons.org/ns#DerivativeWorks" /> 80 + </cc:License> 81 + </rdf:RDF> 82 + </metadata> 83 + </svg> 56 84 {{ end }}
+60 -22
appview/pages/templates/fragments/dolly/silhouette.html
··· 2 2 <svg 3 3 version="1.1" 4 4 id="svg1" 5 - width="32" 6 - height="32" 5 + width="25" 6 + height="25" 7 7 viewBox="0 0 25 25" 8 - sodipodi:docname="tangled_dolly_silhouette.png" 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)" 9 13 xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" 10 14 xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" 11 15 xmlns="http://www.w3.org/2000/svg" 12 - xmlns:svg="http://www.w3.org/2000/svg"> 13 - <style> 14 - .dolly { 15 - color: #000000; 16 - } 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 + } 17 23 18 - @media (prefers-color-scheme: dark) { 19 - .dolly { 20 - color: #ffffff; 21 - } 22 - } 23 - </style> 24 - <title>Dolly</title> 25 - <defs 26 - id="defs1" /> 24 + @media (prefers-color-scheme: dark) { 25 + .dolly { 26 + color: #ffffff; 27 + } 28 + } 29 + </style> 27 30 <sodipodi:namedview 28 31 id="namedview1" 29 32 pagecolor="#ffffff" ··· 32 35 inkscape:showpageshadow="2" 33 36 inkscape:pageopacity="0.0" 34 37 inkscape:pagecheckerboard="true" 35 - inkscape:deskcolor="#d1d1d1"> 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"> 36 49 <inkscape:page 37 50 x="0" 38 51 y="0" ··· 45 58 <g 46 59 inkscape:groupmode="layer" 47 60 inkscape:label="Image" 48 - id="g1"> 61 + id="g1" 62 + transform="translate(-0.42924038,-0.87777209)"> 49 63 <path 50 64 class="dolly" 51 65 fill="currentColor" 52 - style="stroke-width:1.12248" 53 - d="m 16.208435,23.914069 c -0.06147,-0.02273 -0.147027,-0.03034 -0.190158,-0.01691 -0.197279,0.06145 -1.31068,-0.230493 -1.388819,-0.364153 -0.01956,-0.03344 -0.163274,-0.134049 -0.319377,-0.223561 -0.550395,-0.315603 -1.010951,-0.696643 -1.428383,-1.181771 -0.264598,-0.307509 -0.597257,-0.785384 -0.597257,-0.857979 0,-0.0216 -0.02841,-0.06243 -0.06313,-0.0907 -0.04977,-0.04053 -0.160873,0.0436 -0.52488,0.397463 -0.479803,0.466432 -0.78924,0.689475 -1.355603,0.977118 -0.183693,0.0933 -0.323426,0.179989 -0.310516,0.192658 0.02801,0.02748 -0.7656391,0.270031 -1.209129,0.369517 -0.5378332,0.120647 -1.6341809,0.08626 -1.9721503,-0.06186 C 6.7977157,23.031391 6.56735,22.957551 6.3371134,22.889782 4.9717169,22.487902 3.7511914,21.481518 3.1172396,20.234838 2.6890391,19.392772 2.5582276,18.827446 2.5610489,17.831154 2.5639589,16.802192 2.7366641,16.125844 3.2142117,15.273187 3.3040457,15.112788 3.3713143,14.976533 3.3636956,14.9704 3.3560756,14.9643 3.2459634,14.90305 3.1189994,14.834381 1.7582586,14.098312 0.77760984,12.777439 0.44909837,11.23818 0.33531456,10.705039 0.33670119,9.7067968 0.45195381,9.1778795 0.72259241,7.9359287 1.3827188,6.8888436 2.4297498,6.0407205 2.6856126,5.8334648 3.2975489,5.4910878 3.6885849,5.3364049 L 4.0584319,5.190106 4.2333984,4.860432 C 4.8393906,3.7186139 5.8908314,2.7968028 7.1056396,2.3423025 7.7690673,2.0940921 8.2290216,2.0150935 9.01853,2.0137575 c 0.9625627,-0.00163 1.629181,0.1532762 2.485864,0.5776514 l 0.271744,0.1346134 0.42911,-0.3607688 c 1.082666,-0.9102346 2.185531,-1.3136811 3.578383,-1.3090327 0.916696,0.00306 1.573918,0.1517893 2.356121,0.5331927 1.465948,0.7148 2.54506,2.0625628 2.865177,3.57848 l 0.07653,0.362429 0.515095,0.2556611 c 1.022872,0.5076874 1.756122,1.1690944 2.288361,2.0641468 0.401896,0.6758594 0.537303,1.0442682 0.675505,1.8378683 0.288575,1.6570823 -0.266229,3.3548023 -1.490464,4.5608743 -0.371074,0.36557 -0.840205,0.718265 -1.203442,0.904754 -0.144112,0.07398 -0.271303,0.15826 -0.282647,0.187269 -0.01134,0.02901 0.02121,0.142764 0.07234,0.25279 0.184248,0.396467 0.451371,1.331823 0.619371,2.168779 0.463493,2.30908 -0.754646,4.693707 -2.92278,5.721632 -0.479538,0.227352 -0.717629,0.309322 -1.144194,0.39393 -0.321869,0.06383 -1.850573,0.09139 -2.000174,0.03604 z M 12.25443,18.636956 c 0.739923,-0.24652 1.382521,-0.718922 1.874623,-1.37812 0.0752,-0.100718 0.213883,-0.275851 0.308198,-0.389167 0.09432,-0.113318 0.210136,-0.271056 0.257381,-0.350531 0.416347,-0.700389 0.680936,-1.176102 0.766454,-1.378041 0.05594,-0.132087 0.114653,-0.239607 0.130477,-0.238929 0.01583,6.79e-4 0.08126,0.08531 0.145412,0.188069 0.178029,0.285173 0.614305,0.658998 0.868158,0.743878 0.259802,0.08686 0.656158,0.09598 0.911369,0.02095 0.213812,-0.06285 0.507296,-0.298016 0.645179,-0.516947 0.155165,-0.246374 0.327989,-0.989595 0.327989,-1.410501 0,-1.26718 -0.610975,-3.143405 -1.237774,-3.801045 -0.198483,-0.2082486 -0.208557,-0.2319396 -0.208557,-0.4904655 0,-0.2517771 -0.08774,-0.5704927 -0.258476,-0.938956 C 16.694963,8.50313 16.375697,8.1377479 16.135846,7.9543702 L 15.932296,7.7987471 15.683004,7.9356529 C 15.131767,8.2383821 14.435638,8.1945733 13.943459,7.8261812 L 13.782862,7.7059758 13.686773,7.8908012 C 13.338849,8.5600578 12.487087,8.8811064 11.743178,8.6233891 11.487199,8.5347109 11.358897,8.4505994 11.063189,8.1776138 L 10.69871,7.8411436 10.453484,8.0579255 C 10.318608,8.1771557 10.113778,8.3156283 9.9983037,8.3656417 9.7041488,8.4930449 9.1808299,8.5227884 8.8979004,8.4281886 8.7754792,8.3872574 8.6687415,8.3537661 8.6607053,8.3537661 c -0.03426,0 -0.3092864,0.3066098 -0.3791974,0.42275 -0.041935,0.069664 -0.1040482,0.1266636 -0.1380294,0.1266636 -0.1316419,0 -0.4197402,0.1843928 -0.6257041,0.4004735 -0.1923125,0.2017571 -0.6853701,0.9036038 -0.8926582,1.2706578 -0.042662,0.07554 -0.1803555,0.353687 -0.3059848,0.618091 -0.1256293,0.264406 -0.3270073,0.686768 -0.4475067,0.938581 -0.1204992,0.251816 -0.2469926,0.519654 -0.2810961,0.595199 -0.2592829,0.574347 -0.285919,1.391094 -0.057822,1.77304 0.1690683,0.283105 0.4224039,0.480895 0.7285507,0.568809 0.487122,0.139885 0.9109638,-0.004 1.6013422,-0.543768 l 0.4560939,-0.356568 0.0036,0.172041 c 0.01635,0.781837 0.1831084,1.813183 0.4016641,2.484154 0.1160449,0.356262 0.3781448,0.83968 0.5614081,1.035462 0.2171883,0.232025 0.7140951,0.577268 1.0100284,0.701749 0.121485,0.0511 0.351032,0.110795 0.510105,0.132647 0.396966,0.05452 1.2105,0.02265 1.448934,-0.05679 z" 54 - id="path1" /> 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" /> 55 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> 56 94 </svg> 57 95 {{ end }}
-44
appview/pages/templates/fragments/dolly/silhouette.svg
··· 1 - <svg 2 - version="1.1" 3 - id="svg1" 4 - width="32" 5 - height="32" 6 - viewBox="0 0 25 25" 7 - sodipodi:docname="tangled_dolly_silhouette.png" 8 - xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" 9 - xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" 10 - xmlns="http://www.w3.org/2000/svg" 11 - xmlns:svg="http://www.w3.org/2000/svg"> 12 - <title>Dolly</title> 13 - <defs 14 - id="defs1" /> 15 - <sodipodi:namedview 16 - id="namedview1" 17 - pagecolor="#ffffff" 18 - bordercolor="#000000" 19 - borderopacity="0.25" 20 - inkscape:showpageshadow="2" 21 - inkscape:pageopacity="0.0" 22 - inkscape:pagecheckerboard="true" 23 - inkscape:deskcolor="#d1d1d1"> 24 - <inkscape:page 25 - x="0" 26 - y="0" 27 - width="25" 28 - height="25" 29 - id="page2" 30 - margin="0" 31 - bleed="0" /> 32 - </sodipodi:namedview> 33 - <g 34 - inkscape:groupmode="layer" 35 - inkscape:label="Image" 36 - id="g1"> 37 - <path 38 - class="dolly" 39 - fill="currentColor" 40 - style="stroke-width:1.12248" 41 - d="m 16.208435,23.914069 c -0.06147,-0.02273 -0.147027,-0.03034 -0.190158,-0.01691 -0.197279,0.06145 -1.31068,-0.230493 -1.388819,-0.364153 -0.01956,-0.03344 -0.163274,-0.134049 -0.319377,-0.223561 -0.550395,-0.315603 -1.010951,-0.696643 -1.428383,-1.181771 -0.264598,-0.307509 -0.597257,-0.785384 -0.597257,-0.857979 0,-0.0216 -0.02841,-0.06243 -0.06313,-0.0907 -0.04977,-0.04053 -0.160873,0.0436 -0.52488,0.397463 -0.479803,0.466432 -0.78924,0.689475 -1.355603,0.977118 -0.183693,0.0933 -0.323426,0.179989 -0.310516,0.192658 0.02801,0.02748 -0.7656391,0.270031 -1.209129,0.369517 -0.5378332,0.120647 -1.6341809,0.08626 -1.9721503,-0.06186 C 6.7977157,23.031391 6.56735,22.957551 6.3371134,22.889782 4.9717169,22.487902 3.7511914,21.481518 3.1172396,20.234838 2.6890391,19.392772 2.5582276,18.827446 2.5610489,17.831154 2.5639589,16.802192 2.7366641,16.125844 3.2142117,15.273187 3.3040457,15.112788 3.3713143,14.976533 3.3636956,14.9704 3.3560756,14.9643 3.2459634,14.90305 3.1189994,14.834381 1.7582586,14.098312 0.77760984,12.777439 0.44909837,11.23818 0.33531456,10.705039 0.33670119,9.7067968 0.45195381,9.1778795 0.72259241,7.9359287 1.3827188,6.8888436 2.4297498,6.0407205 2.6856126,5.8334648 3.2975489,5.4910878 3.6885849,5.3364049 L 4.0584319,5.190106 4.2333984,4.860432 C 4.8393906,3.7186139 5.8908314,2.7968028 7.1056396,2.3423025 7.7690673,2.0940921 8.2290216,2.0150935 9.01853,2.0137575 c 0.9625627,-0.00163 1.629181,0.1532762 2.485864,0.5776514 l 0.271744,0.1346134 0.42911,-0.3607688 c 1.082666,-0.9102346 2.185531,-1.3136811 3.578383,-1.3090327 0.916696,0.00306 1.573918,0.1517893 2.356121,0.5331927 1.465948,0.7148 2.54506,2.0625628 2.865177,3.57848 l 0.07653,0.362429 0.515095,0.2556611 c 1.022872,0.5076874 1.756122,1.1690944 2.288361,2.0641468 0.401896,0.6758594 0.537303,1.0442682 0.675505,1.8378683 0.288575,1.6570823 -0.266229,3.3548023 -1.490464,4.5608743 -0.371074,0.36557 -0.840205,0.718265 -1.203442,0.904754 -0.144112,0.07398 -0.271303,0.15826 -0.282647,0.187269 -0.01134,0.02901 0.02121,0.142764 0.07234,0.25279 0.184248,0.396467 0.451371,1.331823 0.619371,2.168779 0.463493,2.30908 -0.754646,4.693707 -2.92278,5.721632 -0.479538,0.227352 -0.717629,0.309322 -1.144194,0.39393 -0.321869,0.06383 -1.850573,0.09139 -2.000174,0.03604 z M 12.25443,18.636956 c 0.739923,-0.24652 1.382521,-0.718922 1.874623,-1.37812 0.0752,-0.100718 0.213883,-0.275851 0.308198,-0.389167 0.09432,-0.113318 0.210136,-0.271056 0.257381,-0.350531 0.416347,-0.700389 0.680936,-1.176102 0.766454,-1.378041 0.05594,-0.132087 0.114653,-0.239607 0.130477,-0.238929 0.01583,6.79e-4 0.08126,0.08531 0.145412,0.188069 0.178029,0.285173 0.614305,0.658998 0.868158,0.743878 0.259802,0.08686 0.656158,0.09598 0.911369,0.02095 0.213812,-0.06285 0.507296,-0.298016 0.645179,-0.516947 0.155165,-0.246374 0.327989,-0.989595 0.327989,-1.410501 0,-1.26718 -0.610975,-3.143405 -1.237774,-3.801045 -0.198483,-0.2082486 -0.208557,-0.2319396 -0.208557,-0.4904655 0,-0.2517771 -0.08774,-0.5704927 -0.258476,-0.938956 C 16.694963,8.50313 16.375697,8.1377479 16.135846,7.9543702 L 15.932296,7.7987471 15.683004,7.9356529 C 15.131767,8.2383821 14.435638,8.1945733 13.943459,7.8261812 L 13.782862,7.7059758 13.686773,7.8908012 C 13.338849,8.5600578 12.487087,8.8811064 11.743178,8.6233891 11.487199,8.5347109 11.358897,8.4505994 11.063189,8.1776138 L 10.69871,7.8411436 10.453484,8.0579255 C 10.318608,8.1771557 10.113778,8.3156283 9.9983037,8.3656417 9.7041488,8.4930449 9.1808299,8.5227884 8.8979004,8.4281886 8.7754792,8.3872574 8.6687415,8.3537661 8.6607053,8.3537661 c -0.03426,0 -0.3092864,0.3066098 -0.3791974,0.42275 -0.041935,0.069664 -0.1040482,0.1266636 -0.1380294,0.1266636 -0.1316419,0 -0.4197402,0.1843928 -0.6257041,0.4004735 -0.1923125,0.2017571 -0.6853701,0.9036038 -0.8926582,1.2706578 -0.042662,0.07554 -0.1803555,0.353687 -0.3059848,0.618091 -0.1256293,0.264406 -0.3270073,0.686768 -0.4475067,0.938581 -0.1204992,0.251816 -0.2469926,0.519654 -0.2810961,0.595199 -0.2592829,0.574347 -0.285919,1.391094 -0.057822,1.77304 0.1690683,0.283105 0.4224039,0.480895 0.7285507,0.568809 0.487122,0.139885 0.9109638,-0.004 1.6013422,-0.543768 l 0.4560939,-0.356568 0.0036,0.172041 c 0.01635,0.781837 0.1831084,1.813183 0.4016641,2.484154 0.1160449,0.356262 0.3781448,0.83968 0.5614081,1.035462 0.2171883,0.232025 0.7140951,0.577268 1.0100284,0.701749 0.121485,0.0511 0.351032,0.110795 0.510105,0.132647 0.396966,0.05452 1.2105,0.02265 1.448934,-0.05679 z" 42 - id="path1" /> 43 - </g> 44 - </svg>
+5
appview/pages/templates/fragments/starBtn-oob.html
··· 1 + {{ define "fragments/starBtn-oob" }} 2 + <div hx-swap-oob='outerHTML:#starBtn[data-star-subject-at="{{ .SubjectAt }}"]'> 3 + {{ template "fragments/starBtn" . }} 4 + </div> 5 + {{ end }}
+26
appview/pages/templates/fragments/starBtn.html
··· 1 + {{ define "fragments/starBtn" }} 2 + {{/* NOTE: this fragment is always replaced with hx-swap-oob */}} 3 + <button 4 + id="starBtn" 5 + class="btn disabled:opacity-50 disabled:cursor-not-allowed flex gap-2 items-center group" 6 + data-star-subject-at="{{ .SubjectAt }}" 7 + {{ if .IsStarred }} 8 + hx-delete="/star?subject={{ .SubjectAt }}&countHint={{ .StarCount }}" 9 + {{ else }} 10 + hx-post="/star?subject={{ .SubjectAt }}&countHint={{ .StarCount }}" 11 + {{ end }} 12 + 13 + hx-trigger="click" 14 + hx-disabled-elt="#starBtn" 15 + > 16 + {{ if .IsStarred }} 17 + {{ i "star" "w-4 h-4 fill-current" }} 18 + {{ else }} 19 + {{ i "star" "w-4 h-4" }} 20 + {{ end }} 21 + <span class="text-sm"> 22 + {{ .StarCount }} 23 + </span> 24 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 25 + </button> 26 + {{ end }}
+33
appview/pages/templates/fragments/tabSelector.html
··· 1 + {{ define "fragments/tabSelector" }} 2 + {{ $name := .Name }} 3 + {{ $all := .Values }} 4 + {{ $active := .Active }} 5 + {{ $include := .Include }} 6 + <div class="flex justify-between divide-x divide-gray-200 dark:divide-gray-700 rounded border border-gray-200 dark:border-gray-700 overflow-hidden"> 7 + {{ $activeTab := "bg-white dark:bg-gray-700 shadow-sm" }} 8 + {{ $inactiveTab := "bg-gray-100 dark:bg-gray-800 shadow-inner" }} 9 + {{ range $index, $value := $all }} 10 + {{ $isActive := eq $value.Key $active }} 11 + <a href="?{{ $name }}={{ $value.Key }}" 12 + {{ if $include }} 13 + hx-get="?{{ $name }}={{ $value.Key }}" 14 + hx-include="{{ $include }}" 15 + hx-push-url="true" 16 + hx-target="body" 17 + hx-on:htmx:config-request="if(!event.detail.parameters.q) delete event.detail.parameters.q" 18 + {{ end }} 19 + class="p-2 whitespace-nowrap flex justify-center items-center gap-2 text-sm w-full block hover:no-underline text-center {{ if $isActive }} {{$activeTab }} {{ else }} {{ $inactiveTab }} {{ end }}"> 20 + {{ if $value.Icon }} 21 + {{ i $value.Icon "size-4" }} 22 + {{ end }} 23 + 24 + {{ with $value.Meta }} 25 + {{ . }} 26 + {{ end }} 27 + 28 + {{ $value.Value }} 29 + </a> 30 + {{ end }} 31 + </div> 32 + {{ end }} 33 +
+22
appview/pages/templates/fragments/tinyAvatarList.html
··· 1 + {{ define "fragments/tinyAvatarList" }} 2 + {{ $all := .all }} 3 + {{ $classes := .classes }} 4 + {{ $ps := take $all 5 }} 5 + <div class="inline-flex items-center -space-x-3"> 6 + {{ $c := "z-50 z-40 z-30 z-20 z-10" }} 7 + {{ range $i, $p := $ps }} 8 + <img 9 + src="{{ tinyAvatar . }}" 10 + alt="" 11 + class="rounded-full size-8 mr-1 border-2 border-gray-100 dark:border-gray-900 z-{{sub 5 $i}}0 {{ $classes }}" 12 + /> 13 + {{ end }} 14 + 15 + {{ if gt (len $all) 5 }} 16 + <span class="pl-4 text-gray-500 dark:text-gray-400 text-sm"> 17 + +{{ sub (len $all) 5 }} 18 + </span> 19 + {{ end }} 20 + </div> 21 + {{ end }} 22 +
+36
appview/pages/templates/fragments/workflow-timers.html
··· 1 + {{ define "fragments/workflow-timers" }} 2 + <script> 3 + function formatElapsed(seconds) { 4 + if (seconds < 1) return '0s'; 5 + if (seconds < 60) return `${seconds}s`; 6 + const minutes = Math.floor(seconds / 60); 7 + const secs = seconds % 60; 8 + if (seconds < 3600) return `${minutes}m ${secs}s`; 9 + const hours = Math.floor(seconds / 3600); 10 + const mins = Math.floor((seconds % 3600) / 60); 11 + return `${hours}h ${mins}m`; 12 + } 13 + 14 + function updateTimers() { 15 + const now = Math.floor(Date.now() / 1000); 16 + 17 + document.querySelectorAll('[data-timer]').forEach(el => { 18 + const startTime = parseInt(el.dataset.start); 19 + const endTime = el.dataset.end ? parseInt(el.dataset.end) : null; 20 + 21 + if (endTime) { 22 + // Step is complete, show final time 23 + const elapsed = endTime - startTime; 24 + el.textContent = formatElapsed(elapsed); 25 + } else { 26 + // Step is running, update live 27 + const elapsed = now - startTime; 28 + el.textContent = formatElapsed(elapsed); 29 + } 30 + }); 31 + } 32 + 33 + setInterval(updateTimers, 1000); 34 + updateTimers(); 35 + </script> 36 + {{ end }}
+23 -7
appview/pages/templates/knots/dashboard.html
··· 1 - {{ define "title" }}{{ .Registration.Domain }} &middot; knots{{ end }} 1 + {{ define "title" }}{{ .Registration.Domain }} &middot; {{ .Tab }} settings{{ end }} 2 2 3 3 {{ define "content" }} 4 - <div class="px-6 py-4"> 4 + <div class="p-6"> 5 + <p class="text-xl font-bold dark:text-white">Settings</p> 6 + </div> 7 + <div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 8 + <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6"> 9 + <div class="col-span-1"> 10 + {{ template "user/settings/fragments/sidebar" . }} 11 + </div> 12 + <div class="col-span-1 md:col-span-3 flex flex-col gap-6"> 13 + {{ template "knotDash" . }} 14 + </div> 15 + </section> 16 + </div> 17 + {{ end }} 18 + 19 + {{ define "knotDash" }} 20 + <div> 5 21 <div class="flex justify-between items-center"> 6 - <h1 class="text-xl font-bold dark:text-white">{{ .Registration.Domain }}</h1> 22 + <h2 class="text-sm pb-2 uppercase font-bold">{{ .Tab }} &middot; {{ .Registration.Domain }}</h2> 7 23 <div id="right-side" class="flex gap-2"> 8 24 {{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2" }} 9 25 {{ $isOwner := and .LoggedInUser (eq .LoggedInUser.Did .Registration.ByDid) }} ··· 35 51 </div> 36 52 37 53 {{ if .Members }} 38 - <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 54 + <section class="bg-white dark:bg-gray-800 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 39 55 <div class="flex flex-col gap-2"> 40 56 {{ block "member" . }} {{ end }} 41 57 </div> ··· 79 95 <button 80 96 class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 81 97 title="Delete knot" 82 - hx-delete="/knots/{{ .Domain }}" 98 + hx-delete="/settings/knots/{{ .Domain }}" 83 99 hx-swap="outerHTML" 84 100 hx-confirm="Are you sure you want to delete the knot '{{ .Domain }}'?" 85 101 hx-headers='{"shouldRedirect": "true"}' ··· 95 111 <button 96 112 class="btn gap-2 group" 97 113 title="Retry knot verification" 98 - hx-post="/knots/{{ .Domain }}/retry" 114 + hx-post="/settings/knots/{{ .Domain }}/retry" 99 115 hx-swap="none" 100 116 hx-headers='{"shouldRefresh": "true"}' 101 117 > ··· 113 129 <button 114 130 class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 115 131 title="Remove member" 116 - hx-post="/knots/{{ $root.Registration.Domain }}/remove" 132 + hx-post="/settings/knots/{{ $root.Registration.Domain }}/remove" 117 133 hx-swap="none" 118 134 hx-vals='{"member": "{{$member}}" }' 119 135 hx-confirm="Are you sure you want to remove {{ $memberHandle }} from this knot?"
+18 -10
appview/pages/templates/knots/fragments/addMemberModal.html
··· 13 13 <div 14 14 id="add-member-{{ .Id }}" 15 15 popover 16 - class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 16 + class=" 17 + bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50 18 + w-full md:w-96 p-4 rounded drop-shadow overflow-visible"> 17 19 {{ block "addKnotMemberPopover" . }} {{ end }} 18 20 </div> 19 21 {{ end }} 20 22 21 23 {{ define "addKnotMemberPopover" }} 22 24 <form 23 - hx-post="/knots/{{ .Domain }}/add" 25 + hx-post="/settings/knots/{{ .Domain }}/add" 24 26 hx-indicator="#spinner" 25 27 hx-swap="none" 26 28 class="flex flex-col gap-2" ··· 29 31 ADD MEMBER 30 32 </label> 31 33 <p class="text-sm text-gray-500 dark:text-gray-400">Members can create repositories and run workflows on this knot.</p> 32 - <input 33 - type="text" 34 - id="member-did-{{ .Id }}" 35 - name="member" 36 - required 37 - placeholder="@foo.bsky.social" 38 - /> 34 + <actor-typeahead> 35 + <input 36 + autocapitalize="none" 37 + autocorrect="off" 38 + autocomplete="off" 39 + type="text" 40 + id="member-did-{{ .Id }}" 41 + name="member" 42 + required 43 + placeholder="user.tngl.sh" 44 + class="w-full" 45 + /> 46 + </actor-typeahead> 39 47 <div class="flex gap-2 pt-2"> 40 48 <button 41 49 type="button" ··· 54 62 </div> 55 63 <div id="add-member-error-{{ .Id }}" class="text-red-500 dark:text-red-400"></div> 56 64 </form> 57 - {{ end }} 65 + {{ end }}
+3 -3
appview/pages/templates/knots/fragments/knotListing.html
··· 7 7 8 8 {{ define "knotLeftSide" }} 9 9 {{ if .Registered }} 10 - <a href="/knots/{{ .Domain }}" class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]"> 10 + <a href="/settings/knots/{{ .Domain }}" class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]"> 11 11 {{ i "hard-drive" "w-4 h-4" }} 12 12 <span class="hover:underline"> 13 13 {{ .Domain }} ··· 56 56 <button 57 57 class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 58 58 title="Delete knot" 59 - hx-delete="/knots/{{ .Domain }}" 59 + hx-delete="/settings/knots/{{ .Domain }}" 60 60 hx-swap="outerHTML" 61 61 hx-target="#knot-{{.Id}}" 62 62 hx-confirm="Are you sure you want to delete the knot '{{ .Domain }}'?" ··· 72 72 <button 73 73 class="btn gap-2 group" 74 74 title="Retry knot verification" 75 - hx-post="/knots/{{ .Domain }}/retry" 75 + hx-post="/settings/knots/{{ .Domain }}/retry" 76 76 hx-swap="none" 77 77 hx-target="#knot-{{.Id}}" 78 78 >
+42 -11
appview/pages/templates/knots/index.html
··· 1 - {{ define "title" }}knots{{ end }} 1 + {{ define "title" }}{{ .Tab }} settings{{ end }} 2 2 3 3 {{ define "content" }} 4 - <div class="px-6 py-4 flex items-center justify-between gap-4 align-bottom"> 5 - <h1 class="text-xl font-bold dark:text-white">Knots</h1> 6 - <span class="flex items-center gap-1"> 7 - {{ i "book" "w-3 h-3" }} 8 - <a href="https://tangled.org/@tangled.org/core/blob/master/docs/knot-hosting.md">docs</a> 9 - </span> 10 - </div> 4 + <div class="p-6"> 5 + <p class="text-xl font-bold dark:text-white">Settings</p> 6 + </div> 7 + <div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 8 + <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6"> 9 + <div class="col-span-1"> 10 + {{ template "user/settings/fragments/sidebar" . }} 11 + </div> 12 + <div class="col-span-1 md:col-span-3 flex flex-col gap-6"> 13 + {{ template "knotsList" . }} 14 + </div> 15 + </section> 16 + </div> 17 + {{ end }} 18 + 19 + {{ define "knotsList" }} 20 + <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 21 + <div class="col-span-1 md:col-span-2"> 22 + <h2 class="text-sm pb-2 uppercase font-bold">Knots</h2> 23 + {{ block "about" . }} {{ end }} 24 + </div> 25 + <div class="col-span-1 md:col-span-1 md:justify-self-end"> 26 + {{ template "docsButton" . }} 27 + </div> 28 + </div> 11 29 12 - <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 30 + <section> 13 31 <div class="flex flex-col gap-6"> 14 - {{ block "about" . }} {{ end }} 15 32 {{ block "list" . }} {{ end }} 16 33 {{ block "register" . }} {{ end }} 17 34 </div> ··· 50 67 <h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">register a knot</h2> 51 68 <p class="mb-2 dark:text-gray-300">Enter the hostname of your knot to get started.</p> 52 69 <form 53 - hx-post="/knots/register" 70 + hx-post="/settings/knots/register" 54 71 class="max-w-2xl mb-2 space-y-4" 55 72 hx-indicator="#register-button" 56 73 hx-swap="none" ··· 84 101 85 102 </section> 86 103 {{ end }} 104 + 105 + {{ define "docsButton" }} 106 + <a 107 + class="btn flex items-center gap-2" 108 + href="https://tangled.org/@tangled.org/core/blob/master/docs/spindle/hosting.md"> 109 + {{ i "book" "size-4" }} 110 + docs 111 + </a> 112 + <div 113 + id="add-email-modal" 114 + popover 115 + class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 116 + </div> 117 + {{ end }}
+3 -2
appview/pages/templates/layouts/base.html
··· 9 9 10 10 <script defer src="/static/htmx.min.js"></script> 11 11 <script defer src="/static/htmx-ext-ws.min.js"></script> 12 + <script defer src="/static/actor-typeahead.js" type="module"></script> 12 13 13 14 <!-- preconnect to image cdn --> 14 15 <link rel="preconnect" href="https://avatar.tangled.sh" /> ··· 26 27 </head> 27 28 <body class="min-h-screen flex flex-col gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200"> 28 29 {{ block "topbarLayout" . }} 29 - <header class="w-full bg-white dark:bg-gray-800 col-span-full md:col-span-1 md:col-start-2" style="z-index: 20;"> 30 + <header class="w-full col-span-full md:col-span-1 md:col-start-2" style="z-index: 20;"> 30 31 31 32 {{ if .LoggedInUser }} 32 33 <div id="upgrade-banner" ··· 58 59 {{ end }} 59 60 60 61 {{ block "footerLayout" . }} 61 - <footer class="bg-white dark:bg-gray-800 mt-12"> 62 + <footer class="mt-12"> 62 63 {{ template "layouts/fragments/footer" . }} 63 64 </footer> 64 65 {{ end }}
+1 -1
appview/pages/templates/layouts/fragments/footer.html
··· 1 1 {{ define "layouts/fragments/footer" }} 2 - <div class="w-full p-8"> 2 + <div class="w-full p-8 bg-white dark:bg-gray-800"> 3 3 <div class="mx-auto px-4"> 4 4 <div class="flex flex-col text-gray-600 dark:text-gray-400 gap-8"> 5 5 <!-- Desktop layout: grid with 3 columns -->
+6 -12
appview/pages/templates/layouts/fragments/topbar.html
··· 1 1 {{ define "layouts/fragments/topbar" }} 2 - <nav class="mx-auto space-x-4 px-6 py-2 rounded-b dark:text-white drop-shadow-sm"> 2 + <nav class="mx-auto space-x-4 px-6 py-2 dark:text-white drop-shadow-sm bg-white dark:bg-gray-800"> 3 3 <div class="flex justify-between p-0 items-center"> 4 4 <div id="left-items"> 5 5 <a href="/" hx-boost="true" class="text-2xl no-underline hover:no-underline flex items-center gap-2"> ··· 15 15 {{ with .LoggedInUser }} 16 16 {{ block "newButton" . }} {{ end }} 17 17 {{ template "notifications/fragments/bell" }} 18 - {{ block "dropDown" . }} {{ end }} 18 + {{ block "profileDropdown" . }} {{ end }} 19 19 {{ else }} 20 20 <a href="/login">login</a> 21 21 <span class="text-gray-500 dark:text-gray-400">or</span> ··· 33 33 <summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2"> 34 34 {{ i "plus" "w-4 h-4" }} <span class="hidden md:inline">new</span> 35 35 </summary> 36 - <div class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700"> 36 + <div class="absolute flex flex-col right-0 mt-3 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700"> 37 37 <a href="/repo/new" class="flex items-center gap-2"> 38 38 {{ i "book-plus" "w-4 h-4" }} 39 39 new repository ··· 46 46 </details> 47 47 {{ end }} 48 48 49 - {{ define "dropDown" }} 49 + {{ define "profileDropdown" }} 50 50 <details class="relative inline-block text-left nav-dropdown"> 51 - <summary 52 - class="cursor-pointer list-none flex items-center gap-1" 53 - > 51 + <summary class="cursor-pointer list-none flex items-center gap-1"> 54 52 {{ $user := .Did }} 55 53 <img 56 54 src="{{ tinyAvatar $user }}" ··· 59 57 /> 60 58 <span class="hidden md:inline">{{ $user | resolve | truncateAt30 }}</span> 61 59 </summary> 62 - <div 63 - class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700" 64 - > 60 + <div class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700"> 65 61 <a href="/{{ $user }}">profile</a> 66 62 <a href="/{{ $user }}?tab=repos">repositories</a> 67 63 <a href="/{{ $user }}?tab=strings">strings</a> 68 - <a href="/knots">knots</a> 69 - <a href="/spindles">spindles</a> 70 64 <a href="/settings">settings</a> 71 65 <a href="#" 72 66 hx-post="/logout"
+14 -4
appview/pages/templates/layouts/profilebase.html
··· 1 - {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }}{{ end }} 1 + {{ define "title" }}{{ resolve .Card.UserDid }}{{ end }} 2 2 3 3 {{ define "extrameta" }} 4 - <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" /> 4 + {{ $handle := resolve .Card.UserDid }} 5 + {{ $avatarUrl := fullAvatar $handle }} 6 + <meta property="og:title" content="{{ $handle }}" /> 5 7 <meta property="og:type" content="profile" /> 6 - <meta property="og:url" content="https://tangled.org/{{ or .Card.UserHandle .Card.UserDid }}?tab={{ .Active }}" /> 7 - <meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 8 + <meta property="og:url" content="https://tangled.org/{{ $handle }}?tab={{ .Active }}" /> 9 + <meta property="og:description" content="{{ or .Card.Profile.Description $handle }}" /> 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 }}" /> 8 18 {{ end }} 9 19 10 20 {{ define "content" }}
+57 -26
appview/pages/templates/layouts/repobase.html
··· 1 1 {{ define "title" }}{{ .RepoInfo.FullName }}{{ end }} 2 2 3 3 {{ define "content" }} 4 - <section id="repo-header" class="mb-4 py-2 px-6 dark:text-white"> 5 - {{ if .RepoInfo.Source }} 6 - <p class="text-sm"> 7 - <div class="flex items-center"> 8 - {{ i "git-fork" "w-3 h-3 mr-1 shrink-0" }} 9 - forked from 10 - {{ $sourceOwner := didOrHandle .RepoInfo.Source.Did .RepoInfo.SourceHandle }} 11 - <a class="ml-1 underline" href="/{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}">{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}</a> 12 - </div> 13 - </p> 14 - {{ end }} 15 - <div class="text-lg flex items-center justify-between"> 16 - <div> 17 - <a href="/{{ .RepoInfo.OwnerWithAt }}">{{ .RepoInfo.OwnerWithAt }}</a> 18 - <span class="select-none">/</span> 19 - <a href="/{{ .RepoInfo.FullName }}" class="font-bold">{{ .RepoInfo.Name }}</a> 4 + <section id="repo-header" class="mb-4 p-2 dark:text-white"> 5 + <div class="text-lg flex flex-col sm:flex-row items-start gap-4 justify-between"> 6 + <!-- left items --> 7 + <div class="flex flex-col gap-2"> 8 + <!-- repo owner / repo name --> 9 + <div class="flex items-center gap-2 flex-wrap"> 10 + {{ template "user/fragments/picHandleLink" .RepoInfo.OwnerDid }} 11 + <span class="select-none">/</span> 12 + <a href="/{{ .RepoInfo.FullName }}" class="font-bold">{{ .RepoInfo.Name }}</a> 13 + </div> 14 + 15 + {{ if .RepoInfo.Source }} 16 + {{ $sourceOwner := resolve .RepoInfo.Source.Did }} 17 + <div class="flex items-center gap-1 text-sm flex-wrap"> 18 + {{ i "git-fork" "w-3 h-3 shrink-0" }} 19 + <span>forked from</span> 20 + <a class="underline" href="/{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}"> 21 + {{ $sourceOwner }}/{{ .RepoInfo.Source.Name }} 22 + </a> 23 + </div> 24 + {{ end }} 25 + 26 + <span class="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-gray-600 dark:text-gray-300"> 27 + {{ if .RepoInfo.Description }} 28 + {{ .RepoInfo.Description | description }} 29 + {{ else }} 30 + <span class="italic">this repo has no description</span> 31 + {{ end }} 32 + 33 + {{ with .RepoInfo.Website }} 34 + <span class="flex items-center gap-1"> 35 + <span class="flex-shrink-0">{{ i "globe" "size-4" }}</span> 36 + <a href="{{ . }}">{{ . | trimUriScheme }}</a> 37 + </span> 38 + {{ end }} 39 + 40 + {{ if .RepoInfo.Topics }} 41 + <div class="flex items-center gap-1 text-sm text-gray-600 dark:text-gray-300"> 42 + {{ range .RepoInfo.Topics }} 43 + <span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-sm">{{ . }}</span> 44 + {{ end }} 45 + </div> 46 + {{ end }} 47 + 48 + </span> 20 49 </div> 21 50 22 - <div class="flex items-center gap-2 z-auto"> 23 - <a 24 - class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group" 25 - href="/{{ .RepoInfo.FullName }}/feed.atom" 26 - > 27 - {{ i "rss" "size-4" }} 28 - </a> 29 - {{ template "repo/fragments/repoStar" .RepoInfo }} 51 + <div class="w-full sm:w-fit grid grid-cols-3 gap-2 z-auto"> 52 + {{ template "fragments/starBtn" 53 + (dict "SubjectAt" .RepoInfo.RepoAt 54 + "IsStarred" .RepoInfo.IsStarred 55 + "StarCount" .RepoInfo.Stats.StarCount) }} 30 56 <a 31 57 class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group" 32 58 hx-boost="true" ··· 36 62 fork 37 63 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 38 64 </a> 65 + <a 66 + class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group" 67 + href="/{{ .RepoInfo.FullName }}/feed.atom"> 68 + {{ i "rss" "size-4" }} 69 + <span class="md:hidden">atom</span> 70 + </a> 39 71 </div> 40 72 </div> 41 - {{ template "repo/fragments/repoDescription" . }} 42 73 </section> 43 74 44 75 <section class="w-full flex flex-col" > ··· 79 110 </div> 80 111 </nav> 81 112 {{ block "repoContentLayout" . }} 82 - <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white"> 113 + <section class="bg-white dark:bg-gray-800 px-6 py-4 rounded relative w-full mx-auto dark:text-white"> 83 114 {{ block "repoContent" . }}{{ end }} 84 115 </section> 85 116 {{ block "repoAfter" . }}{{ end }}
+11 -2
appview/pages/templates/notifications/fragments/item.html
··· 8 8 "> 9 9 {{ template "notificationIcon" . }} 10 10 <div class="flex-1 w-full flex flex-col gap-1"> 11 - <span>{{ template "notificationHeader" . }}</span> 11 + <div class="flex items-center gap-1"> 12 + <span>{{ template "notificationHeader" . }}</span> 13 + <span class="text-sm text-gray-500 dark:text-gray-400 before:content-['ยท'] before:select-none">{{ template "repo/fragments/shortTime" .Created }}</span> 14 + </div> 12 15 <span class="text-sm text-gray-500 dark:text-gray-400">{{ template "notificationSummary" . }}</span> 13 16 </div> 14 17 ··· 19 22 {{ define "notificationIcon" }} 20 23 <div class="flex-shrink-0 max-h-full w-16 h-16 relative"> 21 24 <img class="object-cover rounded-full p-2" src="{{ fullAvatar .ActorDid }}" /> 22 - <div class="absolute border-2 border-white dark:border-gray-800 bg-gray-200 dark:bg-gray-700 bottom-1 right-1 rounded-full p-2 flex items-center justify-center z-10"> 25 + <div class="absolute border-2 border-white dark:border-gray-800 bg-gray-200 dark:bg-gray-700 bottom-1 right-1 rounded-full p-1 flex items-center justify-center z-10"> 23 26 {{ i .Icon "size-3 text-black dark:text-white" }} 24 27 </div> 25 28 </div> ··· 37 40 commented on an issue 38 41 {{ else if eq .Type "issue_closed" }} 39 42 closed an issue 43 + {{ else if eq .Type "issue_reopen" }} 44 + reopened an issue 40 45 {{ else if eq .Type "pull_created" }} 41 46 created a pull request 42 47 {{ else if eq .Type "pull_commented" }} ··· 45 50 merged a pull request 46 51 {{ else if eq .Type "pull_closed" }} 47 52 closed a pull request 53 + {{ else if eq .Type "pull_reopen" }} 54 + reopened a pull request 48 55 {{ else if eq .Type "followed" }} 49 56 followed you 57 + {{ else if eq .Type "user_mentioned" }} 58 + mentioned you 50 59 {{ else }} 51 60 {{ end }} 52 61 {{ end }}
+64 -39
appview/pages/templates/repo/blob.html
··· 11 11 {{ end }} 12 12 13 13 {{ define "repoContent" }} 14 - {{ $lines := split .Contents }} 15 - {{ $tot_lines := len $lines }} 16 - {{ $tot_chars := len (printf "%d" $tot_lines) }} 17 - {{ $code_number_style := "text-gray-400 dark:text-gray-500 left-0 bg-white dark:bg-gray-800 text-right mr-6 select-none inline-block w-12" }} 18 14 {{ $linkstyle := "no-underline hover:underline" }} 19 15 <div class="pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700"> 20 16 <div class="flex flex-col md:flex-row md:justify-between gap-2"> ··· 36 32 </div> 37 33 <div id="file-info" class="text-gray-500 dark:text-gray-400 text-xs md:text-sm flex flex-wrap items-center gap-1 md:gap-0"> 38 34 <span>at <a href="/{{ .RepoInfo.FullName }}/tree/{{ .Ref }}">{{ .Ref }}</a></span> 39 - <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 40 - <span>{{ .Lines }} lines</span> 41 - <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 42 - <span>{{ byteFmt .SizeHint }}</span> 43 - <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 44 - <a href="/{{ .RepoInfo.FullName }}/raw/{{ .Ref }}/{{ .Path }}">view raw</a> 45 - {{ if .RenderToggle }} 46 - <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 47 - <a 48 - href="/{{ .RepoInfo.FullName }}/blob/{{ .Ref }}/{{ .Path }}?code={{ .ShowRendered }}" 49 - hx-boost="true" 50 - >view {{ if .ShowRendered }}code{{ else }}rendered{{ end }}</a> 35 + 36 + {{ if .BlobView.ShowingText }} 37 + <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 38 + <span>{{ .Lines }} lines</span> 39 + {{ end }} 40 + 41 + {{ if .BlobView.SizeHint }} 42 + <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 43 + <span>{{ byteFmt .BlobView.SizeHint }}</span> 44 + {{ end }} 45 + 46 + {{ if .BlobView.HasRawView }} 47 + <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 48 + <a href="/{{ .RepoInfo.FullName }}/raw/{{ .Ref }}/{{ .Path }}">view raw</a> 49 + {{ end }} 50 + 51 + {{ if .BlobView.ShowToggle }} 52 + <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 53 + <a href="/{{ .RepoInfo.FullName }}/blob/{{ .Ref }}/{{ .Path }}?code={{ .BlobView.ShowingRendered }}" hx-boost="true"> 54 + view {{ if .BlobView.ShowingRendered }}code{{ else }}rendered{{ end }} 55 + </a> 51 56 {{ end }} 52 57 </div> 53 58 </div> 54 59 </div> 55 - {{ if and .IsBinary .Unsupported }} 56 - <p class="text-center text-gray-400 dark:text-gray-500"> 57 - Previews are not supported for this file type. 58 - </p> 59 - {{ else if .IsBinary }} 60 - <div class="text-center"> 61 - {{ if .IsImage }} 62 - <img src="{{ .ContentSrc }}" 63 - alt="{{ .Path }}" 64 - class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded" /> 65 - {{ else if .IsVideo }} 66 - <video controls class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded"> 67 - <source src="{{ .ContentSrc }}"> 68 - Your browser does not support the video tag. 69 - </video> 70 - {{ end }} 71 - </div> 72 - {{ else }} 73 - <div class="overflow-auto relative"> 74 - {{ if .ShowRendered }} 75 - <div id="blob-contents" class="prose dark:prose-invert">{{ .RenderedContents }}</div> 60 + {{ if .BlobView.IsUnsupported }} 61 + <p class="text-center text-gray-400 dark:text-gray-500"> 62 + Previews are not supported for this file type. 63 + </p> 64 + {{ else if .BlobView.ContentType.IsSubmodule }} 65 + <p class="text-center text-gray-400 dark:text-gray-500"> 66 + This directory is a git submodule of <a href="{{ .BlobView.ContentSrc }}">{{ .BlobView.ContentSrc }}</a>. 67 + </p> 68 + {{ else if .BlobView.ContentType.IsImage }} 69 + <div class="text-center"> 70 + <img src="{{ .BlobView.ContentSrc }}" 71 + alt="{{ .Path }}" 72 + class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded" /> 73 + </div> 74 + {{ else if .BlobView.ContentType.IsVideo }} 75 + <div class="text-center"> 76 + <video controls class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded"> 77 + <source src="{{ .BlobView.ContentSrc }}"> 78 + Your browser does not support the video tag. 79 + </video> 80 + </div> 81 + {{ else if .BlobView.ContentType.IsSvg }} 82 + <div class="overflow-auto relative"> 83 + {{ if .BlobView.ShowingRendered }} 84 + <div class="text-center"> 85 + <img src="{{ .BlobView.ContentSrc }}" 86 + alt="{{ .Path }}" 87 + class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded" /> 88 + </div> 89 + {{ else }} 90 + <div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ code .BlobView.Contents .Path | escapeHtml }}</div> 91 + {{ end }} 92 + </div> 93 + {{ else if .BlobView.ContentType.IsMarkup }} 94 + <div class="overflow-auto relative"> 95 + {{ if .BlobView.ShowingRendered }} 96 + <div id="blob-contents" class="prose dark:prose-invert">{{ .BlobView.Contents | readme }}</div> 76 97 {{ else }} 77 - <div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ $.Contents | escapeHtml }}</div> 98 + <div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ code .BlobView.Contents .Path | escapeHtml }}</div> 78 99 {{ end }} 79 - </div> 100 + </div> 101 + {{ else if .BlobView.ContentType.IsCode }} 102 + <div class="overflow-auto relative"> 103 + <div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ code .BlobView.Contents .Path | escapeHtml }}</div> 104 + </div> 80 105 {{ end }} 81 106 {{ template "fragments/multiline-select" }} 82 107 {{ end }}
+44 -19
appview/pages/templates/repo/commit.html
··· 24 24 </div> 25 25 </div> 26 26 27 - <div class="flex items-center space-x-2"> 28 - <p class="text-sm text-gray-500 dark:text-gray-300"> 29 - {{ $didOrHandle := index $.EmailToDidOrHandle $commit.Author.Email }} 27 + <div class="flex flex-wrap items-center space-x-2"> 28 + <p class="flex flex-wrap items-center gap-1 text-sm text-gray-500 dark:text-gray-300"> 29 + {{ template "attribution" . }} 30 30 31 - {{ if $didOrHandle }} 32 - <a href="/{{ $didOrHandle }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ $didOrHandle }}</a> 33 - {{ else }} 34 - <a href="mailto:{{ $commit.Author.Email }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ $commit.Author.Name }}</a> 35 - {{ end }} 36 31 <span class="px-1 select-none before:content-['\00B7']"></span> 37 - {{ template "repo/fragments/time" $commit.Author.When }} 32 + {{ template "repo/fragments/time" $commit.Committer.When }} 38 33 <span class="px-1 select-none before:content-['\00B7']"></span> 39 - </p> 40 34 41 - <p class="flex items-center text-sm text-gray-500 dark:text-gray-300"> 42 35 <a href="/{{ $repo }}/commit/{{ $commit.This }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ slice $commit.This 0 8 }}</a> 36 + 43 37 {{ if $commit.Parent }} 44 - {{ i "arrow-left" "w-3 h-3 mx-1" }} 45 - <a href="/{{ $repo }}/commit/{{ $commit.Parent }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ slice $commit.Parent 0 8 }}</a> 38 + {{ i "arrow-left" "w-3 h-3 mx-1" }} 39 + <a href="/{{ $repo }}/commit/{{ $commit.Parent }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ slice $commit.Parent 0 8 }}</a> 46 40 {{ end }} 47 41 </p> 48 42 ··· 58 52 <div class="mb-1">This commit was signed with the committer's <span class="text-green-600 font-semibold">known signature</span>.</div> 59 53 <div class="flex items-center gap-2 my-2"> 60 54 {{ i "user" "w-4 h-4" }} 61 - {{ $committerDidOrHandle := index $.EmailToDidOrHandle $commit.Committer.Email }} 62 - <a href="/{{ $committerDidOrHandle }}">{{ template "user/fragments/picHandleLink" $committerDidOrHandle }}</a> 55 + {{ $committerDid := index $.EmailToDid $commit.Committer.Email }} 56 + {{ template "user/fragments/picHandleLink" $committerDid }} 63 57 </div> 64 58 <div class="my-1 pt-2 text-xs border-t border-gray-200 dark:border-gray-700"> 65 59 <div class="text-gray-600 dark:text-gray-300">SSH Key Fingerprint:</div> ··· 79 73 </section> 80 74 {{end}} 81 75 76 + {{ define "attribution" }} 77 + {{ $commit := .Diff.Commit }} 78 + {{ $showCommitter := true }} 79 + {{ if eq $commit.Author.Email $commit.Committer.Email }} 80 + {{ $showCommitter = false }} 81 + {{ end }} 82 + 83 + {{ if $showCommitter }} 84 + authored by {{ template "attributedUser" (list $commit.Author.Email $commit.Author.Name $.EmailToDid) }} 85 + {{ range $commit.CoAuthors }} 86 + {{ template "attributedUser" (list .Email .Name $.EmailToDid) }} 87 + {{ end }} 88 + and committed by {{ template "attributedUser" (list $commit.Committer.Email $commit.Committer.Name $.EmailToDid) }} 89 + {{ else }} 90 + {{ template "attributedUser" (list $commit.Author.Email $commit.Author.Name $.EmailToDid )}} 91 + {{ end }} 92 + {{ end }} 93 + 94 + {{ define "attributedUser" }} 95 + {{ $email := index . 0 }} 96 + {{ $name := index . 1 }} 97 + {{ $map := index . 2 }} 98 + {{ $did := index $map $email }} 99 + 100 + {{ if $did }} 101 + {{ template "user/fragments/picHandleLink" $did }} 102 + {{ else }} 103 + <a href="mailto:{{ $email }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ $name }}</a> 104 + {{ end }} 105 + {{ end }} 106 + 82 107 {{ define "topbarLayout" }} 83 - <header class="px-1 col-span-full" style="z-index: 20;"> 108 + <header class="col-span-full" style="z-index: 20;"> 84 109 {{ template "layouts/fragments/topbar" . }} 85 110 </header> 86 111 {{ end }} 87 112 88 113 {{ define "mainLayout" }} 89 - <div class="px-1 col-span-full flex flex-col gap-4"> 114 + <div class="px-1 flex-grow col-span-full flex flex-col gap-4"> 90 115 {{ block "contentLayout" . }} 91 116 {{ block "content" . }}{{ end }} 92 117 {{ end }} ··· 105 130 {{ end }} 106 131 107 132 {{ define "footerLayout" }} 108 - <footer class="px-1 col-span-full mt-12"> 133 + <footer class="col-span-full mt-12"> 109 134 {{ template "layouts/fragments/footer" . }} 110 135 </footer> 111 136 {{ end }} 112 137 113 138 {{ define "contentAfter" }} 114 - {{ template "repo/fragments/diff" (list .RepoInfo.FullName .Diff .DiffOpts) }} 139 + {{ template "repo/fragments/diff" (list .Diff .DiffOpts) }} 115 140 {{end}} 116 141 117 142 {{ define "contentAfterLeft" }}
+2 -2
appview/pages/templates/repo/compare/compare.html
··· 17 17 {{ end }} 18 18 19 19 {{ define "mainLayout" }} 20 - <div class="px-1 col-span-full flex flex-col gap-4"> 20 + <div class="px-1 flex-grow col-span-full flex flex-col gap-4"> 21 21 {{ block "contentLayout" . }} 22 22 {{ block "content" . }}{{ end }} 23 23 {{ end }} ··· 42 42 {{ end }} 43 43 44 44 {{ define "contentAfter" }} 45 - {{ template "repo/fragments/diff" (list .RepoInfo.FullName .Diff .DiffOpts) }} 45 + {{ template "repo/fragments/diff" (list .Diff .DiffOpts) }} 46 46 {{end}} 47 47 48 48 {{ define "contentAfterLeft" }}
+1 -1
appview/pages/templates/repo/empty.html
··· 35 35 36 36 <p><span class="{{$bullet}}">1</span>First, generate a new <a href="https://git-scm.com/book/en/v2/Git-on-the-Server-Generating-Your-SSH-Public-Key" class="underline">SSH key pair</a>.</p> 37 37 <p><span class="{{$bullet}}">2</span>Then add the public key to your account from the <a href="/settings" class="underline">settings</a> page.</p> 38 - <p><span class="{{$bullet}}">3</span>Configure your remote to <code>git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code></p> 38 + <p><span class="{{$bullet}}">3</span>Configure your remote to <code>git@{{ $knot | stripPort }}:{{ resolve .RepoInfo.OwnerDid }}/{{ .RepoInfo.Name }}</code></p> 39 39 <p><span class="{{$bullet}}">4</span>Push!</p> 40 40 </div> 41 41 </div>
+2 -1
appview/pages/templates/repo/fork.html
··· 25 25 value="{{ . }}" 26 26 class="mr-2" 27 27 id="domain-{{ . }}" 28 + {{if eq (len $.Knots) 1}}checked{{end}} 28 29 /> 29 30 <label for="domain-{{ . }}" class="dark:text-white">{{ . }}</label> 30 31 </div> ··· 33 34 {{ end }} 34 35 </div> 35 36 </div> 36 - <p class="text-sm text-gray-500 dark:text-gray-400">A knot hosts repository data. <a href="/knots" class="underline">Learn how to register your own knot.</a></p> 37 + <p class="text-sm text-gray-500 dark:text-gray-400">A knot hosts repository data. <a href="/settings/knots" class="underline">Learn how to register your own knot.</a></p> 37 38 </fieldset> 38 39 39 40 <div class="space-y-2">
+49
appview/pages/templates/repo/fragments/backlinks.html
··· 1 + {{ define "repo/fragments/backlinks" }} 2 + {{ if .Backlinks }} 3 + <div id="at-uri-panel" class="px-2 md:px-0"> 4 + <div> 5 + <span class="text-sm py-1 font-bold text-gray-500 dark:text-gray-400">Referenced by</span> 6 + </div> 7 + <ul> 8 + {{ range .Backlinks }} 9 + <li> 10 + {{ $repoOwner := resolve .Handle }} 11 + {{ $repoName := .Repo }} 12 + {{ $repoUrl := printf "%s/%s" $repoOwner $repoName }} 13 + <div class="flex flex-col"> 14 + <div class="flex gap-2 items-center"> 15 + {{ if .State.IsClosed }} 16 + <span class="text-gray-500 dark:text-gray-400"> 17 + {{ i "ban" "size-3" }} 18 + </span> 19 + {{ else if eq .Kind.String "issues" }} 20 + <span class="text-green-600 dark:text-green-500"> 21 + {{ i "circle-dot" "size-3" }} 22 + </span> 23 + {{ else if .State.IsOpen }} 24 + <span class="text-green-600 dark:text-green-500"> 25 + {{ i "git-pull-request" "size-3" }} 26 + </span> 27 + {{ else if .State.IsMerged }} 28 + <span class="text-purple-600 dark:text-purple-500"> 29 + {{ i "git-merge" "size-3" }} 30 + </span> 31 + {{ else }} 32 + <span class="text-gray-600 dark:text-gray-300"> 33 + {{ i "git-pull-request-closed" "size-3" }} 34 + </span> 35 + {{ end }} 36 + <a href="{{ . }}" class="line-clamp-1 text-sm"><span class="text-gray-500 dark:text-gray-400">#{{ .SubjectId }}</span> {{ .Title }}</a> 37 + </div> 38 + {{ if not (eq $.RepoInfo.FullName $repoUrl) }} 39 + <div> 40 + <span>on <a href="/{{ $repoUrl }}">{{ $repoUrl }}</a></span> 41 + </div> 42 + {{ end }} 43 + </div> 44 + </li> 45 + {{ end }} 46 + </ul> 47 + </div> 48 + {{ end }} 49 + {{ end }}
+5 -4
appview/pages/templates/repo/fragments/cloneDropdown.html
··· 29 29 <code 30 30 class="flex-1 px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-l select-all cursor-pointer whitespace-nowrap overflow-x-auto" 31 31 onclick="window.getSelection().selectAllChildren(this)" 32 - data-url="https://tangled.org/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}" 33 - >https://tangled.org/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}</code> 32 + data-url="https://tangled.org/{{ resolve .RepoInfo.OwnerDid }}/{{ .RepoInfo.Name }}" 33 + >https://tangled.org/{{ resolve .RepoInfo.OwnerDid }}/{{ .RepoInfo.Name }}</code> 34 34 <button 35 35 onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))" 36 36 class="px-3 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 border-l border-gray-300 dark:border-gray-600" ··· 43 43 44 44 <!-- SSH Clone --> 45 45 <div class="mb-3"> 46 + {{ $repoOwnerHandle := resolve .RepoInfo.OwnerDid }} 46 47 <label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">SSH</label> 47 48 <div class="flex items-center border border-gray-300 dark:border-gray-600 rounded"> 48 49 <code 49 50 class="flex-1 px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-l select-all cursor-pointer whitespace-nowrap overflow-x-auto" 50 51 onclick="window.getSelection().selectAllChildren(this)" 51 - data-url="git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}" 52 - >git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code> 52 + data-url="git@{{ $knot | stripPort }}:{{ $repoOwnerHandle }}/{{ .RepoInfo.Name }}" 53 + >git@{{ $knot | stripPort }}:{{ $repoOwnerHandle }}/{{ .RepoInfo.Name }}</code> 53 54 <button 54 55 onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))" 55 56 class="px-3 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 border-l border-gray-300 dark:border-gray-600"
+2 -3
appview/pages/templates/repo/fragments/diff.html
··· 1 1 {{ define "repo/fragments/diff" }} 2 - {{ $repo := index . 0 }} 3 - {{ $diff := index . 1 }} 4 - {{ $opts := index . 2 }} 2 + {{ $diff := index . 0 }} 3 + {{ $opts := index . 1 }} 5 4 6 5 {{ $commit := $diff.Commit }} 7 6 {{ $diff := $diff.Diff }}
+20 -18
appview/pages/templates/repo/fragments/diffOpts.html
··· 5 5 {{ if .Split }} 6 6 {{ $active = "split" }} 7 7 {{ end }} 8 - {{ $values := list "unified" "split" }} 9 - {{ block "tabSelector" (dict "Name" "diff" "Values" $values "Active" $active) }} {{ end }} 8 + 9 + {{ $unified := 10 + (dict 11 + "Key" "unified" 12 + "Value" "unified" 13 + "Icon" "square-split-vertical" 14 + "Meta" "") }} 15 + {{ $split := 16 + (dict 17 + "Key" "split" 18 + "Value" "split" 19 + "Icon" "square-split-horizontal" 20 + "Meta" "") }} 21 + {{ $values := list $unified $split }} 22 + 23 + {{ template "fragments/tabSelector" 24 + (dict 25 + "Name" "diff" 26 + "Values" $values 27 + "Active" $active) }} 10 28 </section> 11 29 {{ end }} 12 30 13 - {{ define "tabSelector" }} 14 - {{ $name := .Name }} 15 - {{ $all := .Values }} 16 - {{ $active := .Active }} 17 - <div class="flex justify-between divide-x divide-gray-200 dark:divide-gray-700 rounded border border-gray-200 dark:border-gray-700 overflow-hidden"> 18 - {{ $activeTab := "bg-white dark:bg-gray-700 shadow-sm" }} 19 - {{ $inactiveTab := "bg-gray-100 dark:bg-gray-800 shadow-inner" }} 20 - {{ range $index, $value := $all }} 21 - {{ $isActive := eq $value $active }} 22 - <a href="?{{ $name }}={{ $value }}" 23 - class="py-2 text-sm w-full block hover:no-underline text-center {{ if $isActive }} {{$activeTab }} {{ else }} {{ $inactiveTab }} {{ end }}"> 24 - {{ $value }} 25 - </a> 26 - {{ end }} 27 - </div> 28 - {{ end }}
+15 -1
appview/pages/templates/repo/fragments/editLabelPanel.html
··· 170 170 {{ $fieldName := $def.AtUri }} 171 171 {{ $valueType := $def.ValueType }} 172 172 {{ $value := .value }} 173 + 173 174 {{ if $valueType.IsDidFormat }} 174 175 {{ $value = trimPrefix (resolve .value) "@" }} 176 + <actor-typeahead> 177 + <input 178 + autocapitalize="none" 179 + autocorrect="off" 180 + autocomplete="off" 181 + placeholder="user.tngl.sh" 182 + value="{{$value}}" 183 + name="{{$fieldName}}" 184 + type="text" 185 + class="p-1 w-full text-sm" 186 + /> 187 + </actor-typeahead> 188 + {{ else }} 189 + <input class="p-1 w-full" type="text" name="{{$fieldName}}" value="{{$value}}"> 175 190 {{ end }} 176 - <input class="p-1 w-full" type="text" name="{{$fieldName}}" value="{{$value}}"> 177 191 {{ end }} 178 192 179 193 {{ define "nullTypeInput" }}
-11
appview/pages/templates/repo/fragments/editRepoDescription.html
··· 1 - {{ define "repo/fragments/editRepoDescription" }} 2 - <form hx-put="/{{ .RepoInfo.FullName }}/description" hx-target="this" hx-swap="outerHTML" class="flex flex-wrap gap-2"> 3 - <input type="text" class="p-1" name="description" value="{{ .RepoInfo.Description }}"> 4 - <button type="submit" class="btn p-1 flex items-center gap-2 no-underline text-sm"> 5 - {{ i "check" "w-3 h-3" }} save 6 - </button> 7 - <button type="button" class="btn p-1 flex items-center gap-2 no-underline text-sm" hx-get="/{{ .RepoInfo.FullName }}/description" > 8 - {{ i "x" "w-3 h-3" }} cancel 9 - </button> 10 - </form> 11 - {{ end }}
+48
appview/pages/templates/repo/fragments/externalLinkPanel.html
··· 1 + {{ define "repo/fragments/externalLinkPanel" }} 2 + <div id="at-uri-panel" class="px-2 md:px-0"> 3 + <div class="flex justify-between items-center gap-2"> 4 + <span class="text-sm py-1 font-bold text-gray-500 dark:text-gray-400 capitalize">AT URI</span> 5 + <div class="flex items-center gap-2"> 6 + <button 7 + onclick="copyToClipboard(this)" 8 + class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200" 9 + title="Copy to clipboard"> 10 + {{ i "copy" "w-4 h-4" }} 11 + </button> 12 + <a 13 + href="https://pdsls.dev/{{.}}" 14 + class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200" 15 + title="View in PDSls"> 16 + {{ i "arrow-up-right" "w-4 h-4" }} 17 + </a> 18 + </div> 19 + </div> 20 + <span 21 + class="font-mono text-sm select-all cursor-pointer block max-w-full overflow-x-auto whitespace-nowrap scrollbar-thin scrollbar-thumb-gray-300 dark:scrollbar-thumb-gray-600" 22 + onclick="window.getSelection().selectAllChildren(this)" 23 + title="{{.}}" 24 + data-aturi="{{ . | string | safeUrl }}" 25 + >{{.}}</span> 26 + 27 + 28 + </div> 29 + 30 + <script> 31 + function copyToClipboard(button) { 32 + const container = document.getElementById("at-uri-panel"); 33 + const urlSpan = container?.querySelector('[data-aturi]'); 34 + const text = urlSpan?.getAttribute('data-aturi'); 35 + console.log("copying to clipboard", text) 36 + if (!text) return; 37 + 38 + navigator.clipboard.writeText(text).then(() => { 39 + const originalContent = button.innerHTML; 40 + button.innerHTML = `{{ i "check" "w-4 h-4" }}`; 41 + setTimeout(() => { 42 + button.innerHTML = originalContent; 43 + }, 2000); 44 + }); 45 + } 46 + </script> 47 + {{ end }} 48 +
+1 -1
appview/pages/templates/repo/fragments/og.html
··· 11 11 <meta property="og:image" content="{{ $imageUrl }}" /> 12 12 <meta property="og:image:width" content="1200" /> 13 13 <meta property="og:image:height" content="600" /> 14 - 14 + 15 15 <meta name="twitter:card" content="summary_large_image" /> 16 16 <meta name="twitter:title" content="{{ unescapeHtml $title }}" /> 17 17 <meta name="twitter:description" content="{{ $description }}" />
+1 -16
appview/pages/templates/repo/fragments/participants.html
··· 6 6 <span class="font-bold text-gray-500 dark:text-gray-400 capitalize">Participants</span> 7 7 <span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 ml-1">{{ len $all }}</span> 8 8 </div> 9 - <div class="flex items-center -space-x-3 mt-2"> 10 - {{ $c := "z-50 z-40 z-30 z-20 z-10" }} 11 - {{ range $i, $p := $ps }} 12 - <img 13 - src="{{ tinyAvatar . }}" 14 - alt="" 15 - class="rounded-full h-8 w-8 mr-1 border-2 border-gray-100 dark:border-gray-900 z-{{sub 5 $i}}0" 16 - /> 17 - {{ end }} 18 - 19 - {{ if gt (len $all) 5 }} 20 - <span class="pl-4 text-gray-500 dark:text-gray-400 text-sm"> 21 - +{{ sub (len $all) 5 }} 22 - </span> 23 - {{ end }} 24 - </div> 9 + {{ template "fragments/tinyAvatarList" (dict "all" $all "classes" "w-8 h-8") }} 25 10 </div> 26 11 {{ end }}
-15
appview/pages/templates/repo/fragments/repoDescription.html
··· 1 - {{ define "repo/fragments/repoDescription" }} 2 - <span id="repo-description" class="flex flex-wrap items-center gap-2 text-sm" hx-target="this" hx-swap="outerHTML"> 3 - {{ if .RepoInfo.Description }} 4 - {{ .RepoInfo.Description | description }} 5 - {{ else }} 6 - <span class="italic">this repo has no description</span> 7 - {{ end }} 8 - 9 - {{ if .RepoInfo.Roles.IsOwner }} 10 - <button class="flex items-center gap-2 no-underline text-sm" hx-get="/{{ .RepoInfo.FullName }}/description/edit"> 11 - {{ i "pencil" "w-3 h-3" }} 12 - </button> 13 - {{ end }} 14 - </span> 15 - {{ end }}
-26
appview/pages/templates/repo/fragments/repoStar.html
··· 1 - {{ define "repo/fragments/repoStar" }} 2 - <button 3 - id="starBtn" 4 - class="btn disabled:opacity-50 disabled:cursor-not-allowed flex gap-2 items-center group" 5 - {{ if .IsStarred }} 6 - hx-delete="/star?subject={{ .RepoAt }}&countHint={{ .Stats.StarCount }}" 7 - {{ else }} 8 - hx-post="/star?subject={{ .RepoAt }}&countHint={{ .Stats.StarCount }}" 9 - {{ end }} 10 - 11 - hx-trigger="click" 12 - hx-target="this" 13 - hx-swap="outerHTML" 14 - hx-disabled-elt="#starBtn" 15 - > 16 - {{ if .IsStarred }} 17 - {{ i "star" "w-4 h-4 fill-current" }} 18 - {{ else }} 19 - {{ i "star" "w-4 h-4" }} 20 - {{ end }} 21 - <span class="text-sm"> 22 - {{ .Stats.StarCount }} 23 - </span> 24 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 25 - </button> 26 - {{ end }}
+39 -20
appview/pages/templates/repo/index.html
··· 14 14 {{ end }} 15 15 <div class="flex items-center justify-between pb-5"> 16 16 {{ block "branchSelector" . }}{{ end }} 17 - <div class="flex md:hidden items-center gap-2"> 17 + <div class="flex md:hidden items-center gap-3"> 18 18 <a href="/{{ .RepoInfo.FullName }}/commits/{{ .Ref | urlquery }}" class="inline-flex items-center text-sm gap-1 font-bold"> 19 19 {{ i "git-commit-horizontal" "w-4" "h-4" }} {{ .TotalCommits }} 20 20 </a> ··· 35 35 {{ end }} 36 36 37 37 {{ define "repoLanguages" }} 38 - <details class="group -m-6 mb-4"> 38 + <details class="group -my-4 -m-6 mb-4"> 39 39 <summary class="flex gap-[1px] h-4 scale-y-50 hover:scale-y-100 origin-top group-open:scale-y-100 transition-all hover:cursor-pointer overflow-hidden rounded-t"> 40 40 {{ range $value := .Languages }} 41 41 <div ··· 47 47 <div class="px-4 py-2 border-b border-gray-200 dark:border-gray-600 flex items-center gap-4 flex-wrap"> 48 48 {{ range $value := .Languages }} 49 49 <div 50 - class="flex flex-grow items-center gap-2 text-xs align-items-center justify-center" 50 + class="flex items-center gap-2 text-xs align-items-center justify-center" 51 51 > 52 52 {{ template "repo/fragments/colorBall" (dict "color" (langColor $value.Name)) }} 53 53 <div>{{ or $value.Name "Other" }} ··· 66 66 67 67 {{ define "branchSelector" }} 68 68 <div class="flex gap-2 items-center justify-between w-full"> 69 - <div class="flex gap-2 items-center"> 69 + <div class="flex gap-2 items-stretch"> 70 70 <select 71 71 onchange="window.location.href = '/{{ .RepoInfo.FullName }}/tree/' + encodeURIComponent(this.value)" 72 72 class="p-1 border max-w-32 border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700" ··· 129 129 {{ $icon := "folder" }} 130 130 {{ $iconStyle := "size-4 fill-current" }} 131 131 132 + {{ if .IsSubmodule }} 133 + {{ $link = printf "/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) .Name }} 134 + {{ $icon = "folder-input" }} 135 + {{ $iconStyle = "size-4" }} 136 + {{ end }} 137 + 132 138 {{ if .IsFile }} 133 139 {{ $link = printf "/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) .Name }} 134 140 {{ $icon = "file" }} 135 141 {{ $iconStyle = "size-4" }} 136 142 {{ end }} 143 + 137 144 <a href="{{ $link }}" class="{{ $linkstyle }}"> 138 145 <div class="flex items-center gap-2"> 139 146 {{ i $icon $iconStyle "flex-shrink-0" }} ··· 221 228 <span 222 229 class="mx-1 before:content-['ยท'] before:select-none" 223 230 ></span> 224 - <span> 225 - {{ $didOrHandle := index $.EmailToDidOrHandle .Author.Email }} 226 - <a 227 - href="{{ if $didOrHandle }} 228 - /{{ $didOrHandle }} 229 - {{ else }} 230 - mailto:{{ .Author.Email }} 231 - {{ end }}" 232 - class="text-gray-500 dark:text-gray-400 no-underline hover:underline" 233 - >{{ if $didOrHandle }} 234 - {{ template "user/fragments/picHandleLink" $didOrHandle }} 235 - {{ else }} 236 - {{ .Author.Name }} 237 - {{ end }}</a 238 - > 239 - </span> 231 + {{ template "attribution" (list . $.EmailToDid) }} 240 232 <div class="inline-block px-1 select-none after:content-['ยท']"></div> 241 233 {{ template "repo/fragments/time" .Committer.When }} 242 234 ··· 262 254 {{ end }} 263 255 </div> 264 256 </div> 257 + {{ end }} 258 + 259 + {{ define "attribution" }} 260 + {{ $commit := index . 0 }} 261 + {{ $map := index . 1 }} 262 + <span class="flex items-center"> 263 + {{ $author := index $map $commit.Author.Email }} 264 + {{ $coauthors := $commit.CoAuthors }} 265 + {{ $all := list }} 266 + 267 + {{ if $author }} 268 + {{ $all = append $all $author }} 269 + {{ end }} 270 + {{ range $coauthors }} 271 + {{ $co := index $map .Email }} 272 + {{ if $co }} 273 + {{ $all = append $all $co }} 274 + {{ end }} 275 + {{ end }} 276 + 277 + {{ template "fragments/tinyAvatarList" (dict "all" $all "classes" "size-6") }} 278 + <a href="{{ if $author }}/{{ $author }}{{ else }}mailto:{{ $commit.Author.Email }}{{ end }}" 279 + class="no-underline hover:underline"> 280 + {{ if $author }}{{ resolve $author }}{{ else }}{{ $commit.Author.Name }}{{ end }} 281 + {{ if $coauthors }} +{{ length $coauthors }}{{ end }} 282 + </a> 283 + </span> 265 284 {{ end }} 266 285 267 286 {{ define "branchList" }}
+4 -4
appview/pages/templates/repo/issues/fragments/issueCommentHeader.html
··· 19 19 {{ end }} 20 20 21 21 {{ define "timestamp" }} 22 - <a href="#{{ .Comment.Id }}" 22 + <a href="#comment-{{ .Comment.Id }}" 23 23 class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-400 hover:underline no-underline" 24 - id="{{ .Comment.Id }}"> 24 + id="comment-{{ .Comment.Id }}"> 25 25 {{ if .Comment.Deleted }} 26 26 {{ template "repo/fragments/shortTimeAgo" .Comment.Deleted }} 27 27 {{ else if .Comment.Edited }} ··· 34 34 35 35 {{ define "editIssueComment" }} 36 36 <a 37 - class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 37 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer" 38 38 hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/edit" 39 39 hx-swap="outerHTML" 40 40 hx-target="#comment-body-{{.Comment.Id}}"> ··· 44 44 45 45 {{ define "deleteIssueComment" }} 46 46 <a 47 - class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 47 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer" 48 48 hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/" 49 49 hx-confirm="Are you sure you want to delete your comment?" 50 50 hx-swap="outerHTML"
+1 -1
appview/pages/templates/repo/issues/fragments/issueListing.html
··· 8 8 class="no-underline hover:underline" 9 9 > 10 10 {{ .Title | description }} 11 - <span class="text-gray-500">#{{ .IssueId }}</span> 11 + <span class="text-gray-500 dark:text-gray-400">#{{ .IssueId }}</span> 12 12 </a> 13 13 </div> 14 14 <div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1">
+19
appview/pages/templates/repo/issues/fragments/og.html
··· 1 + {{ define "repo/issues/fragments/og" }} 2 + {{ $title := printf "%s #%d" .Issue.Title .Issue.IssueId }} 3 + {{ $description := or .Issue.Body .RepoInfo.Description }} 4 + {{ $url := printf "https://tangled.org/%s/issues/%d" .RepoInfo.FullName .Issue.IssueId }} 5 + {{ $imageUrl := printf "https://tangled.org/%s/issues/%d/opengraph" .RepoInfo.FullName .Issue.IssueId }} 6 + 7 + <meta property="og:title" content="{{ unescapeHtml $title }}" /> 8 + <meta property="og:type" content="object" /> 9 + <meta property="og:url" content="{{ $url }}" /> 10 + <meta property="og:description" content="{{ $description }}" /> 11 + <meta property="og:image" content="{{ $imageUrl }}" /> 12 + <meta property="og:image:width" content="1200" /> 13 + <meta property="og:image:height" content="600" /> 14 + 15 + <meta name="twitter:card" content="summary_large_image" /> 16 + <meta name="twitter:title" content="{{ unescapeHtml $title }}" /> 17 + <meta name="twitter:description" content="{{ $description }}" /> 18 + <meta name="twitter:image" content="{{ $imageUrl }}" /> 19 + {{ end }}
+7 -6
appview/pages/templates/repo/issues/issue.html
··· 2 2 3 3 4 4 {{ define "extrameta" }} 5 - {{ $title := printf "%s &middot; issue #%d &middot; %s" .Issue.Title .Issue.IssueId .RepoInfo.FullName }} 6 - {{ $url := printf "https://tangled.org/%s/issues/%d" .RepoInfo.FullName .Issue.IssueId }} 7 - 8 - {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 5 + {{ template "repo/issues/fragments/og" (dict "RepoInfo" .RepoInfo "Issue" .Issue) }} 9 6 {{ end }} 10 7 11 8 {{ define "repoContentLayout" }} ··· 23 20 "Subject" $.Issue.AtUri 24 21 "State" $.Issue.Labels) }} 25 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 }} 26 27 </div> 27 28 </div> 28 29 {{ end }} ··· 87 88 88 89 {{ define "editIssue" }} 89 90 <a 90 - class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 91 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer" 91 92 hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/edit" 92 93 hx-swap="innerHTML" 93 94 hx-target="#issue-{{.Issue.IssueId}}"> ··· 97 98 98 99 {{ define "deleteIssue" }} 99 100 <a 100 - class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 101 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer" 101 102 hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/" 102 103 hx-confirm="Are you sure you want to delete your issue?" 103 104 hx-swap="none">
+147 -47
appview/pages/templates/repo/issues/issues.html
··· 8 8 {{ end }} 9 9 10 10 {{ define "repoContent" }} 11 - <div class="flex justify-between items-center gap-4"> 12 - <div class="flex gap-4"> 13 - <a 14 - href="?state=open" 15 - class="flex items-center gap-2 {{ if .FilteringByOpen }}font-bold {{ else }}text-gray-500 dark:text-gray-400{{ end }}" 11 + {{ $active := "closed" }} 12 + {{ if .FilteringByOpen }} 13 + {{ $active = "open" }} 14 + {{ end }} 15 + 16 + {{ $open := 17 + (dict 18 + "Key" "open" 19 + "Value" "open" 20 + "Icon" "circle-dot" 21 + "Meta" (string .RepoInfo.Stats.IssueCount.Open)) }} 22 + {{ $closed := 23 + (dict 24 + "Key" "closed" 25 + "Value" "closed" 26 + "Icon" "ban" 27 + "Meta" (string .RepoInfo.Stats.IssueCount.Closed)) }} 28 + {{ $values := list $open $closed }} 29 + 30 + <div class="grid gap-2 grid-cols-[auto_1fr_auto] grid-row-2"> 31 + <form class="flex relative col-span-3 sm:col-span-1 sm:col-start-2" method="GET"> 32 + <input type="hidden" name="state" value="{{ if .FilteringByOpen }}open{{ else }}closed{{ end }}"> 33 + <div class="flex-1 flex relative"> 34 + <input 35 + id="search-q" 36 + class="flex-1 py-1 pl-2 pr-10 mr-[-1px] rounded-r-none focus:border-0 focus:outline-none focus:ring focus:ring-blue-400 ring-inset peer" 37 + type="text" 38 + name="q" 39 + value="{{ .FilterQuery }}" 40 + placeholder=" " 16 41 > 17 - {{ i "circle-dot" "w-4 h-4" }} 18 - <span>{{ .RepoInfo.Stats.IssueCount.Open }} open</span> 19 - </a> 42 + <a 43 + href="?state={{ if .FilteringByOpen }}open{{ else }}closed{{ end }}" 44 + class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hidden peer-[:not(:placeholder-shown)]:block" 45 + > 46 + {{ i "x" "w-4 h-4" }} 47 + </a> 48 + </div> 49 + <button 50 + type="submit" 51 + class="p-2 text-gray-400 border rounded-r border-gray-400 dark:border-gray-600" 52 + > 53 + {{ i "search" "w-4 h-4" }} 54 + </button> 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> 20 59 <a 21 - href="?state=closed" 22 - class="flex items-center gap-2 {{ if not .FilteringByOpen }}font-bold {{ else }}text-gray-500 dark:text-gray-400{{ end }}" 23 - > 24 - {{ i "ban" "w-4 h-4" }} 25 - <span>{{ .RepoInfo.Stats.IssueCount.Closed }} closed</span> 26 - </a> 27 - </div> 28 - <a 29 60 href="/{{ .RepoInfo.FullName }}/issues/new" 30 - class="btn-create text-sm flex items-center justify-center gap-2 no-underline hover:no-underline hover:text-white" 31 - > 61 + class="col-start-3 btn-create text-sm flex items-center justify-center gap-2 no-underline hover:no-underline hover:text-white" 62 + > 32 63 {{ i "circle-plus" "w-4 h-4" }} 33 64 <span>new</span> 34 - </a> 35 - </div> 36 - <div class="error" id="issues"></div> 65 + </a> 66 + </div> 67 + <div class="error" id="issues"></div> 37 68 {{ end }} 38 69 39 70 {{ define "repoAfter" }} 40 71 <div class="mt-2"> 41 72 {{ template "repo/issues/fragments/issueListing" (dict "Issues" .Issues "RepoPrefix" .RepoInfo.FullName "LabelDefs" .LabelDefs) }} 42 73 </div> 43 - {{ block "pagination" . }} {{ end }} 74 + {{if gt .IssueCount .Page.Limit }} 75 + {{ block "pagination" . }} {{ end }} 76 + {{ end }} 44 77 {{ end }} 45 78 46 79 {{ define "pagination" }} 47 - <div class="flex justify-end mt-4 gap-2"> 48 - {{ $currentState := "closed" }} 49 - {{ if .FilteringByOpen }} 50 - {{ $currentState = "open" }} 51 - {{ end }} 80 + <div class="flex justify-center items-center mt-4 gap-2"> 81 + {{ $currentState := "closed" }} 82 + {{ if .FilteringByOpen }} 83 + {{ $currentState = "open" }} 84 + {{ end }} 52 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 + " 53 98 {{ if gt .Page.Offset 0 }} 54 - {{ $prev := .Page.Previous }} 55 - <a 56 - class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 57 - hx-boost="true" 58 - href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&offset={{ $prev.Offset }}&limit={{ $prev.Limit }}" 59 - > 60 - {{ i "chevron-left" "w-4 h-4" }} 61 - previous 62 - </a> 63 - {{ else }} 64 - <div></div> 99 + hx-boost="true" 100 + href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&q={{ .FilterQuery }}&offset={{ $prev }}&limit={{ .Page.Limit }}" 65 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> 66 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 + " 67 170 {{ if eq (len .Issues) .Page.Limit }} 68 - {{ $next := .Page.Next }} 69 - <a 70 - class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 71 - hx-boost="true" 72 - href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&offset={{ $next.Offset }}&limit={{ $next.Limit }}" 73 - > 74 - next 75 - {{ i "chevron-right" "w-4 h-4" }} 76 - </a> 171 + hx-boost="true" 172 + href="/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&q={{ .FilterQuery }}&offset={{ $next }}&limit={{ .Page.Limit }}" 77 173 {{ end }} 174 + > 175 + next 176 + {{ i "chevron-right" "w-4 h-4" }} 177 + </a> 78 178 </div> 79 179 {{ end }}
+40 -23
appview/pages/templates/repo/log.html
··· 17 17 <div class="hidden md:flex md:flex-col divide-y divide-gray-200 dark:divide-gray-700"> 18 18 {{ $grid := "grid grid-cols-14 gap-4" }} 19 19 <div class="{{ $grid }}"> 20 - <div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-2">Author</div> 20 + <div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-3">Author</div> 21 21 <div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-3">Commit</div> 22 22 <div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-6">Message</div> 23 - <div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-1"></div> 24 23 <div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-2 justify-self-end">Date</div> 25 24 </div> 26 25 {{ range $index, $commit := .Commits }} 27 26 {{ $messageParts := splitN $commit.Message "\n\n" 2 }} 28 27 <div class="{{ $grid }} py-3"> 29 - <div class="align-top truncate col-span-2"> 30 - {{ $didOrHandle := index $.EmailToDidOrHandle $commit.Author.Email }} 31 - {{ if $didOrHandle }} 32 - {{ template "user/fragments/picHandleLink" $didOrHandle }} 33 - {{ else }} 34 - <a href="mailto:{{ $commit.Author.Email }}" class="text-gray-700 dark:text-gray-300 no-underline hover:underline">{{ $commit.Author.Name }}</a> 35 - {{ end }} 28 + <div class="align-top col-span-3"> 29 + {{ template "attribution" (list $commit $.EmailToDid) }} 36 30 </div> 37 31 <div class="align-top font-mono flex items-start col-span-3"> 38 32 {{ $verified := $.VerifiedCommits.IsVerified $commit.Hash.String }} ··· 61 55 <div class="align-top col-span-6"> 62 56 <div> 63 57 <a href="/{{ $.RepoInfo.FullName }}/commit/{{ $commit.Hash.String }}" class="dark:text-white no-underline hover:underline">{{ index $messageParts 0 }}</a> 58 + 64 59 {{ if gt (len $messageParts) 1 }} 65 60 <button class="py-1/2 px-1 bg-gray-200 hover:bg-gray-400 dark:bg-gray-700 dark:hover:bg-gray-600 rounded" hx-on:click="this.parentElement.nextElementSibling.classList.toggle('hidden')">{{ i "ellipsis" "w-3 h-3" }}</button> 66 61 {{ end }} ··· 72 67 </span> 73 68 {{ end }} 74 69 {{ end }} 70 + 71 + <!-- ci status --> 72 + <span class="text-xs"> 73 + {{ $pipeline := index $.Pipelines .Hash.String }} 74 + {{ if and $pipeline (gt (len $pipeline.Statuses) 0) }} 75 + {{ template "repo/pipelines/fragments/pipelineSymbolLong" (dict "Pipeline" $pipeline "RepoInfo" $.RepoInfo) }} 76 + {{ end }} 77 + </span> 75 78 </div> 76 79 77 80 {{ if gt (len $messageParts) 1 }} 78 81 <p class="hidden mt-1 text-sm text-gray-600 dark:text-gray-400">{{ nl2br (index $messageParts 1) }}</p> 79 82 {{ end }} 80 - </div> 81 - <div class="align-top col-span-1"> 82 - <!-- ci status --> 83 - {{ $pipeline := index $.Pipelines .Hash.String }} 84 - {{ if and $pipeline (gt (len $pipeline.Statuses) 0) }} 85 - {{ template "repo/pipelines/fragments/pipelineSymbolLong" (dict "Pipeline" $pipeline "RepoInfo" $.RepoInfo) }} 86 - {{ end }} 87 83 </div> 88 84 <div class="align-top justify-self-end text-gray-500 dark:text-gray-400 col-span-2">{{ template "repo/fragments/shortTimeAgo" $commit.Committer.When }}</div> 89 85 </div> ··· 152 148 </a> 153 149 </span> 154 150 <span class="mx-2 before:content-['ยท'] before:select-none"></span> 155 - <span> 156 - {{ $didOrHandle := index $.EmailToDidOrHandle $commit.Author.Email }} 157 - <a href="{{ if $didOrHandle }}/{{ $didOrHandle }}{{ else }}mailto:{{ $commit.Author.Email }}{{ end }}" 158 - class="text-gray-500 dark:text-gray-400 no-underline hover:underline"> 159 - {{ if $didOrHandle }}{{ template "user/fragments/picHandleLink" $didOrHandle }}{{ else }}{{ $commit.Author.Name }}{{ end }} 160 - </a> 161 - </span> 151 + {{ template "attribution" (list $commit $.EmailToDid) }} 162 152 <div class="inline-block px-1 select-none after:content-['ยท']"></div> 163 153 <span>{{ template "repo/fragments/shortTime" $commit.Committer.When }}</span> 164 154 ··· 176 166 </div> 177 167 </section> 178 168 169 + {{ end }} 170 + 171 + {{ define "attribution" }} 172 + {{ $commit := index . 0 }} 173 + {{ $map := index . 1 }} 174 + <span class="flex items-center gap-1"> 175 + {{ $author := index $map $commit.Author.Email }} 176 + {{ $coauthors := $commit.CoAuthors }} 177 + {{ $all := list }} 178 + 179 + {{ if $author }} 180 + {{ $all = append $all $author }} 181 + {{ end }} 182 + {{ range $coauthors }} 183 + {{ $co := index $map .Email }} 184 + {{ if $co }} 185 + {{ $all = append $all $co }} 186 + {{ end }} 187 + {{ end }} 188 + 189 + {{ template "fragments/tinyAvatarList" (dict "all" $all "classes" "size-6") }} 190 + <a href="{{ if $author }}/{{ $author }}{{ else }}mailto:{{ $commit.Author.Email }}{{ end }}" 191 + class="no-underline hover:underline"> 192 + {{ if $author }}{{ resolve $author }}{{ else }}{{ $commit.Author.Name }}{{ end }} 193 + {{ if $coauthors }} +{{ length $coauthors }}{{ end }} 194 + </a> 195 + </span> 179 196 {{ end }} 180 197 181 198 {{ define "repoAfter" }}
+2 -1
appview/pages/templates/repo/new.html
··· 155 155 class="mr-2" 156 156 id="domain-{{ . }}" 157 157 required 158 + {{if eq (len $.Knots) 1}}checked{{end}} 158 159 /> 159 160 <label for="domain-{{ . }}" class="dark:text-white lowercase">{{ . }}</label> 160 161 </div> ··· 164 165 </div> 165 166 <p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> 166 167 A knot hosts repository data and handles Git operations. 167 - You can also <a href="/knots" class="underline">register your own knot</a>. 168 + You can also <a href="/settings/knots" class="underline">register your own knot</a>. 168 169 </p> 169 170 </div> 170 171 {{ end }}
+7 -6
appview/pages/templates/repo/pipelines/fragments/logBlock.html
··· 2 2 <div id="lines" hx-swap-oob="beforeend"> 3 3 <details id="step-{{ .Id }}" {{if not .Collapsed}}open{{end}} class="group pb-2 rounded-sm border border-gray-200 dark:border-gray-700"> 4 4 <summary class="sticky top-0 pt-2 px-2 group-open:pb-2 group-open:mb-2 list-none cursor-pointer group-open:border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:text-gray-500 hover:dark:text-gray-400"> 5 - <div class="group-open:hidden flex items-center gap-1"> 6 - {{ i "chevron-right" "w-4 h-4" }} {{ .Name }} 7 - </div> 8 - <div class="hidden group-open:flex items-center gap-1"> 9 - {{ i "chevron-down" "w-4 h-4" }} {{ .Name }} 10 - </div> 5 + <div class="group-open:hidden flex items-center gap-1">{{ i "chevron-right" "w-4 h-4" }} {{ template "stepHeader" . }}</div> 6 + <div class="hidden group-open:flex items-center gap-1">{{ i "chevron-down" "w-4 h-4" }} {{ template "stepHeader" . }}</div> 11 7 </summary> 12 8 <div class="font-mono whitespace-pre overflow-x-auto px-2"><div class="text-blue-600 dark:text-blue-300">{{ .Command }}</div><div id="step-body-{{ .Id }}"></div></div> 13 9 </details> 14 10 </div> 15 11 {{ end }} 12 + 13 + {{ define "stepHeader" }} 14 + {{ .Name }} 15 + <span class="ml-auto text-sm text-gray-500 tabular-nums" data-timer="{{ .Id }}" data-start="{{ .StartTime.Unix }}"></span> 16 + {{ end }}
+9
appview/pages/templates/repo/pipelines/fragments/logBlockEnd.html
··· 1 + {{ define "repo/pipelines/fragments/logBlockEnd" }} 2 + <span 3 + class="ml-auto text-sm text-gray-500 tabular-nums" 4 + data-timer="{{ .Id }}" 5 + data-start="{{ .StartTime.Unix }}" 6 + data-end="{{ .EndTime.Unix }}" 7 + hx-swap-oob="outerHTML:[data-timer='{{ .Id }}']"></span> 8 + {{ end }} 9 +
+15 -3
appview/pages/templates/repo/pipelines/pipelines.html
··· 12 12 {{ range .Pipelines }} 13 13 {{ block "pipeline" (list $ .) }} {{ end }} 14 14 {{ else }} 15 - <p class="text-center pt-5 text-gray-400 dark:text-gray-500"> 16 - No pipelines run for this repository. 17 - </p> 15 + <div class="py-6 w-fit flex flex-col gap-4 mx-auto"> 16 + <p> 17 + No pipelines have been run for this repository yet. To get started: 18 + </p> 19 + {{ $bullet := "mx-2 text-xs bg-gray-200 dark:bg-gray-600 rounded-full size-5 flex items-center justify-center font-mono inline-flex align-middle" }} 20 + <p> 21 + <span class="{{ $bullet }}">1</span>First, choose a spindle in your 22 + <a href="/{{ .RepoInfo.FullName }}/settings?tab=pipelines" class="underline">repository settings</a>. 23 + </p> 24 + <p> 25 + <span class="{{ $bullet }}">2</span>Configure your CI/CD 26 + <a href="https://tangled.org/@tangled.org/core/blob/master/docs/spindle/pipeline.md" class="underline">pipeline</a>. 27 + </p> 28 + <p><span class="{{ $bullet }}">3</span>Trigger a workflow with a push or a pull-request!</p> 29 + </div> 18 30 {{ end }} 19 31 </div> 20 32 </div>
+6
appview/pages/templates/repo/pipelines/workflow.html
··· 15 15 {{ block "logs" . }} {{ end }} 16 16 </div> 17 17 </section> 18 + {{ template "fragments/workflow-timers" }} 18 19 {{ end }} 19 20 20 21 {{ define "sidebar" }} ··· 58 59 hx-ext="ws" 59 60 ws-connect="/{{ $.RepoInfo.FullName }}/pipelines/{{ .Pipeline.Id }}/workflow/{{ .Workflow }}/logs"> 60 61 <div id="lines" class="flex flex-col gap-2"> 62 + <div class="text-base text-gray-500 flex items-center justify-center italic p-12 only:flex hidden border border-gray-200 dark:border-gray-700 rounded"> 63 + <span class="flex items-center gap-2"> 64 + {{ i "triangle-alert" "size-4" }} No logs for this workflow 65 + </span> 66 + </div> 61 67 </div> 62 68 </div> 63 69 {{ end }}
+19
appview/pages/templates/repo/pulls/fragments/og.html
··· 1 + {{ define "repo/pulls/fragments/og" }} 2 + {{ $title := printf "%s #%d" .Pull.Title .Pull.PullId }} 3 + {{ $description := or .Pull.Body .RepoInfo.Description }} 4 + {{ $url := printf "https://tangled.org/%s/pulls/%d" .RepoInfo.FullName .Pull.PullId }} 5 + {{ $imageUrl := printf "https://tangled.org/%s/pulls/%d/opengraph" .RepoInfo.FullName .Pull.PullId }} 6 + 7 + <meta property="og:title" content="{{ unescapeHtml $title }}" /> 8 + <meta property="og:type" content="object" /> 9 + <meta property="og:url" content="{{ $url }}" /> 10 + <meta property="og:description" content="{{ $description }}" /> 11 + <meta property="og:image" content="{{ $imageUrl }}" /> 12 + <meta property="og:image:width" content="1200" /> 13 + <meta property="og:image:height" content="600" /> 14 + 15 + <meta name="twitter:card" content="summary_large_image" /> 16 + <meta name="twitter:title" content="{{ unescapeHtml $title }}" /> 17 + <meta name="twitter:description" content="{{ $description }}" /> 18 + <meta name="twitter:image" content="{{ $imageUrl }}" /> 19 + {{ end }}
+81 -72
appview/pages/templates/repo/pulls/fragments/pullActions.html
··· 22 22 {{ $isLastRound := eq $roundNumber $lastIdx }} 23 23 {{ $isSameRepoBranch := .Pull.IsBranchBased }} 24 24 {{ $isUpToDate := .ResubmitCheck.No }} 25 - <div class="relative w-fit"> 26 - <div id="actions-{{$roundNumber}}" class="flex flex-wrap gap-2"> 27 - <button 28 - hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ $roundNumber }}/comment" 29 - hx-target="#actions-{{$roundNumber}}" 30 - hx-swap="outerHtml" 31 - class="btn p-2 flex items-center gap-2 no-underline hover:no-underline group"> 32 - {{ i "message-square-plus" "w-4 h-4" }} 33 - <span>comment</span> 34 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 35 - </button> 36 - {{ if and $isPushAllowed $isOpen $isLastRound }} 37 - {{ $disabled := "" }} 38 - {{ if $isConflicted }} 39 - {{ $disabled = "disabled" }} 40 - {{ end }} 41 - <button 42 - hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/merge" 43 - hx-swap="none" 44 - hx-confirm="Are you sure you want to merge pull #{{ .Pull.PullId }} into the `{{ .Pull.TargetBranch }}` branch?" 45 - class="btn p-2 flex items-center gap-2 group" {{ $disabled }}> 46 - {{ i "git-merge" "w-4 h-4" }} 47 - <span>merge{{if $stackCount}} {{$stackCount}}{{end}}</span> 48 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 49 - </button> 50 - {{ end }} 25 + <div id="actions-{{$roundNumber}}" class="flex flex-wrap gap-2 relative"> 26 + <button 27 + hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ $roundNumber }}/comment" 28 + hx-target="#actions-{{$roundNumber}}" 29 + hx-swap="outerHtml" 30 + class="btn p-2 flex items-center gap-2 no-underline hover:no-underline group"> 31 + {{ i "message-square-plus" "w-4 h-4" }} 32 + <span>comment</span> 33 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 34 + </button> 35 + {{ if .BranchDeleteStatus }} 36 + <button 37 + hx-delete="/{{ .BranchDeleteStatus.Repo.Did }}/{{ .BranchDeleteStatus.Repo.Name }}/branches" 38 + hx-vals='{"branch": "{{ .BranchDeleteStatus.Branch }}" }' 39 + hx-swap="none" 40 + class="btn p-2 flex items-center gap-2 no-underline hover:no-underline group text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"> 41 + {{ i "git-branch" "w-4 h-4" }} 42 + <span>delete branch</span> 43 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 44 + </button> 45 + {{ end }} 46 + {{ if and $isPushAllowed $isOpen $isLastRound }} 47 + {{ $disabled := "" }} 48 + {{ if $isConflicted }} 49 + {{ $disabled = "disabled" }} 50 + {{ end }} 51 + <button 52 + hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/merge" 53 + hx-swap="none" 54 + hx-confirm="Are you sure you want to merge pull #{{ .Pull.PullId }} into the `{{ .Pull.TargetBranch }}` branch?" 55 + class="btn p-2 flex items-center gap-2 group" {{ $disabled }}> 56 + {{ i "git-merge" "w-4 h-4" }} 57 + <span>merge{{if $stackCount}} {{$stackCount}}{{end}}</span> 58 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 59 + </button> 60 + {{ end }} 51 61 52 - {{ if and $isPullAuthor $isOpen $isLastRound }} 53 - {{ $disabled := "" }} 54 - {{ if $isUpToDate }} 55 - {{ $disabled = "disabled" }} 62 + {{ if and $isPullAuthor $isOpen $isLastRound }} 63 + {{ $disabled := "" }} 64 + {{ if $isUpToDate }} 65 + {{ $disabled = "disabled" }} 66 + {{ end }} 67 + <button id="resubmitBtn" 68 + {{ if not .Pull.IsPatchBased }} 69 + hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit" 70 + {{ else }} 71 + hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit" 72 + hx-target="#actions-{{$roundNumber}}" 73 + hx-swap="outerHtml" 56 74 {{ end }} 57 - <button id="resubmitBtn" 58 - {{ if not .Pull.IsPatchBased }} 59 - hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit" 60 - {{ else }} 61 - hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit" 62 - hx-target="#actions-{{$roundNumber}}" 63 - hx-swap="outerHtml" 64 - {{ end }} 65 75 66 - hx-disabled-elt="#resubmitBtn" 67 - class="btn p-2 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed group" {{ $disabled }} 76 + hx-disabled-elt="#resubmitBtn" 77 + class="btn p-2 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed group" {{ $disabled }} 68 78 69 - {{ if $disabled }} 70 - title="Update this branch to resubmit this pull request" 71 - {{ else }} 72 - title="Resubmit this pull request" 73 - {{ end }} 74 - > 75 - {{ i "rotate-ccw" "w-4 h-4" }} 76 - <span>resubmit</span> 77 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 78 - </button> 79 - {{ end }} 79 + {{ if $disabled }} 80 + title="Update this branch to resubmit this pull request" 81 + {{ else }} 82 + title="Resubmit this pull request" 83 + {{ end }} 84 + > 85 + {{ i "rotate-ccw" "w-4 h-4" }} 86 + <span>resubmit</span> 87 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 88 + </button> 89 + {{ end }} 80 90 81 - {{ if and (or $isPullAuthor $isPushAllowed) $isOpen $isLastRound }} 82 - <button 83 - hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/close" 84 - hx-swap="none" 85 - class="btn p-2 flex items-center gap-2 group"> 86 - {{ i "ban" "w-4 h-4" }} 87 - <span>close</span> 88 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 89 - </button> 90 - {{ end }} 91 + {{ if and (or $isPullAuthor $isPushAllowed) $isOpen $isLastRound }} 92 + <button 93 + hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/close" 94 + hx-swap="none" 95 + class="btn p-2 flex items-center gap-2 group"> 96 + {{ i "ban" "w-4 h-4" }} 97 + <span>close</span> 98 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 99 + </button> 100 + {{ end }} 91 101 92 - {{ if and (or $isPullAuthor $isPushAllowed) $isClosed $isLastRound }} 93 - <button 94 - hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/reopen" 95 - hx-swap="none" 96 - class="btn p-2 flex items-center gap-2 group"> 97 - {{ i "refresh-ccw-dot" "w-4 h-4" }} 98 - <span>reopen</span> 99 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 100 - </button> 101 - {{ end }} 102 - </div> 102 + {{ if and (or $isPullAuthor $isPushAllowed) $isClosed $isLastRound }} 103 + <button 104 + hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/reopen" 105 + hx-swap="none" 106 + class="btn p-2 flex items-center gap-2 group"> 107 + {{ i "refresh-ccw-dot" "w-4 h-4" }} 108 + <span>reopen</span> 109 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 110 + </button> 111 + {{ end }} 103 112 </div> 104 113 {{ end }} 105 114
+12 -10
appview/pages/templates/repo/pulls/fragments/pullHeader.html
··· 42 42 {{ if not .Pull.IsPatchBased }} 43 43 from 44 44 <span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center"> 45 - {{ if .Pull.IsForkBased }} 46 - {{ if .Pull.PullSource.Repo }} 47 - {{ $owner := resolve .Pull.PullSource.Repo.Did }} 48 - <a href="/{{ $owner }}/{{ .Pull.PullSource.Repo.Name }}" class="no-underline hover:underline">{{ $owner }}/{{ .Pull.PullSource.Repo.Name }}</a>: 49 - {{- else -}} 50 - <span class="italic">[deleted fork]</span> 51 - {{- end -}} 52 - {{- end -}} 53 - {{- .Pull.PullSource.Branch -}} 45 + {{ if not .Pull.IsForkBased }} 46 + {{ $repoPath := .RepoInfo.FullName }} 47 + <a href="/{{ $repoPath }}/tree/{{ pathEscape .Pull.PullSource.Branch }}" class="no-underline hover:underline">{{ .Pull.PullSource.Branch }}</a> 48 + {{ else if .Pull.PullSource.Repo }} 49 + {{ $repoPath := print (resolve .Pull.PullSource.Repo.Did) "/" .Pull.PullSource.Repo.Name }} 50 + <a href="/{{ $repoPath }}" class="no-underline hover:underline">{{ $repoPath }}</a>: 51 + <a href="/{{ $repoPath }}/tree/{{ pathEscape .Pull.PullSource.Branch }}" class="no-underline hover:underline">{{ .Pull.PullSource.Branch }}</a> 52 + {{ else }} 53 + <span class="italic">[deleted fork]</span>: 54 + {{ .Pull.PullSource.Branch }} 55 + {{ end }} 54 56 </span> 55 57 {{ end }} 56 58 </span> ··· 73 75 "Kind" $kind 74 76 "Count" $reactionData.Count 75 77 "IsReacted" (index $.UserReacted $kind) 76 - "ThreadAt" $.Pull.PullAt 78 + "ThreadAt" $.Pull.AtUri 77 79 "Users" $reactionData.Users) 78 80 }} 79 81 {{ end }}
+1 -14
appview/pages/templates/repo/pulls/interdiff.html
··· 28 28 29 29 {{ end }} 30 30 31 - {{ define "topbarLayout" }} 32 - <header class="px-1 col-span-full" style="z-index: 20;"> 33 - {{ template "layouts/fragments/topbar" . }} 34 - </header> 35 - {{ end }} 36 - 37 31 {{ define "mainLayout" }} 38 - <div class="px-1 col-span-full flex flex-col gap-4"> 32 + <div class="px-1 col-span-full flex-grow flex flex-col gap-4"> 39 33 {{ block "contentLayout" . }} 40 34 {{ block "content" . }}{{ end }} 41 35 {{ end }} ··· 52 46 {{ end }} 53 47 </div> 54 48 {{ end }} 55 - 56 - {{ define "footerLayout" }} 57 - <footer class="px-1 col-span-full mt-12"> 58 - {{ template "layouts/fragments/footer" . }} 59 - </footer> 60 - {{ end }} 61 - 62 49 63 50 {{ define "contentAfter" }} 64 51 {{ template "repo/fragments/interdiff" (list .RepoInfo.FullName .Interdiff .DiffOpts) }}
+2 -14
appview/pages/templates/repo/pulls/patch.html
··· 34 34 </section> 35 35 {{ end }} 36 36 37 - {{ define "topbarLayout" }} 38 - <header class="px-1 col-span-full" style="z-index: 20;"> 39 - {{ template "layouts/fragments/topbar" . }} 40 - </header> 41 - {{ end }} 42 - 43 37 {{ define "mainLayout" }} 44 - <div class="px-1 col-span-full flex flex-col gap-4"> 38 + <div class="px-1 col-span-full flex-grow flex flex-col gap-4"> 45 39 {{ block "contentLayout" . }} 46 40 {{ block "content" . }}{{ end }} 47 41 {{ end }} ··· 59 53 </div> 60 54 {{ end }} 61 55 62 - {{ define "footerLayout" }} 63 - <footer class="px-1 col-span-full mt-12"> 64 - {{ template "layouts/fragments/footer" . }} 65 - </footer> 66 - {{ end }} 67 - 68 56 {{ define "contentAfter" }} 69 - {{ template "repo/fragments/diff" (list .RepoInfo.FullName .Diff .DiffOpts) }} 57 + {{ template "repo/fragments/diff" (list .Diff .DiffOpts) }} 70 58 {{end}} 71 59 72 60 {{ define "contentAfterLeft" }}
+16 -6
appview/pages/templates/repo/pulls/pull.html
··· 3 3 {{ end }} 4 4 5 5 {{ define "extrameta" }} 6 - {{ $title := printf "%s &middot; pull #%d &middot; %s" .Pull.Title .Pull.PullId .RepoInfo.FullName }} 7 - {{ $url := printf "https://tangled.org/%s/pulls/%d" .RepoInfo.FullName .Pull.PullId }} 8 - 9 - {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 6 + {{ template "repo/pulls/fragments/og" (dict "RepoInfo" .RepoInfo "Pull" .Pull) }} 10 7 {{ end }} 11 8 12 9 {{ define "repoContentLayout" }} ··· 21 18 {{ template "repo/fragments/labelPanel" 22 19 (dict "RepoInfo" $.RepoInfo 23 20 "Defs" $.LabelDefs 24 - "Subject" $.Pull.PullAt 21 + "Subject" $.Pull.AtUri 25 22 "State" $.Pull.Labels) }} 26 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 }} 27 28 </div> 28 29 </div> 29 30 {{ end }} ··· 187 188 {{ end }} 188 189 189 190 {{ if $.LoggedInUser }} 190 - {{ template "repo/pulls/fragments/pullActions" (dict "LoggedInUser" $.LoggedInUser "Pull" $.Pull "RepoInfo" $.RepoInfo "RoundNumber" .RoundNumber "MergeCheck" $.MergeCheck "ResubmitCheck" $.ResubmitCheck "Stack" $.Stack) }} 191 + {{ template "repo/pulls/fragments/pullActions" 192 + (dict 193 + "LoggedInUser" $.LoggedInUser 194 + "Pull" $.Pull 195 + "RepoInfo" $.RepoInfo 196 + "RoundNumber" .RoundNumber 197 + "MergeCheck" $.MergeCheck 198 + "ResubmitCheck" $.ResubmitCheck 199 + "BranchDeleteStatus" $.BranchDeleteStatus 200 + "Stack" $.Stack) }} 191 201 {{ else }} 192 202 <div class="bg-amber-50 dark:bg-amber-900 border border-amber-500 rounded drop-shadow-sm p-2 relative flex gap-2 items-center w-fit"> 193 203 <a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2">
+61 -31
appview/pages/templates/repo/pulls/pulls.html
··· 8 8 {{ end }} 9 9 10 10 {{ define "repoContent" }} 11 - <div class="flex justify-between items-center"> 12 - <div class="flex gap-4"> 13 - <a 14 - href="?state=open" 15 - class="flex items-center gap-2 {{ if .FilteringBy.IsOpen }}font-bold {{ else }}text-gray-500 dark:text-gray-400{{ end }}" 16 - > 17 - {{ i "git-pull-request" "w-4 h-4" }} 18 - <span>{{ .RepoInfo.Stats.PullCount.Open }} open</span> 19 - </a> 20 - <a 21 - href="?state=merged" 22 - class="flex items-center gap-2 {{ if .FilteringBy.IsMerged }}font-bold {{ else }}text-gray-500 dark:text-gray-400{{ end }}" 23 - > 24 - {{ i "git-merge" "w-4 h-4" }} 25 - <span>{{ .RepoInfo.Stats.PullCount.Merged }} merged</span> 26 - </a> 27 - <a 28 - href="?state=closed" 29 - class="flex items-center gap-2 {{ if .FilteringBy.IsClosed }}font-bold {{ else }}text-gray-500 dark:text-gray-400{{ end }}" 30 - > 31 - {{ i "ban" "w-4 h-4" }} 32 - <span>{{ .RepoInfo.Stats.PullCount.Closed }} closed</span> 33 - </a> 34 - </div> 11 + {{ $active := "closed" }} 12 + {{ if .FilteringBy.IsOpen }} 13 + {{ $active = "open" }} 14 + {{ else if .FilteringBy.IsMerged }} 15 + {{ $active = "merged" }} 16 + {{ end }} 17 + {{ $open := 18 + (dict 19 + "Key" "open" 20 + "Value" "open" 21 + "Icon" "git-pull-request" 22 + "Meta" (string .RepoInfo.Stats.PullCount.Open)) }} 23 + {{ $merged := 24 + (dict 25 + "Key" "merged" 26 + "Value" "merged" 27 + "Icon" "git-merge" 28 + "Meta" (string .RepoInfo.Stats.PullCount.Merged)) }} 29 + {{ $closed := 30 + (dict 31 + "Key" "closed" 32 + "Value" "closed" 33 + "Icon" "ban" 34 + "Meta" (string .RepoInfo.Stats.PullCount.Closed)) }} 35 + {{ $values := list $open $merged $closed }} 36 + <div class="grid gap-2 grid-cols-[auto_1fr_auto] grid-row-2"> 37 + <form class="flex relative col-span-3 sm:col-span-1 sm:col-start-2" method="GET"> 38 + <input type="hidden" name="state" value="{{ .FilteringBy.String }}"> 39 + <div class="flex-1 flex relative"> 40 + <input 41 + id="search-q" 42 + class="flex-1 py-1 pl-2 pr-10 mr-[-1px] rounded-r-none focus:border-0 focus:outline-none focus:ring focus:ring-blue-400 ring-inset peer" 43 + type="text" 44 + name="q" 45 + value="{{ .FilterQuery }}" 46 + placeholder=" " 47 + > 35 48 <a 36 - href="/{{ .RepoInfo.FullName }}/pulls/new" 37 - class="btn-create text-sm flex items-center gap-2 no-underline hover:no-underline hover:text-white" 49 + href="?state={{ .FilteringBy.String }}" 50 + class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hidden peer-[:not(:placeholder-shown)]:block" 38 51 > 39 - {{ i "git-pull-request-create" "w-4 h-4" }} 40 - <span>new</span> 52 + {{ i "x" "w-4 h-4" }} 41 53 </a> 54 + </div> 55 + <button 56 + type="submit" 57 + class="p-2 text-gray-400 border rounded-r border-gray-400 dark:border-gray-600" 58 + > 59 + {{ i "search" "w-4 h-4" }} 60 + </button> 61 + </form> 62 + <div class="sm:row-start-1"> 63 + {{ template "fragments/tabSelector" (dict "Name" "state" "Values" $values "Active" $active "Include" "#search-q") }} 42 64 </div> 43 - <div class="error" id="pulls"></div> 65 + <a 66 + href="/{{ .RepoInfo.FullName }}/pulls/new" 67 + class="col-start-3 btn-create text-sm flex items-center gap-2 no-underline hover:no-underline hover:text-white" 68 + > 69 + {{ i "git-pull-request-create" "w-4 h-4" }} 70 + <span>new</span> 71 + </a> 72 + </div> 73 + <div class="error" id="pulls"></div> 44 74 {{ end }} 45 75 46 76 {{ define "repoAfter" }} ··· 133 163 {{ i "chevrons-down-up" "w-4 h-4" }} hide {{ len $otherPulls }} pull{{$s}} in this stack 134 164 </div> 135 165 </summary> 136 - {{ block "pullList" (list $otherPulls $) }} {{ end }} 166 + {{ block "stackedPullList" (list $otherPulls $) }} {{ end }} 137 167 </details> 138 168 {{ end }} 139 169 {{ end }} ··· 142 172 </div> 143 173 {{ end }} 144 174 145 - {{ define "pullList" }} 175 + {{ define "stackedPullList" }} 146 176 {{ $list := index . 0 }} 147 177 {{ $root := index . 1 }} 148 178 <div class="grid grid-cols-1 rounded-b border-b border-t border-gray-200 dark:border-gray-900 divide-y divide-gray-200 dark:divide-gray-900">
+22 -12
appview/pages/templates/repo/settings/access.html
··· 29 29 {{ template "addCollaboratorButton" . }} 30 30 {{ end }} 31 31 {{ range .Collaborators }} 32 + {{ $handle := resolve .Did }} 32 33 <div class="border border-gray-200 dark:border-gray-700 rounded p-4"> 33 34 <div class="flex items-center gap-3"> 34 35 <img 35 - src="{{ fullAvatar .Handle }}" 36 - alt="{{ .Handle }}" 36 + src="{{ fullAvatar $handle }}" 37 + alt="{{ $handle }}" 37 38 class="rounded-full h-10 w-10 border border-gray-300 dark:border-gray-600 flex-shrink-0"/> 38 39 39 40 <div class="flex-1 min-w-0"> 40 - <a href="/{{ .Handle }}" class="block truncate"> 41 - {{ didOrHandle .Did .Handle }} 41 + <a href="/{{ $handle }}" class="block truncate"> 42 + {{ $handle }} 42 43 </a> 43 44 <p class="text-sm text-gray-500 dark:text-gray-400">{{ .Role }}</p> 44 45 </div> ··· 66 67 <div 67 68 id="add-collaborator-modal" 68 69 popover 69 - class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 70 + class=" 71 + bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 72 + dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50 73 + w-full md:w-96 p-4 rounded drop-shadow overflow-visible"> 70 74 {{ template "addCollaboratorModal" . }} 71 75 </div> 72 76 {{ end }} ··· 82 86 ADD COLLABORATOR 83 87 </label> 84 88 <p class="text-sm text-gray-500 dark:text-gray-400">Collaborators can push to this repository.</p> 85 - <input 86 - type="text" 87 - id="add-collaborator" 88 - name="collaborator" 89 - required 90 - placeholder="@foo.bsky.social" 91 - /> 89 + <actor-typeahead> 90 + <input 91 + autocapitalize="none" 92 + autocorrect="off" 93 + autocomplete="off" 94 + type="text" 95 + id="add-collaborator" 96 + name="collaborator" 97 + required 98 + placeholder="user.tngl.sh" 99 + class="w-full" 100 + /> 101 + </actor-typeahead> 92 102 <div class="flex gap-2 pt-2"> 93 103 <button 94 104 type="button"
+47
appview/pages/templates/repo/settings/general.html
··· 6 6 {{ template "repo/settings/fragments/sidebar" . }} 7 7 </div> 8 8 <div class="col-span-1 md:col-span-3 flex flex-col gap-6 p-2"> 9 + {{ template "baseSettings" . }} 9 10 {{ template "branchSettings" . }} 10 11 {{ template "defaultLabelSettings" . }} 11 12 {{ template "customLabelSettings" . }} ··· 13 14 <div id="operation-error" class="text-red-500 dark:text-red-400"></div> 14 15 </div> 15 16 </section> 17 + {{ end }} 18 + 19 + {{ define "baseSettings" }} 20 + <form hx-put="/{{ $.RepoInfo.FullName }}/settings/base" hx-swap="none"> 21 + <fieldset 22 + class="" 23 + {{ if not .RepoInfo.Roles.IsOwner }}disabled{{ end }} 24 + > 25 + <h2 class="text-sm pb-2 uppercase font-bold">Description</h2> 26 + <textarea 27 + rows="3" 28 + class="w-full mb-2" 29 + id="base-form-description" 30 + name="description" 31 + >{{ .RepoInfo.Description }}</textarea> 32 + <h2 class="text-sm pb-2 uppercase font-bold">Website URL</h2> 33 + <input 34 + type="text" 35 + class="w-full mb-2" 36 + id="base-form-website" 37 + name="website" 38 + value="{{ .RepoInfo.Website }}" 39 + > 40 + <h2 class="text-sm pb-2 uppercase font-bold">Topics</h2> 41 + <p class="text-gray-500 dark:text-gray-400"> 42 + List of topics separated by spaces. 43 + </p> 44 + <textarea 45 + rows="2" 46 + class="w-full my-2" 47 + id="base-form-topics" 48 + name="topics" 49 + >{{ range $topic := .RepoInfo.Topics }}{{ $topic }} {{ end }}</textarea> 50 + <div id="repo-base-settings-error" class="text-red-500 dark:text-red-400"></div> 51 + <div class="flex justify-end pt-2"> 52 + <button 53 + type="submit" 54 + class="btn-create flex items-center gap-2 group" 55 + > 56 + {{ i "save" "w-4 h-4" }} 57 + save 58 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 59 + </button> 60 + </div> 61 + </fieldset> 62 + </form> 16 63 {{ end }} 17 64 18 65 {{ define "branchSettings" }}
+8
appview/pages/templates/repo/tree.html
··· 59 59 {{ $icon := "folder" }} 60 60 {{ $iconStyle := "size-4 fill-current" }} 61 61 62 + {{ if .IsSubmodule }} 63 + {{ $link = printf "/%s/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) $.TreePath .Name }} 64 + {{ $icon = "folder-input" }} 65 + {{ $iconStyle = "size-4" }} 66 + {{ end }} 67 + 62 68 {{ if .IsFile }} 69 + {{ $link = printf "/%s/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) $.TreePath .Name }} 63 70 {{ $icon = "file" }} 64 71 {{ $iconStyle = "size-4" }} 65 72 {{ end }} 73 + 66 74 <a href="{{ $link }}" class="{{ $linkstyle }}"> 67 75 <div class="flex items-center gap-2"> 68 76 {{ i $icon $iconStyle "flex-shrink-0" }}
+22 -6
appview/pages/templates/spindles/dashboard.html
··· 1 - {{ define "title" }}{{.Spindle.Instance}} &middot; spindles{{ end }} 1 + {{ define "title" }}{{.Spindle.Instance}} &middot; {{ .Tab }} settings{{ end }} 2 2 3 3 {{ define "content" }} 4 - <div class="px-6 py-4"> 4 + <div class="p-6"> 5 + <p class="text-xl font-bold dark:text-white">Settings</p> 6 + </div> 7 + <div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 8 + <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6"> 9 + <div class="col-span-1"> 10 + {{ template "user/settings/fragments/sidebar" . }} 11 + </div> 12 + <div class="col-span-1 md:col-span-3 flex flex-col gap-6"> 13 + {{ template "spindleDash" . }} 14 + </div> 15 + </section> 16 + </div> 17 + {{ end }} 18 + 19 + {{ define "spindleDash" }} 20 + <div> 5 21 <div class="flex justify-between items-center"> 6 - <h1 class="text-xl font-bold dark:text-white">{{ .Spindle.Instance }}</h1> 22 + <h2 class="text-sm pb-2 uppercase font-bold">{{ .Tab }} &middot; {{ .Spindle.Instance }}</h2> 7 23 <div id="right-side" class="flex gap-2"> 8 24 {{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2" }} 9 25 {{ $isOwner := and .LoggedInUser (eq .LoggedInUser.Did .Spindle.Owner) }} ··· 71 87 <button 72 88 class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 73 89 title="Delete spindle" 74 - hx-delete="/spindles/{{ .Instance }}" 90 + hx-delete="/settings/spindles/{{ .Instance }}" 75 91 hx-swap="outerHTML" 76 92 hx-confirm="Are you sure you want to delete the spindle '{{ .Instance }}'?" 77 93 hx-headers='{"shouldRedirect": "true"}' ··· 87 103 <button 88 104 class="btn gap-2 group" 89 105 title="Retry spindle verification" 90 - hx-post="/spindles/{{ .Instance }}/retry" 106 + hx-post="/settings/spindles/{{ .Instance }}/retry" 91 107 hx-swap="none" 92 108 hx-headers='{"shouldRefresh": "true"}' 93 109 > ··· 104 120 <button 105 121 class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 106 122 title="Remove member" 107 - hx-post="/spindles/{{ $root.Spindle.Instance }}/remove" 123 + hx-post="/settings/spindles/{{ $root.Spindle.Instance }}/remove" 108 124 hx-swap="none" 109 125 hx-vals='{"member": "{{$member}}" }' 110 126 hx-confirm="Are you sure you want to remove {{ resolve $member }} from this instance?"
+17 -9
appview/pages/templates/spindles/fragments/addMemberModal.html
··· 13 13 <div 14 14 id="add-member-{{ .Instance }}" 15 15 popover 16 - class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 16 + class=" 17 + bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50 18 + w-full md:w-96 p-4 rounded drop-shadow overflow-visible"> 17 19 {{ block "addSpindleMemberPopover" . }} {{ end }} 18 20 </div> 19 21 {{ end }} 20 22 21 23 {{ define "addSpindleMemberPopover" }} 22 24 <form 23 - hx-post="/spindles/{{ .Instance }}/add" 25 + hx-post="/settings/spindles/{{ .Instance }}/add" 24 26 hx-indicator="#spinner" 25 27 hx-swap="none" 26 28 class="flex flex-col gap-2" ··· 29 31 ADD MEMBER 30 32 </label> 31 33 <p class="text-sm text-gray-500 dark:text-gray-400">Members can register repositories and run workflows on this spindle.</p> 32 - <input 33 - type="text" 34 - id="member-did-{{ .Id }}" 35 - name="member" 36 - required 37 - placeholder="@foo.bsky.social" 38 - /> 34 + <actor-typeahead> 35 + <input 36 + autocapitalize="none" 37 + autocorrect="off" 38 + autocomplete="off" 39 + type="text" 40 + id="member-did-{{ .Id }}" 41 + name="member" 42 + required 43 + placeholder="user.tngl.sh" 44 + class="w-full" 45 + /> 46 + </actor-typeahead> 39 47 <div class="flex gap-2 pt-2"> 40 48 <button 41 49 type="button"
+3 -3
appview/pages/templates/spindles/fragments/spindleListing.html
··· 7 7 8 8 {{ define "spindleLeftSide" }} 9 9 {{ if .Verified }} 10 - <a href="/spindles/{{ .Instance }}" class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]"> 10 + <a href="/settings/spindles/{{ .Instance }}" class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]"> 11 11 {{ i "hard-drive" "w-4 h-4" }} 12 12 <span class="hover:underline"> 13 13 {{ .Instance }} ··· 50 50 <button 51 51 class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 52 52 title="Delete spindle" 53 - hx-delete="/spindles/{{ .Instance }}" 53 + hx-delete="/settings/spindles/{{ .Instance }}" 54 54 hx-swap="outerHTML" 55 55 hx-target="#spindle-{{.Id}}" 56 56 hx-confirm="Are you sure you want to delete the spindle '{{ .Instance }}'?" ··· 66 66 <button 67 67 class="btn gap-2 group" 68 68 title="Retry spindle verification" 69 - hx-post="/spindles/{{ .Instance }}/retry" 69 + hx-post="/settings/spindles/{{ .Instance }}/retry" 70 70 hx-swap="none" 71 71 hx-target="#spindle-{{.Id}}" 72 72 >
+90 -59
appview/pages/templates/spindles/index.html
··· 1 - {{ define "title" }}spindles{{ end }} 1 + {{ define "title" }}{{ .Tab }} settings{{ end }} 2 2 3 3 {{ define "content" }} 4 - <div class="px-6 py-4 flex items-center justify-between gap-4 align-bottom"> 5 - <h1 class="text-xl font-bold dark:text-white">Spindles</h1> 6 - <span class="flex items-center gap-1"> 7 - {{ i "book" "w-3 h-3" }} 8 - <a href="https://tangled.org/@tangled.org/core/blob/master/docs/spindle/hosting.md">docs</a> 9 - </span> 4 + <div class="p-6"> 5 + <p class="text-xl font-bold dark:text-white">Settings</p> 6 + </div> 7 + <div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 8 + <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6"> 9 + <div class="col-span-1"> 10 + {{ template "user/settings/fragments/sidebar" . }} 11 + </div> 12 + <div class="col-span-1 md:col-span-3 flex flex-col gap-6"> 13 + {{ template "spindleList" . }} 14 + </div> 15 + </section> 16 + </div> 17 + {{ end }} 18 + 19 + {{ define "spindleList" }} 20 + <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 21 + <div class="col-span-1 md:col-span-2"> 22 + <h2 class="text-sm pb-2 uppercase font-bold">Spindle</h2> 23 + {{ block "about" . }} {{ end }} 24 + </div> 25 + <div class="col-span-1 md:col-span-1 md:justify-self-end"> 26 + {{ template "docsButton" . }} 27 + </div> 10 28 </div> 11 29 12 - <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 30 + <section> 13 31 <div class="flex flex-col gap-6"> 14 - {{ block "about" . }} {{ end }} 15 32 {{ block "list" . }} {{ end }} 16 33 {{ block "register" . }} {{ end }} 17 34 </div> ··· 20 37 21 38 {{ define "about" }} 22 39 <section class="rounded flex items-center gap-2"> 23 - <p class="text-gray-500 dark:text-gray-400"> 24 - Spindles are small CI runners. 25 - </p> 40 + <p class="text-gray-500 dark:text-gray-400"> 41 + Spindles are small CI runners. 42 + </p> 26 43 </section> 27 44 {{ end }} 28 45 29 46 {{ define "list" }} 30 - <section class="rounded w-full flex flex-col gap-2"> 31 - <h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">your spindles</h2> 32 - <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 w-full"> 33 - {{ range $spindle := .Spindles }} 34 - {{ template "spindles/fragments/spindleListing" . }} 35 - {{ else }} 36 - <div class="flex items-center justify-center p-2 border-b border-gray-200 dark:border-gray-700 text-gray-500"> 37 - no spindles registered yet 38 - </div> 39 - {{ end }} 47 + <section class="rounded w-full flex flex-col gap-2"> 48 + <h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">your spindles</h2> 49 + <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 w-full"> 50 + {{ range $spindle := .Spindles }} 51 + {{ template "spindles/fragments/spindleListing" . }} 52 + {{ else }} 53 + <div class="flex items-center justify-center p-2 border-b border-gray-200 dark:border-gray-700 text-gray-500"> 54 + no spindles registered yet 40 55 </div> 41 - <div id="operation-error" class="text-red-500 dark:text-red-400"></div> 42 - </section> 56 + {{ end }} 57 + </div> 58 + <div id="operation-error" class="text-red-500 dark:text-red-400"></div> 59 + </section> 43 60 {{ end }} 44 61 45 62 {{ define "register" }} 46 - <section class="rounded w-full lg:w-fit flex flex-col gap-2"> 47 - <h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">register a spindle</h2> 48 - <p class="mb-2 dark:text-gray-300">Enter the hostname of your spindle to get started.</p> 49 - <form 50 - hx-post="/spindles/register" 51 - class="max-w-2xl mb-2 space-y-4" 52 - hx-indicator="#register-button" 53 - hx-swap="none" 54 - > 55 - <div class="flex gap-2"> 56 - <input 57 - type="text" 58 - id="instance" 59 - name="instance" 60 - placeholder="spindle.example.com" 61 - required 62 - class="flex-1 w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 px-3 py-2 border rounded" 63 - > 64 - <button 65 - type="submit" 66 - id="register-button" 67 - class="btn rounded flex items-center py-2 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 group" 68 - > 69 - <span class="inline-flex items-center gap-2"> 70 - {{ i "plus" "w-4 h-4" }} 71 - register 72 - </span> 73 - <span class="pl-2 hidden group-[.htmx-request]:inline"> 74 - {{ i "loader-circle" "w-4 h-4 animate-spin" }} 75 - </span> 76 - </button> 77 - </div> 63 + <section class="rounded w-full lg:w-fit flex flex-col gap-2"> 64 + <h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">register a spindle</h2> 65 + <p class="mb-2 dark:text-gray-300">Enter the hostname of your spindle to get started.</p> 66 + <form 67 + hx-post="/settings/spindles/register" 68 + class="max-w-2xl mb-2 space-y-4" 69 + hx-indicator="#register-button" 70 + hx-swap="none" 71 + > 72 + <div class="flex gap-2"> 73 + <input 74 + type="text" 75 + id="instance" 76 + name="instance" 77 + placeholder="spindle.example.com" 78 + required 79 + class="flex-1 w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 px-3 py-2 border rounded" 80 + > 81 + <button 82 + type="submit" 83 + id="register-button" 84 + class="btn rounded flex items-center py-2 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 group" 85 + > 86 + <span class="inline-flex items-center gap-2"> 87 + {{ i "plus" "w-4 h-4" }} 88 + register 89 + </span> 90 + <span class="pl-2 hidden group-[.htmx-request]:inline"> 91 + {{ i "loader-circle" "w-4 h-4 animate-spin" }} 92 + </span> 93 + </button> 94 + </div> 78 95 79 - <div id="register-error" class="dark:text-red-400"></div> 80 - </form> 96 + <div id="register-error" class="dark:text-red-400"></div> 97 + </form> 98 + 99 + </section> 100 + {{ end }} 81 101 82 - </section> 102 + {{ define "docsButton" }} 103 + <a 104 + class="btn flex items-center gap-2" 105 + href="https://tangled.org/@tangled.org/core/blob/master/docs/spindle/hosting.md"> 106 + {{ i "book" "size-4" }} 107 + docs 108 + </a> 109 + <div 110 + id="add-email-modal" 111 + popover 112 + class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 113 + </div> 83 114 {{ end }}
+6 -5
appview/pages/templates/strings/dashboard.html
··· 1 - {{ define "title" }}strings by {{ or .Card.UserHandle .Card.UserDid }}{{ end }} 1 + {{ define "title" }}strings by {{ resolve .Card.UserDid }}{{ end }} 2 2 3 3 {{ define "extrameta" }} 4 - <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" /> 4 + {{ $handle := resolve .Card.UserDid }} 5 + <meta property="og:title" content="{{ $handle }}" /> 5 6 <meta property="og:type" content="profile" /> 6 - <meta property="og:url" content="https://tangled.org/{{ or .Card.UserHandle .Card.UserDid }}" /> 7 - <meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 7 + <meta property="og:url" content="https://tangled.org/{{ $handle }}" /> 8 + <meta property="og:description" content="{{ or .Card.Profile.Description $handle }}" /> 8 9 {{ end }} 9 10 10 11 ··· 35 36 {{ $s := index . 1 }} 36 37 <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800"> 37 38 <div class="font-medium dark:text-white flex gap-2 items-center"> 38 - <a href="/strings/{{ or $root.Card.UserHandle $root.Card.UserDid }}/{{ $s.Rkey }}">{{ $s.Filename }}</a> 39 + <a href="/strings/{{ resolve $root.Card.UserDid }}/{{ $s.Rkey }}">{{ $s.Filename }}</a> 39 40 </div> 40 41 {{ with $s.Description }} 41 42 <div class="text-gray-600 dark:text-gray-300 text-sm">
+14 -10
appview/pages/templates/strings/string.html
··· 1 - {{ define "title" }}{{ .String.Filename }} ยท by {{ didOrHandle .Owner.DID.String .Owner.Handle.String }}{{ end }} 1 + {{ define "title" }}{{ .String.Filename }} ยท by {{ resolve .Owner.DID.String }}{{ end }} 2 2 3 3 {{ define "extrameta" }} 4 - {{ $ownerId := didOrHandle .Owner.DID.String .Owner.Handle.String }} 4 + {{ $ownerId := resolve .Owner.DID.String }} 5 5 <meta property="og:title" content="{{ .String.Filename }} ยท by {{ $ownerId }}" /> 6 6 <meta property="og:type" content="object" /> 7 7 <meta property="og:url" content="https://tangled.org/strings/{{ $ownerId }}/{{ .String.Rkey }}" /> ··· 9 9 {{ end }} 10 10 11 11 {{ define "content" }} 12 - {{ $ownerId := didOrHandle .Owner.DID.String .Owner.Handle.String }} 12 + {{ $ownerId := resolve .Owner.DID.String }} 13 13 <section id="string-header" class="mb-4 py-2 px-6 dark:text-white"> 14 14 <div class="text-lg flex items-center justify-between"> 15 15 <div> ··· 17 17 <span class="select-none">/</span> 18 18 <a href="/strings/{{ $ownerId }}/{{ .String.Rkey }}" class="font-bold">{{ .String.Filename }}</a> 19 19 </div> 20 - {{ if and .LoggedInUser (eq .LoggedInUser.Did .String.Did) }} 21 - <div class="flex gap-2 text-base"> 20 + <div class="flex gap-2 items-stretch text-base"> 21 + {{ if and .LoggedInUser (eq .LoggedInUser.Did .String.Did) }} 22 22 <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group" 23 23 hx-boost="true" 24 24 href="/strings/{{ .String.Did }}/{{ .String.Rkey }}/edit"> ··· 37 37 <span class="hidden md:inline">delete</span> 38 38 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 39 39 </button> 40 - </div> 41 - {{ end }} 40 + {{ end }} 41 + {{ template "fragments/starBtn" 42 + (dict "SubjectAt" .String.AtUri 43 + "IsStarred" .IsStarred 44 + "StarCount" .StarCount) }} 45 + </div> 42 46 </div> 43 47 <span> 44 48 {{ with .String.Description }} ··· 47 51 </span> 48 52 </section> 49 53 <section class="bg-white dark:bg-gray-800 px-6 py-4 rounded relative w-full dark:text-white"> 50 - <div class="flex justify-between items-center text-gray-500 dark:text-gray-400 text-sm md:text-base pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700"> 54 + <div class="flex flex-col md:flex-row md:justify-between md:items-center text-gray-500 dark:text-gray-400 text-sm md:text-base pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700"> 51 55 <span> 52 56 {{ .String.Filename }} 53 57 <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> ··· 75 79 </div> 76 80 <div class="overflow-x-auto overflow-y-hidden relative"> 77 81 {{ if .ShowRendered }} 78 - <div id="blob-contents" class="prose dark:prose-invert">{{ .RenderedContents }}</div> 82 + <div id="blob-contents" class="prose dark:prose-invert">{{ .String.Contents | readme }}</div> 79 83 {{ else }} 80 - <div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ .String.Contents | escapeHtml }}</div> 84 + <div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ code .String.Contents .String.Filename | escapeHtml }}</div> 81 85 {{ end }} 82 86 </div> 83 87 {{ template "fragments/multiline-select" }}
+1 -2
appview/pages/templates/timeline/fragments/goodfirstissues.html
··· 3 3 <a href="/goodfirstissues" class="no-underline hover:no-underline"> 4 4 <div class="flex items-center justify-between gap-2 bg-purple-200 dark:bg-purple-900 border border-purple-400 dark:border-purple-500 rounded mb-4 py-4 px-6 "> 5 5 <div class="flex-1 flex flex-col gap-2"> 6 - <div class="text-purple-500 dark:text-purple-400">Oct 2025</div> 7 6 <p> 8 - Make your first contribution to an open-source project this October. 7 + Make your first contribution to an open-source project. 9 8 <em>good-first-issue</em> helps new contributors find easy ways to 10 9 start contributing to open-source projects. 11 10 </p>
+2 -2
appview/pages/templates/timeline/fragments/hero.html
··· 4 4 <h1 class="font-bold text-4xl">tightly-knit<br>social coding.</h1> 5 5 6 6 <p class="text-lg"> 7 - tangled is new social-enabled git collaboration platform built on <a class="underline" href="https://atproto.com/">atproto</a>. 7 + Tangled is a decentralized Git hosting and collaboration platform. 8 8 </p> 9 9 <p class="text-lg"> 10 - we envision a place where developers have complete ownership of their 10 + We envision a place where developers have complete ownership of their 11 11 code, open source communities can freely self-govern and most 12 12 importantly, coding can be social and fun again. 13 13 </p>
+5 -5
appview/pages/templates/timeline/fragments/timeline.html
··· 14 14 <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm"> 15 15 {{ if .Repo }} 16 16 {{ template "timeline/fragments/repoEvent" (list $ .) }} 17 - {{ else if .Star }} 17 + {{ else if .RepoStar }} 18 18 {{ template "timeline/fragments/starEvent" (list $ .) }} 19 19 {{ else if .Follow }} 20 20 {{ template "timeline/fragments/followEvent" (list $ .) }} ··· 52 52 <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $repo.Created }}</span> 53 53 </div> 54 54 {{ with $repo }} 55 - {{ template "user/fragments/repoCard" (list $root . true true (dict "IsStarred" $event.IsStarred "RepoAt" .RepoAt "Stats" (dict "StarCount" $event.StarCount))) }} 55 + {{ template "user/fragments/repoCard" (list $root . true true (dict "IsStarred" $event.IsStarred "SubjectAt" .RepoAt "StarCount" $event.StarCount)) }} 56 56 {{ end }} 57 57 {{ end }} 58 58 59 59 {{ define "timeline/fragments/starEvent" }} 60 60 {{ $root := index . 0 }} 61 61 {{ $event := index . 1 }} 62 - {{ $star := $event.Star }} 62 + {{ $star := $event.RepoStar }} 63 63 {{ with $star }} 64 - {{ $starrerHandle := resolve .StarredByDid }} 64 + {{ $starrerHandle := resolve .Did }} 65 65 {{ $repoOwnerHandle := resolve .Repo.Did }} 66 66 <div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm"> 67 67 {{ template "user/fragments/picHandleLink" $starrerHandle }} ··· 72 72 <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" .Created }}</span> 73 73 </div> 74 74 {{ with .Repo }} 75 - {{ template "user/fragments/repoCard" (list $root . true true (dict "IsStarred" $event.IsStarred "RepoAt" .RepoAt "Stats" (dict "StarCount" $event.StarCount))) }} 75 + {{ template "user/fragments/repoCard" (list $root . true true (dict "IsStarred" $event.IsStarred "SubjectAt" .RepoAt "StarCount" $event.StarCount)) }} 76 76 {{ end }} 77 77 {{ end }} 78 78 {{ end }}
+4 -2
appview/pages/templates/user/followers.html
··· 1 - {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท followers {{ end }} 1 + {{ define "title" }}{{ resolve .Card.UserDid }} ยท followers {{ end }} 2 2 3 3 {{ define "profileContent" }} 4 4 <div id="all-followers" class="md:col-span-8 order-2 md:order-2"> ··· 19 19 "FollowersCount" .FollowersCount 20 20 "FollowingCount" .FollowingCount) }} 21 21 {{ else }} 22 - <p class="px-6 dark:text-white">This user does not have any followers yet.</p> 22 + <div class="text-base text-gray-500 flex items-center justify-center italic p-12 border border-gray-200 dark:border-gray-700 rounded"> 23 + <span>This user does not have any followers yet.</span> 24 + </div> 23 25 {{ end }} 24 26 </div> 25 27 {{ end }}
+4 -2
appview/pages/templates/user/following.html
··· 1 - {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท following {{ end }} 1 + {{ define "title" }}{{ resolve .Card.UserDid }} ยท following {{ end }} 2 2 3 3 {{ define "profileContent" }} 4 4 <div id="all-following" class="md:col-span-8 order-2 md:order-2"> ··· 19 19 "FollowersCount" .FollowersCount 20 20 "FollowingCount" .FollowingCount) }} 21 21 {{ else }} 22 - <p class="px-6 dark:text-white">This user does not follow anyone yet.</p> 22 + <div class="text-base text-gray-500 flex items-center justify-center italic p-12 border border-gray-200 dark:border-gray-700 rounded"> 23 + <span>This user does not follow anyone yet.</span> 24 + </div> 23 25 {{ end }} 24 26 </div> 25 27 {{ end }}
+17
appview/pages/templates/user/fragments/editBio.html
··· 20 20 </div> 21 21 22 22 <div class="flex flex-col gap-1"> 23 + <label class="m-0 p-0" for="pronouns">pronouns</label> 24 + <div class="flex items-center gap-2 w-full"> 25 + {{ $pronouns := "" }} 26 + {{ if and .Profile .Profile.Pronouns }} 27 + {{ $pronouns = .Profile.Pronouns }} 28 + {{ end }} 29 + <input 30 + type="text" 31 + class="py-1 px-1 w-full" 32 + name="pronouns" 33 + placeholder="they/them" 34 + value="{{ $pronouns }}" 35 + > 36 + </div> 37 + </div> 38 + 39 + <div class="flex flex-col gap-1"> 23 40 <label class="m-0 p-0" for="location">location</label> 24 41 <div class="flex items-center gap-2 w-full"> 25 42 {{ $location := "" }}
+1 -1
appview/pages/templates/user/fragments/followCard.html
··· 3 3 <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 rounded-sm"> 4 4 <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4"> 5 5 <div class="flex-shrink-0 max-h-full w-24 h-24"> 6 - <img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" /> 6 + <img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" alt="{{ $userIdent }}" /> 7 7 </div> 8 8 9 9 <div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2 w-full">
+20 -7
appview/pages/templates/user/fragments/profileCard.html
··· 1 1 {{ define "user/fragments/profileCard" }} 2 - {{ $userIdent := didOrHandle .UserDid .UserHandle }} 2 + {{ $userIdent := resolve .UserDid }} 3 3 <div class="grid grid-cols-3 md:grid-cols-1 gap-1 items-center"> 4 4 <div id="avatar" class="col-span-1 flex justify-center items-center"> 5 5 <div class="w-3/4 aspect-square relative"> ··· 12 12 class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap"> 13 13 {{ $userIdent }} 14 14 </p> 15 - <a href="/{{ $userIdent }}/feed.atom">{{ i "rss" "size-4" }}</a> 15 + {{ with .Profile }} 16 + {{ if .Pronouns }} 17 + <p class="text-gray-500 dark:text-gray-400">{{ .Pronouns }}</p> 18 + {{ end }} 19 + {{ end }} 16 20 </div> 17 21 18 22 <div class="md:hidden"> ··· 67 71 {{ end }} 68 72 </div> 69 73 {{ end }} 70 - {{ if ne .FollowStatus.String "IsSelf" }} 71 - {{ template "user/fragments/follow" . }} 72 - {{ else }} 74 + 75 + <div class="flex mt-2 items-center gap-2"> 76 + {{ if ne .FollowStatus.String "IsSelf" }} 77 + {{ template "user/fragments/follow" . }} 78 + {{ else }} 73 79 <button id="editBtn" 74 - class="btn mt-2 w-full flex items-center gap-2 group" 80 + class="btn w-full flex items-center gap-2 group" 75 81 hx-target="#profile-bio" 76 82 hx-get="/profile/edit-bio" 77 83 hx-swap="innerHTML"> ··· 79 85 edit 80 86 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 81 87 </button> 82 - {{ end }} 88 + {{ end }} 89 + 90 + <a class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group" 91 + href="/{{ $userIdent }}/feed.atom"> 92 + {{ i "rss" "size-4" }} 93 + </a> 94 + </div> 95 + 83 96 </div> 84 97 <div id="update-profile" class="text-red-400 dark:text-red-500"></div> 85 98 </div>
+2 -1
appview/pages/templates/user/fragments/repoCard.html
··· 1 1 {{ define "user/fragments/repoCard" }} 2 + {{/* root, repo, fullName [,starButton [,starData]] */}} 2 3 {{ $root := index . 0 }} 3 4 {{ $repo := index . 1 }} 4 5 {{ $fullName := index . 2 }} ··· 29 30 </div> 30 31 {{ if and $starButton $root.LoggedInUser }} 31 32 <div class="shrink-0"> 32 - {{ template "repo/fragments/repoStar" $starData }} 33 + {{ template "fragments/starBtn" $starData }} 33 34 </div> 34 35 {{ end }} 35 36 </div>
+23 -2
appview/pages/templates/user/login.html
··· 13 13 <title>login &middot; tangled</title> 14 14 </head> 15 15 <body class="flex items-center justify-center min-h-screen"> 16 - <main class="max-w-md px-6 -mt-4"> 16 + <main class="max-w-md px-7 mt-4"> 17 17 <h1 class="flex place-content-center text-3xl font-semibold italic dark:text-white" > 18 18 {{ template "fragments/logotype" }} 19 19 </h1> ··· 21 21 tightly-knit social coding. 22 22 </h2> 23 23 <form 24 - class="mt-4 max-w-sm mx-auto" 24 + class="mt-4" 25 25 hx-post="/login" 26 26 hx-swap="none" 27 27 hx-disabled-elt="#login-button" ··· 29 29 <div class="flex flex-col"> 30 30 <label for="handle">handle</label> 31 31 <input 32 + autocapitalize="none" 33 + autocorrect="off" 34 + autocomplete="username" 32 35 type="text" 33 36 id="handle" 34 37 name="handle" ··· 53 56 <span>login</span> 54 57 </button> 55 58 </form> 59 + {{ if .ErrorCode }} 60 + <div class="flex gap-2 my-2 bg-red-50 dark:bg-red-900 border border-red-500 rounded drop-shadow-sm px-3 py-2 text-red-500 dark:text-red-300"> 61 + <span class="py-1">{{ i "circle-alert" "w-4 h-4" }}</span> 62 + <div> 63 + <h5 class="font-medium">Login error</h5> 64 + <p class="text-sm"> 65 + {{ if eq .ErrorCode "access_denied" }} 66 + You have not authorized the app. 67 + {{ else if eq .ErrorCode "session" }} 68 + Server failed to create user session. 69 + {{ else }} 70 + Internal Server error. 71 + {{ end }} 72 + Please try again. 73 + </p> 74 + </div> 75 + </div> 76 + {{ end }} 56 77 <p class="text-sm text-gray-500"> 57 78 Don't have an account? <a href="/signup" class="underline">Create an account</a> on Tangled now! 58 79 </p>
+22 -4
appview/pages/templates/user/overview.html
··· 1 - {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }}{{ end }} 1 + {{ define "title" }}{{ resolve .Card.UserDid }}{{ end }} 2 2 3 3 {{ define "profileContent" }} 4 4 <div id="all-repos" class="md:col-span-4 order-2 md:order-2"> ··· 16 16 <p class="text-sm font-bold px-2 pb-4 dark:text-white">ACTIVITY</p> 17 17 <div class="flex flex-col gap-4 relative"> 18 18 {{ if .ProfileTimeline.IsEmpty }} 19 - <p class="dark:text-white">This user does not have any activity yet.</p> 19 + <div class="text-base text-gray-500 flex items-center justify-center italic p-12 border border-gray-200 dark:border-gray-700 rounded"> 20 + <span class="flex items-center gap-2"> 21 + This user does not have any activity yet. 22 + </span> 23 + </div> 20 24 {{ end }} 21 25 22 26 {{ with .ProfileTimeline }} ··· 33 37 </p> 34 38 35 39 <div class="flex flex-col gap-1"> 40 + {{ block "commits" .Commits }} {{ end }} 36 41 {{ block "repoEvents" .RepoEvents }} {{ end }} 37 42 {{ block "issueEvents" .IssueEvents }} {{ end }} 38 43 {{ block "pullEvents" .PullEvents }} {{ end }} ··· 43 48 {{ end }} 44 49 {{ end }} 45 50 </div> 51 + {{ end }} 52 + 53 + {{ define "commits" }} 54 + {{ if . }} 55 + <div class="flex flex-wrap items-center gap-1"> 56 + {{ i "git-commit-horizontal" "size-5" }} 57 + created {{ . }} commits 58 + </div> 59 + {{ end }} 46 60 {{ end }} 47 61 48 62 {{ define "repoEvents" }} ··· 224 238 {{ define "ownRepos" }} 225 239 <div> 226 240 <div class="text-sm font-bold px-2 pb-4 dark:text-white flex items-center gap-2"> 227 - <a href="/@{{ or $.Card.UserHandle $.Card.UserDid }}?tab=repos" 241 + <a href="/{{ resolve $.Card.UserDid }}?tab=repos" 228 242 class="flex text-black dark:text-white items-center gap-2 no-underline hover:no-underline group"> 229 243 <span>PINNED REPOS</span> 230 244 </a> ··· 244 258 {{ template "user/fragments/repoCard" (list $ . false) }} 245 259 </div> 246 260 {{ else }} 247 - <p class="dark:text-white">This user does not have any pinned repos.</p> 261 + <div class="text-base text-gray-500 flex items-center justify-center italic p-12 border border-gray-200 dark:border-gray-700 rounded"> 262 + <span class="flex items-center gap-2"> 263 + This user does not have any pinned repos. 264 + </span> 265 + </div> 248 266 {{ end }} 249 267 </div> 250 268 </div>
+4 -2
appview/pages/templates/user/repos.html
··· 1 - {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท repos {{ end }} 1 + {{ define "title" }}{{ resolve .Card.UserDid }} ยท repos {{ end }} 2 2 3 3 {{ define "profileContent" }} 4 4 <div id="all-repos" class="md:col-span-8 order-2 md:order-2"> ··· 13 13 {{ template "user/fragments/repoCard" (list $ . false) }} 14 14 </div> 15 15 {{ else }} 16 - <p class="px-6 dark:text-white">This user does not have any repos yet.</p> 16 + <div class="text-base text-gray-500 flex items-center justify-center italic p-12 border border-gray-200 dark:border-gray-700 rounded"> 17 + <span>This user does not have any repos yet.</span> 18 + </div> 17 19 {{ end }} 18 20 </div> 19 21 {{ end }}
+14
appview/pages/templates/user/settings/notifications.html
··· 144 144 <div class="flex items-center justify-between p-2"> 145 145 <div class="flex items-center gap-2"> 146 146 <div class="flex flex-col gap-1"> 147 + <span class="font-bold">Mentions</span> 148 + <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 149 + <span>When someone mentions you.</span> 150 + </div> 151 + </div> 152 + </div> 153 + <label class="flex items-center gap-2"> 154 + <input type="checkbox" name="user_mentioned" {{if .Preferences.UserMentioned}}checked{{end}}> 155 + </label> 156 + </div> 157 + 158 + <div class="flex items-center justify-between p-2"> 159 + <div class="flex items-center gap-2"> 160 + <div class="flex flex-col gap-1"> 147 161 <span class="font-bold">Email notifications</span> 148 162 <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 149 163 <span>Receive notifications via email in addition to in-app notifications.</span>
+9 -6
appview/pages/templates/user/signup.html
··· 43 43 page to complete your registration. 44 44 </span> 45 45 <div class="w-full mt-4 text-center"> 46 - <div class="cf-turnstile" data-sitekey="{{ .CloudflareSiteKey }}"></div> 46 + <div class="cf-turnstile" data-sitekey="{{ .CloudflareSiteKey }}" data-size="flexible"></div> 47 47 </div> 48 48 <button class="btn text-base w-full my-2 mt-6" type="submit" id="signup-button" tabindex="7" > 49 49 <span>join now</span> 50 50 </button> 51 + <p class="text-sm text-gray-500"> 52 + Already have an AT Protocol account? <a href="/login" class="underline">Login to Tangled</a>. 53 + </p> 54 + 55 + <p id="signup-msg" class="error w-full"></p> 56 + <p class="text-sm text-gray-500 pt-4"> 57 + By signing up, you agree to our <a href="/terms" class="underline">Terms of Service</a> and <a href="/privacy" class="underline">Privacy Policy</a>. 58 + </p> 51 59 </form> 52 - <p class="text-sm text-gray-500"> 53 - Already have an AT Protocol account? <a href="/login" class="underline">Login to Tangled</a>. 54 - </p> 55 - 56 - <p id="signup-msg" class="error w-full"></p> 57 60 </main> 58 61 </body> 59 62 </html>
+4 -2
appview/pages/templates/user/starred.html
··· 1 - {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท repos {{ end }} 1 + {{ define "title" }}{{ resolve .Card.UserDid }} ยท repos {{ end }} 2 2 3 3 {{ define "profileContent" }} 4 4 <div id="all-repos" class="md:col-span-8 order-2 md:order-2"> ··· 13 13 {{ template "user/fragments/repoCard" (list $ . true) }} 14 14 </div> 15 15 {{ else }} 16 - <p class="px-6 dark:text-white">This user does not have any starred repos yet.</p> 16 + <div class="text-base text-gray-500 flex items-center justify-center italic p-12 border border-gray-200 dark:border-gray-700 rounded"> 17 + <span>This user does not have any starred repos yet.</span> 18 + </div> 17 19 {{ end }} 18 20 </div> 19 21 {{ end }}
+5 -3
appview/pages/templates/user/strings.html
··· 1 - {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท strings {{ end }} 1 + {{ define "title" }}{{ resolve .Card.UserDid }} ยท strings {{ end }} 2 2 3 3 {{ define "profileContent" }} 4 4 <div id="all-strings" class="md:col-span-8 order-2 md:order-2"> ··· 13 13 {{ template "singleString" (list $ .) }} 14 14 </div> 15 15 {{ else }} 16 - <p class="px-6 dark:text-white">This user does not have any strings yet.</p> 16 + <div class="text-base text-gray-500 flex items-center justify-center italic p-12 border border-gray-200 dark:border-gray-700 rounded"> 17 + <span>This user does not have any strings yet.</span> 18 + </div> 17 19 {{ end }} 18 20 </div> 19 21 {{ end }} ··· 23 25 {{ $s := index . 1 }} 24 26 <div class="py-4 px-6 rounded bg-white dark:bg-gray-800"> 25 27 <div class="font-medium dark:text-white flex gap-2 items-center"> 26 - <a href="/strings/{{ or $root.Card.UserHandle $root.Card.UserDid }}/{{ $s.Rkey }}">{{ $s.Filename }}</a> 28 + <a href="/strings/{{ resolve $root.Card.UserDid }}/{{ $s.Rkey }}">{{ $s.Filename }}</a> 27 29 </div> 28 30 {{ with $s.Description }} 29 31 <div class="text-gray-600 dark:text-gray-300 text-sm">
+46
appview/pagination/page.go
··· 1 1 package pagination 2 2 3 + import "context" 4 + 3 5 type Page struct { 4 6 Offset int // where to start from 5 7 Limit int // number of items in a page ··· 10 12 Offset: 0, 11 13 Limit: 30, 12 14 } 15 + } 16 + 17 + type ctxKey struct{} 18 + 19 + func IntoContext(ctx context.Context, page Page) context.Context { 20 + return context.WithValue(ctx, ctxKey{}, page) 21 + } 22 + 23 + func FromContext(ctx context.Context) Page { 24 + if ctx == nil { 25 + return FirstPage() 26 + } 27 + v := ctx.Value(ctxKey{}) 28 + if v == nil { 29 + return FirstPage() 30 + } 31 + page, ok := v.(Page) 32 + if !ok { 33 + return FirstPage() 34 + } 35 + return page 13 36 } 14 37 15 38 func (p Page) Previous() Page { ··· 29 52 Limit: p.Limit, 30 53 } 31 54 } 55 + 56 + func IterateAll[T any]( 57 + fetch func(page Page) ([]T, error), 58 + handle func(items []T) error, 59 + ) error { 60 + page := FirstPage() 61 + for { 62 + items, err := fetch(page) 63 + if err != nil { 64 + return err 65 + } 66 + 67 + err = handle(items) 68 + if err != nil { 69 + return err 70 + } 71 + if len(items) < page.Limit { 72 + break 73 + } 74 + page = page.Next() 75 + } 76 + return nil 77 + }
+54 -38
appview/pipelines/pipelines.go
··· 16 16 "tangled.org/core/appview/reporesolver" 17 17 "tangled.org/core/eventconsumer" 18 18 "tangled.org/core/idresolver" 19 - "tangled.org/core/log" 19 + "tangled.org/core/orm" 20 20 "tangled.org/core/rbac" 21 21 spindlemodel "tangled.org/core/spindle/models" 22 22 ··· 36 36 logger *slog.Logger 37 37 } 38 38 39 + func (p *Pipelines) Router() http.Handler { 40 + r := chi.NewRouter() 41 + r.Get("/", p.Index) 42 + r.Get("/{pipeline}/workflow/{workflow}", p.Workflow) 43 + r.Get("/{pipeline}/workflow/{workflow}/logs", p.Logs) 44 + 45 + return r 46 + } 47 + 39 48 func New( 40 49 oauth *oauth.OAuth, 41 50 repoResolver *reporesolver.RepoResolver, ··· 45 54 db *db.DB, 46 55 config *config.Config, 47 56 enforcer *rbac.Enforcer, 57 + logger *slog.Logger, 48 58 ) *Pipelines { 49 - logger := log.New("pipelines") 50 - 51 59 return &Pipelines{ 52 60 oauth: oauth, 53 61 repoResolver: repoResolver, ··· 71 79 return 72 80 } 73 81 74 - repoInfo := f.RepoInfo(user) 75 - 76 82 ps, err := db.GetPipelineStatuses( 77 83 p.db, 78 - db.FilterEq("repo_owner", repoInfo.OwnerDid), 79 - db.FilterEq("repo_name", repoInfo.Name), 80 - db.FilterEq("knot", repoInfo.Knot), 84 + 30, 85 + orm.FilterEq("repo_owner", f.Did), 86 + orm.FilterEq("repo_name", f.Name), 87 + orm.FilterEq("knot", f.Knot), 81 88 ) 82 89 if err != nil { 83 90 l.Error("failed to query db", "err", err) ··· 86 93 87 94 p.pages.Pipelines(w, pages.PipelinesParams{ 88 95 LoggedInUser: user, 89 - RepoInfo: repoInfo, 96 + RepoInfo: p.repoResolver.GetRepoInfo(r, user), 90 97 Pipelines: ps, 91 98 }) 92 99 } ··· 101 108 return 102 109 } 103 110 104 - repoInfo := f.RepoInfo(user) 105 - 106 111 pipelineId := chi.URLParam(r, "pipeline") 107 112 if pipelineId == "" { 108 113 l.Error("empty pipeline ID") ··· 117 122 118 123 ps, err := db.GetPipelineStatuses( 119 124 p.db, 120 - db.FilterEq("repo_owner", repoInfo.OwnerDid), 121 - db.FilterEq("repo_name", repoInfo.Name), 122 - db.FilterEq("knot", repoInfo.Knot), 123 - db.FilterEq("id", pipelineId), 125 + 1, 126 + orm.FilterEq("repo_owner", f.Did), 127 + orm.FilterEq("repo_name", f.Name), 128 + orm.FilterEq("knot", f.Knot), 129 + orm.FilterEq("id", pipelineId), 124 130 ) 125 131 if err != nil { 126 132 l.Error("failed to query db", "err", err) ··· 136 142 137 143 p.pages.Workflow(w, pages.WorkflowParams{ 138 144 LoggedInUser: user, 139 - RepoInfo: repoInfo, 145 + RepoInfo: p.repoResolver.GetRepoInfo(r, user), 140 146 Pipeline: singlePipeline, 141 147 Workflow: workflow, 142 148 }) ··· 167 173 ctx, cancel := context.WithCancel(r.Context()) 168 174 defer cancel() 169 175 170 - user := p.oauth.GetUser(r) 171 176 f, err := p.repoResolver.Resolve(r) 172 177 if err != nil { 173 178 l.Error("failed to get repo and knot", "err", err) 174 179 http.Error(w, "bad repo/knot", http.StatusBadRequest) 175 180 return 176 181 } 177 - 178 - repoInfo := f.RepoInfo(user) 179 182 180 183 pipelineId := chi.URLParam(r, "pipeline") 181 184 workflow := chi.URLParam(r, "workflow") ··· 186 189 187 190 ps, err := db.GetPipelineStatuses( 188 191 p.db, 189 - db.FilterEq("repo_owner", repoInfo.OwnerDid), 190 - db.FilterEq("repo_name", repoInfo.Name), 191 - db.FilterEq("knot", repoInfo.Knot), 192 - db.FilterEq("id", pipelineId), 192 + 1, 193 + orm.FilterEq("repo_owner", f.Did), 194 + orm.FilterEq("repo_name", f.Name), 195 + orm.FilterEq("knot", f.Knot), 196 + orm.FilterEq("id", pipelineId), 193 197 ) 194 198 if err != nil || len(ps) != 1 { 195 199 l.Error("pipeline query failed", "err", err, "count", len(ps)) ··· 198 202 } 199 203 200 204 singlePipeline := ps[0] 201 - spindle := repoInfo.Spindle 202 - knot := repoInfo.Knot 205 + spindle := f.Spindle 206 + knot := f.Knot 203 207 rkey := singlePipeline.Rkey 204 208 205 209 if spindle == "" || knot == "" || rkey == "" { ··· 229 233 // start a goroutine to read from spindle 230 234 go readLogs(spindleConn, evChan) 231 235 232 - stepIdx := 0 236 + stepStartTimes := make(map[int]time.Time) 233 237 var fragment bytes.Buffer 234 238 for { 235 239 select { ··· 261 265 262 266 switch logLine.Kind { 263 267 case spindlemodel.LogKindControl: 264 - // control messages create a new step block 265 - stepIdx++ 266 - collapsed := false 267 - if logLine.StepKind == spindlemodel.StepKindSystem { 268 - collapsed = true 268 + switch logLine.StepStatus { 269 + case spindlemodel.StepStatusStart: 270 + stepStartTimes[logLine.StepId] = logLine.Time 271 + collapsed := false 272 + if logLine.StepKind == spindlemodel.StepKindSystem { 273 + collapsed = true 274 + } 275 + err = p.pages.LogBlock(&fragment, pages.LogBlockParams{ 276 + Id: logLine.StepId, 277 + Name: logLine.Content, 278 + Command: logLine.StepCommand, 279 + Collapsed: collapsed, 280 + StartTime: logLine.Time, 281 + }) 282 + case spindlemodel.StepStatusEnd: 283 + startTime := stepStartTimes[logLine.StepId] 284 + endTime := logLine.Time 285 + err = p.pages.LogBlockEnd(&fragment, pages.LogBlockEndParams{ 286 + Id: logLine.StepId, 287 + StartTime: startTime, 288 + EndTime: endTime, 289 + }) 269 290 } 270 - err = p.pages.LogBlock(&fragment, pages.LogBlockParams{ 271 - Id: stepIdx, 272 - Name: logLine.Content, 273 - Command: logLine.StepCommand, 274 - Collapsed: collapsed, 275 - }) 291 + 276 292 case spindlemodel.LogKindData: 277 293 // data messages simply insert new log lines into current step 278 294 err = p.pages.LogLine(&fragment, pages.LogLineParams{ 279 - Id: stepIdx, 295 + Id: logLine.StepId, 280 296 Content: logLine.Content, 281 297 }) 282 298 }
-17
appview/pipelines/router.go
··· 1 - package pipelines 2 - 3 - import ( 4 - "net/http" 5 - 6 - "github.com/go-chi/chi/v5" 7 - "tangled.org/core/appview/middleware" 8 - ) 9 - 10 - func (p *Pipelines) Router(mw *middleware.Middleware) http.Handler { 11 - r := chi.NewRouter() 12 - r.Get("/", p.Index) 13 - r.Get("/{pipeline}/workflow/{workflow}", p.Workflow) 14 - r.Get("/{pipeline}/workflow/{workflow}/logs", p.Logs) 15 - 16 - return r 17 - }
+322
appview/pulls/opengraph.go
··· 1 + package pulls 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "fmt" 7 + "image" 8 + "image/color" 9 + "image/png" 10 + "log" 11 + "net/http" 12 + 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 + ) 20 + 21 + func (s *Pulls) drawPullSummaryCard(pull *models.Pull, repo *models.Repo, commentCount int, diffStats types.DiffStat, filesChanged int) (*ogcard.Card, error) { 22 + width, height := ogcard.DefaultSize() 23 + mainCard, err := ogcard.NewCard(width, height) 24 + if err != nil { 25 + return nil, err 26 + } 27 + 28 + // Split: content area (75%) and status/stats area (25%) 29 + contentCard, statsArea := mainCard.Split(false, 75) 30 + 31 + // Add padding to content 32 + contentCard.SetMargin(50) 33 + 34 + // Split content horizontally: main content (80%) and avatar area (20%) 35 + mainContent, avatarArea := contentCard.Split(true, 80) 36 + 37 + // Add margin to main content 38 + mainContent.SetMargin(10) 39 + 40 + // Use full main content area for repo name and title 41 + bounds := mainContent.Img.Bounds() 42 + startX := bounds.Min.X + mainContent.Margin 43 + startY := bounds.Min.Y + mainContent.Margin 44 + 45 + // Draw full repository name at top (owner/repo format) 46 + var repoOwner string 47 + owner, err := s.idResolver.ResolveIdent(context.Background(), repo.Did) 48 + if err != nil { 49 + repoOwner = repo.Did 50 + } else { 51 + repoOwner = "@" + owner.Handle.String() 52 + } 53 + 54 + fullRepoName := repoOwner + " / " + repo.Name 55 + if len(fullRepoName) > 60 { 56 + fullRepoName = fullRepoName[:60] + "โ€ฆ" 57 + } 58 + 59 + grayColor := color.RGBA{88, 96, 105, 255} 60 + err = mainContent.DrawTextAt(fullRepoName, startX, startY, grayColor, 36, ogcard.Top, ogcard.Left) 61 + if err != nil { 62 + return nil, err 63 + } 64 + 65 + // Draw pull request title below repo name with wrapping 66 + titleY := startY + 60 67 + titleX := startX 68 + 69 + // Truncate title if too long 70 + pullTitle := pull.Title 71 + maxTitleLength := 80 72 + if len(pullTitle) > maxTitleLength { 73 + pullTitle = pullTitle[:maxTitleLength] + "โ€ฆ" 74 + } 75 + 76 + // Create a temporary card for the title area to enable wrapping 77 + titleBounds := mainContent.Img.Bounds() 78 + titleWidth := titleBounds.Dx() - (startX - titleBounds.Min.X) - 20 // Leave some margin 79 + titleHeight := titleBounds.Dy() - (titleY - titleBounds.Min.Y) - 100 // Leave space for pull ID 80 + 81 + titleRect := image.Rect(titleX, titleY, titleX+titleWidth, titleY+titleHeight) 82 + titleCard := &ogcard.Card{ 83 + Img: mainContent.Img.SubImage(titleRect).(*image.RGBA), 84 + Font: mainContent.Font, 85 + Margin: 0, 86 + } 87 + 88 + // Draw wrapped title 89 + lines, err := titleCard.DrawText(pullTitle, color.Black, 54, ogcard.Top, ogcard.Left) 90 + if err != nil { 91 + return nil, err 92 + } 93 + 94 + // Calculate where title ends (number of lines * line height) 95 + lineHeight := 60 // Approximate line height for 54pt font 96 + titleEndY := titleY + (len(lines) * lineHeight) + 10 97 + 98 + // Draw pull ID in gray below the title 99 + pullIdText := fmt.Sprintf("#%d", pull.PullId) 100 + err = mainContent.DrawTextAt(pullIdText, startX, titleEndY, grayColor, 54, ogcard.Top, ogcard.Left) 101 + if err != nil { 102 + return nil, err 103 + } 104 + 105 + // Get pull author handle (needed for avatar and metadata) 106 + var authorHandle string 107 + author, err := s.idResolver.ResolveIdent(context.Background(), pull.OwnerDid) 108 + if err != nil { 109 + authorHandle = pull.OwnerDid 110 + } else { 111 + authorHandle = "@" + author.Handle.String() 112 + } 113 + 114 + // Draw avatar circle on the right side 115 + avatarBounds := avatarArea.Img.Bounds() 116 + avatarSize := min(avatarBounds.Dx(), avatarBounds.Dy()) - 20 // Leave some margin 117 + if avatarSize > 220 { 118 + avatarSize = 220 119 + } 120 + avatarX := avatarBounds.Min.X + (avatarBounds.Dx() / 2) - (avatarSize / 2) 121 + avatarY := avatarBounds.Min.Y + 20 122 + 123 + // Get avatar URL for pull author 124 + avatarURL := s.pages.AvatarUrl(authorHandle, "256") 125 + err = avatarArea.DrawCircularExternalImage(avatarURL, avatarX, avatarY, avatarSize) 126 + if err != nil { 127 + log.Printf("failed to draw avatar (non-fatal): %v", err) 128 + } 129 + 130 + // Split stats area: left side for status/stats (80%), right side for dolly (20%) 131 + statusStatsArea, dollyArea := statsArea.Split(true, 80) 132 + 133 + // Draw status and stats 134 + statsBounds := statusStatsArea.Img.Bounds() 135 + statsX := statsBounds.Min.X + 60 // left padding 136 + statsY := statsBounds.Min.Y 137 + 138 + iconColor := color.RGBA{88, 96, 105, 255} 139 + iconSize := 36 140 + textSize := 36.0 141 + labelSize := 28.0 142 + iconBaselineOffset := int(textSize) / 2 143 + 144 + // Draw status (open/merged/closed) with colored icon and text 145 + var statusIcon string 146 + var statusText string 147 + var statusColor color.RGBA 148 + 149 + if pull.State.IsOpen() { 150 + statusIcon = "git-pull-request" 151 + statusText = "open" 152 + statusColor = color.RGBA{34, 139, 34, 255} // green 153 + } else if pull.State.IsMerged() { 154 + statusIcon = "git-merge" 155 + statusText = "merged" 156 + statusColor = color.RGBA{138, 43, 226, 255} // purple 157 + } else { 158 + statusIcon = "git-pull-request-closed" 159 + statusText = "closed" 160 + statusColor = color.RGBA{128, 128, 128, 255} // gray 161 + } 162 + 163 + statusIconSize := 36 164 + 165 + // Draw icon with status color 166 + err = statusStatsArea.DrawLucideIcon(statusIcon, statsX, statsY+iconBaselineOffset-statusIconSize/2+5, statusIconSize, statusColor) 167 + if err != nil { 168 + log.Printf("failed to draw status icon: %v", err) 169 + } 170 + 171 + // Draw text with status color 172 + textX := statsX + statusIconSize + 12 173 + statusTextSize := 32.0 174 + err = statusStatsArea.DrawTextAt(statusText, textX, statsY+iconBaselineOffset, statusColor, statusTextSize, ogcard.Middle, ogcard.Left) 175 + if err != nil { 176 + log.Printf("failed to draw status text: %v", err) 177 + } 178 + 179 + statusTextWidth := len(statusText) * 20 180 + currentX := statsX + statusIconSize + 12 + statusTextWidth + 40 181 + 182 + // Draw comment count 183 + err = statusStatsArea.DrawLucideIcon("message-square", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) 184 + if err != nil { 185 + log.Printf("failed to draw comment icon: %v", err) 186 + } 187 + 188 + currentX += iconSize + 15 189 + commentText := fmt.Sprintf("%d comments", commentCount) 190 + if commentCount == 1 { 191 + commentText = "1 comment" 192 + } 193 + err = statusStatsArea.DrawTextAt(commentText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left) 194 + if err != nil { 195 + log.Printf("failed to draw comment text: %v", err) 196 + } 197 + 198 + commentTextWidth := len(commentText) * 20 199 + currentX += commentTextWidth + 40 200 + 201 + // Draw files changed 202 + err = statusStatsArea.DrawLucideIcon("static/icons/file-diff", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) 203 + if err != nil { 204 + log.Printf("failed to draw file diff icon: %v", err) 205 + } 206 + 207 + currentX += iconSize + 15 208 + filesText := fmt.Sprintf("%d files", filesChanged) 209 + if filesChanged == 1 { 210 + filesText = "1 file" 211 + } 212 + err = statusStatsArea.DrawTextAt(filesText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left) 213 + if err != nil { 214 + log.Printf("failed to draw files text: %v", err) 215 + } 216 + 217 + filesTextWidth := len(filesText) * 20 218 + currentX += filesTextWidth 219 + 220 + // Draw additions (green +) 221 + greenColor := color.RGBA{34, 139, 34, 255} 222 + additionsText := fmt.Sprintf("+%d", diffStats.Insertions) 223 + err = statusStatsArea.DrawTextAt(additionsText, currentX, statsY+iconBaselineOffset, greenColor, textSize, ogcard.Middle, ogcard.Left) 224 + if err != nil { 225 + log.Printf("failed to draw additions text: %v", err) 226 + } 227 + 228 + additionsTextWidth := len(additionsText) * 20 229 + currentX += additionsTextWidth + 30 230 + 231 + // Draw deletions (red -) right next to additions 232 + redColor := color.RGBA{220, 20, 60, 255} 233 + deletionsText := fmt.Sprintf("-%d", diffStats.Deletions) 234 + err = statusStatsArea.DrawTextAt(deletionsText, currentX, statsY+iconBaselineOffset, redColor, textSize, ogcard.Middle, ogcard.Left) 235 + if err != nil { 236 + log.Printf("failed to draw deletions text: %v", err) 237 + } 238 + 239 + // Draw dolly logo on the right side 240 + dollyBounds := dollyArea.Img.Bounds() 241 + dollySize := 90 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.DrawDollySilhouette(dollyX, dollyY, dollySize, dollyColor) 246 + if err != nil { 247 + log.Printf("dolly silhouette not available (this is ok): %v", err) 248 + } 249 + 250 + // Draw "opened by @author" and date at the bottom with more spacing 251 + labelY := statsY + iconSize + 30 252 + 253 + // Format the opened date 254 + openedDate := pull.Created.Format("Jan 2, 2006") 255 + metaText := fmt.Sprintf("opened by %s ยท %s", authorHandle, openedDate) 256 + 257 + err = statusStatsArea.DrawTextAt(metaText, statsX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Left) 258 + if err != nil { 259 + log.Printf("failed to draw metadata: %v", err) 260 + } 261 + 262 + return mainCard, nil 263 + } 264 + 265 + func (s *Pulls) PullOpenGraphSummary(w http.ResponseWriter, r *http.Request) { 266 + f, err := s.repoResolver.Resolve(r) 267 + if err != nil { 268 + log.Println("failed to get repo and knot", err) 269 + return 270 + } 271 + 272 + pull, ok := r.Context().Value("pull").(*models.Pull) 273 + if !ok { 274 + log.Println("pull not found in context") 275 + http.Error(w, "pull not found", http.StatusNotFound) 276 + return 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 + } 284 + commentCount := len(comments) 285 + 286 + // Calculate diff stats from latest submission using patchutil 287 + var diffStats types.DiffStat 288 + filesChanged := 0 289 + if len(pull.Submissions) > 0 { 290 + latestSubmission := pull.Submissions[len(pull.Submissions)-1] 291 + niceDiff := patchutil.AsNiceDiff(latestSubmission.Patch, pull.TargetBranch) 292 + diffStats.Insertions = int64(niceDiff.Stat.Insertions) 293 + diffStats.Deletions = int64(niceDiff.Stat.Deletions) 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) 301 + return 302 + } 303 + 304 + var imageBuffer bytes.Buffer 305 + err = png.Encode(&imageBuffer, card.Img) 306 + if err != nil { 307 + log.Println("failed to encode pull summary card", err) 308 + http.Error(w, "failed to encode pull summary card", http.StatusInternalServerError) 309 + return 310 + } 311 + 312 + imageBytes := imageBuffer.Bytes() 313 + 314 + w.Header().Set("Content-Type", "image/png") 315 + w.Header().Set("Cache-Control", "public, max-age=3600") // 1 hour 316 + w.WriteHeader(http.StatusOK) 317 + _, err = w.Write(imageBytes) 318 + if err != nil { 319 + log.Println("failed to write pull summary card", err) 320 + return 321 + } 322 + }
+354 -301
appview/pulls/pulls.go
··· 1 1 package pulls 2 2 3 3 import ( 4 + "context" 4 5 "database/sql" 5 6 "encoding/json" 6 7 "errors" 7 8 "fmt" 8 9 "log" 10 + "log/slog" 9 11 "net/http" 12 + "slices" 10 13 "sort" 11 14 "strconv" 12 15 "strings" ··· 15 18 "tangled.org/core/api/tangled" 16 19 "tangled.org/core/appview/config" 17 20 "tangled.org/core/appview/db" 21 + pulls_indexer "tangled.org/core/appview/indexer/pulls" 22 + "tangled.org/core/appview/mentions" 18 23 "tangled.org/core/appview/models" 19 24 "tangled.org/core/appview/notify" 20 25 "tangled.org/core/appview/oauth" 21 26 "tangled.org/core/appview/pages" 22 27 "tangled.org/core/appview/pages/markup" 28 + "tangled.org/core/appview/pages/repoinfo" 23 29 "tangled.org/core/appview/reporesolver" 30 + "tangled.org/core/appview/validator" 24 31 "tangled.org/core/appview/xrpcclient" 25 32 "tangled.org/core/idresolver" 33 + "tangled.org/core/orm" 26 34 "tangled.org/core/patchutil" 35 + "tangled.org/core/rbac" 27 36 "tangled.org/core/tid" 28 37 "tangled.org/core/types" 29 38 30 - "github.com/bluekeyes/go-gitdiff/gitdiff" 31 39 comatproto "github.com/bluesky-social/indigo/api/atproto" 40 + "github.com/bluesky-social/indigo/atproto/syntax" 32 41 lexutil "github.com/bluesky-social/indigo/lex/util" 33 42 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 34 43 "github.com/go-chi/chi/v5" ··· 36 45 ) 37 46 38 47 type Pulls struct { 39 - oauth *oauth.OAuth 40 - repoResolver *reporesolver.RepoResolver 41 - pages *pages.Pages 42 - idResolver *idresolver.Resolver 43 - db *db.DB 44 - config *config.Config 45 - notifier notify.Notifier 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 46 60 } 47 61 48 62 func New( ··· 50 64 repoResolver *reporesolver.RepoResolver, 51 65 pages *pages.Pages, 52 66 resolver *idresolver.Resolver, 67 + mentionsResolver *mentions.Resolver, 53 68 db *db.DB, 54 69 config *config.Config, 55 70 notifier notify.Notifier, 71 + enforcer *rbac.Enforcer, 72 + validator *validator.Validator, 73 + indexer *pulls_indexer.Indexer, 74 + logger *slog.Logger, 56 75 ) *Pulls { 57 76 return &Pulls{ 58 - oauth: oauth, 59 - repoResolver: repoResolver, 60 - pages: pages, 61 - idResolver: resolver, 62 - db: db, 63 - config: config, 64 - notifier: notifier, 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, 65 89 } 66 90 } 67 91 ··· 98 122 } 99 123 100 124 mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 125 + branchDeleteStatus := s.branchDeleteStatus(r, f, pull) 101 126 resubmitResult := pages.Unknown 102 127 if user.Did == pull.OwnerDid { 103 128 resubmitResult = s.resubmitCheck(r, f, pull, stack) 104 129 } 105 130 106 131 s.pages.PullActionsFragment(w, pages.PullActionsParams{ 107 - LoggedInUser: user, 108 - RepoInfo: f.RepoInfo(user), 109 - Pull: pull, 110 - RoundNumber: roundNumber, 111 - MergeCheck: mergeCheckResponse, 112 - ResubmitCheck: resubmitResult, 113 - Stack: stack, 132 + LoggedInUser: user, 133 + RepoInfo: s.repoResolver.GetRepoInfo(r, user), 134 + Pull: pull, 135 + RoundNumber: roundNumber, 136 + MergeCheck: mergeCheckResponse, 137 + ResubmitCheck: resubmitResult, 138 + BranchDeleteStatus: branchDeleteStatus, 139 + Stack: stack, 114 140 }) 115 141 return 116 142 } ··· 131 157 return 132 158 } 133 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 + 134 167 // can be nil if this pull is not stacked 135 168 stack, _ := r.Context().Value("stack").(models.Stack) 136 169 abandonedPulls, _ := r.Context().Value("abandonedPulls").([]*models.Pull) 137 170 138 - totalIdents := 1 139 - for _, submission := range pull.Submissions { 140 - totalIdents += len(submission.Comments) 141 - } 142 - 143 - identsToResolve := make([]string, totalIdents) 144 - 145 - // populate idents 146 - identsToResolve[0] = pull.OwnerDid 147 - idx := 1 148 - for _, submission := range pull.Submissions { 149 - for _, comment := range submission.Comments { 150 - identsToResolve[idx] = comment.OwnerDid 151 - idx += 1 152 - } 153 - } 154 - 155 171 mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 172 + branchDeleteStatus := s.branchDeleteStatus(r, f, pull) 156 173 resubmitResult := pages.Unknown 157 174 if user != nil && user.Did == pull.OwnerDid { 158 175 resubmitResult = s.resubmitCheck(r, f, pull, stack) 159 176 } 160 - 161 - repoInfo := f.RepoInfo(user) 162 177 163 178 m := make(map[string]models.Pipeline) 164 179 ··· 175 190 176 191 ps, err := db.GetPipelineStatuses( 177 192 s.db, 178 - db.FilterEq("repo_owner", repoInfo.OwnerDid), 179 - db.FilterEq("repo_name", repoInfo.Name), 180 - db.FilterEq("knot", repoInfo.Knot), 181 - db.FilterIn("sha", shas), 193 + len(shas), 194 + orm.FilterEq("repo_owner", f.Did), 195 + orm.FilterEq("repo_name", f.Name), 196 + orm.FilterEq("knot", f.Knot), 197 + orm.FilterIn("sha", shas), 182 198 ) 183 199 if err != nil { 184 200 log.Printf("failed to fetch pipeline statuses: %s", err) ··· 189 205 m[p.Sha] = p 190 206 } 191 207 192 - reactionMap, err := db.GetReactionMap(s.db, 20, pull.PullAt()) 208 + reactionMap, err := db.GetReactionMap(s.db, 20, pull.AtUri()) 193 209 if err != nil { 194 210 log.Println("failed to get pull reactions") 195 211 s.pages.Notice(w, "pulls", "Failed to load pull. Try again later.") ··· 197 213 198 214 userReactions := map[models.ReactionKind]bool{} 199 215 if user != nil { 200 - userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.PullAt()) 216 + userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.AtUri()) 201 217 } 202 218 203 219 labelDefs, err := db.GetLabelDefinitions( 204 220 s.db, 205 - db.FilterIn("at_uri", f.Repo.Labels), 206 - db.FilterContains("scope", tangled.RepoPullNSID), 221 + orm.FilterIn("at_uri", f.Labels), 222 + orm.FilterContains("scope", tangled.RepoPullNSID), 207 223 ) 208 224 if err != nil { 209 225 log.Println("failed to fetch labels", err) ··· 217 233 } 218 234 219 235 s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 220 - LoggedInUser: user, 221 - RepoInfo: repoInfo, 222 - Pull: pull, 223 - Stack: stack, 224 - AbandonedPulls: abandonedPulls, 225 - MergeCheck: mergeCheckResponse, 226 - ResubmitCheck: resubmitResult, 227 - Pipelines: m, 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, 245 + Pipelines: m, 228 246 229 247 OrderedReactionKinds: models.OrderedReactionKinds, 230 248 Reactions: reactionMap, ··· 234 252 }) 235 253 } 236 254 237 - func (s *Pulls) mergeCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *models.Pull, stack models.Stack) types.MergeCheckResponse { 255 + func (s *Pulls) mergeCheck(r *http.Request, f *models.Repo, pull *models.Pull, stack models.Stack) types.MergeCheckResponse { 238 256 if pull.State == models.PullMerged { 239 257 return types.MergeCheckResponse{} 240 258 } ··· 263 281 r.Context(), 264 282 &xrpcc, 265 283 &tangled.RepoMergeCheck_Input{ 266 - Did: f.OwnerDid(), 284 + Did: f.Did, 267 285 Name: f.Name, 268 286 Branch: pull.TargetBranch, 269 287 Patch: patch, ··· 301 319 return result 302 320 } 303 321 304 - func (s *Pulls) resubmitCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *models.Pull, stack models.Stack) pages.ResubmitResult { 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 + } 326 + 327 + user := s.oauth.GetUser(r) 328 + if user == nil { 329 + return nil 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 340 + } else { 341 + return nil 342 + } 343 + 344 + // deleted fork 345 + if repo == nil { 346 + return nil 347 + } 348 + 349 + // user can only delete branch if they are a collaborator in the repo that the branch belongs to 350 + perms := s.enforcer.GetPermissionsInRepo(user.Did, repo.Knot, repo.DidSlashRepo()) 351 + if !slices.Contains(perms, "repo:push") { 352 + return nil 353 + } 354 + 355 + scheme := "http" 356 + if !s.config.Core.Dev { 357 + scheme = "https" 358 + } 359 + host := fmt.Sprintf("%s://%s", scheme, repo.Knot) 360 + xrpcc := &indigoxrpc.Client{ 361 + Host: host, 362 + } 363 + 364 + resp, err := tangled.RepoBranch(r.Context(), xrpcc, branch, fmt.Sprintf("%s/%s", repo.Did, repo.Name)) 365 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 366 + return nil 367 + } 368 + 369 + return &models.BranchDeleteStatus{ 370 + Repo: repo, 371 + Branch: resp.Name, 372 + } 373 + } 374 + 375 + func (s *Pulls) resubmitCheck(r *http.Request, repo *models.Repo, pull *models.Pull, stack models.Stack) pages.ResubmitResult { 305 376 if pull.State == models.PullMerged || pull.State == models.PullDeleted || pull.PullSource == nil { 306 377 return pages.Unknown 307 378 } ··· 321 392 repoName = sourceRepo.Name 322 393 } else { 323 394 // pulls within the same repo 324 - knot = f.Knot 325 - ownerDid = f.OwnerDid() 326 - repoName = f.Name 395 + knot = repo.Knot 396 + ownerDid = repo.Did 397 + repoName = repo.Name 327 398 } 328 399 329 400 scheme := "http" ··· 335 406 Host: host, 336 407 } 337 408 338 - repo := fmt.Sprintf("%s/%s", ownerDid, repoName) 339 - branchResp, err := tangled.RepoBranch(r.Context(), xrpcc, pull.PullSource.Branch, repo) 409 + didSlashName := fmt.Sprintf("%s/%s", ownerDid, repoName) 410 + branchResp, err := tangled.RepoBranch(r.Context(), xrpcc, pull.PullSource.Branch, didSlashName) 340 411 if err != nil { 341 412 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 342 413 log.Println("failed to call XRPC repo.branches", xrpcerr) ··· 348 419 349 420 targetBranch := branchResp 350 421 351 - latestSourceRev := pull.Submissions[pull.LastRoundNumber()].SourceRev 422 + latestSourceRev := pull.LatestSha() 352 423 353 424 if pull.IsStacked() && stack != nil { 354 425 top := stack[0] 355 - latestSourceRev = top.Submissions[top.LastRoundNumber()].SourceRev 426 + latestSourceRev = top.LatestSha() 356 427 } 357 428 358 429 if latestSourceRev != targetBranch.Hash { ··· 364 435 365 436 func (s *Pulls) RepoPullPatch(w http.ResponseWriter, r *http.Request) { 366 437 user := s.oauth.GetUser(r) 367 - f, err := s.repoResolver.Resolve(r) 368 - if err != nil { 369 - log.Println("failed to get repo and knot", err) 370 - return 371 - } 372 438 373 439 var diffOpts types.DiffOpts 374 440 if d := r.URL.Query().Get("diff"); d == "split" { ··· 392 458 return 393 459 } 394 460 395 - patch := pull.Submissions[roundIdInt].Patch 461 + patch := pull.Submissions[roundIdInt].CombinedPatch() 396 462 diff := patchutil.AsNiceDiff(patch, pull.TargetBranch) 397 463 398 464 s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{ 399 465 LoggedInUser: user, 400 - RepoInfo: f.RepoInfo(user), 466 + RepoInfo: s.repoResolver.GetRepoInfo(r, user), 401 467 Pull: pull, 402 468 Stack: stack, 403 469 Round: roundIdInt, ··· 411 477 func (s *Pulls) RepoPullInterdiff(w http.ResponseWriter, r *http.Request) { 412 478 user := s.oauth.GetUser(r) 413 479 414 - f, err := s.repoResolver.Resolve(r) 415 - if err != nil { 416 - log.Println("failed to get repo and knot", err) 417 - return 418 - } 419 - 420 480 var diffOpts types.DiffOpts 421 481 if d := r.URL.Query().Get("diff"); d == "split" { 422 482 diffOpts.Split = true ··· 443 503 return 444 504 } 445 505 446 - currentPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt].Patch) 506 + currentPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt].CombinedPatch()) 447 507 if err != nil { 448 508 log.Println("failed to interdiff; current patch malformed") 449 509 s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; current patch is invalid.") 450 510 return 451 511 } 452 512 453 - previousPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt-1].Patch) 513 + previousPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt-1].CombinedPatch()) 454 514 if err != nil { 455 515 log.Println("failed to interdiff; previous patch malformed") 456 516 s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; previous patch is invalid.") ··· 461 521 462 522 s.pages.RepoPullInterdiffPage(w, pages.RepoPullInterdiffParams{ 463 523 LoggedInUser: s.oauth.GetUser(r), 464 - RepoInfo: f.RepoInfo(user), 524 + RepoInfo: s.repoResolver.GetRepoInfo(r, user), 465 525 Pull: pull, 466 526 Round: roundIdInt, 467 527 Interdiff: interdiff, ··· 490 550 } 491 551 492 552 func (s *Pulls) RepoPulls(w http.ResponseWriter, r *http.Request) { 553 + l := s.logger.With("handler", "RepoPulls") 554 + 493 555 user := s.oauth.GetUser(r) 494 556 params := r.URL.Query() 495 557 ··· 507 569 return 508 570 } 509 571 572 + keyword := params.Get("q") 573 + 574 + var ids []int64 575 + searchOpts := models.PullSearchOptions{ 576 + Keyword: keyword, 577 + RepoAt: f.RepoAt().String(), 578 + State: state, 579 + // Page: page, 580 + } 581 + l.Debug("searching with", "searchOpts", searchOpts) 582 + if keyword != "" { 583 + res, err := s.indexer.Search(r.Context(), searchOpts) 584 + if err != nil { 585 + l.Error("failed to search for pulls", "err", err) 586 + return 587 + } 588 + ids = res.Hits 589 + l.Debug("searched pulls with indexer", "count", len(ids)) 590 + } else { 591 + ids, err = db.GetPullIDs(s.db, searchOpts) 592 + if err != nil { 593 + l.Error("failed to get all pull ids", "err", err) 594 + return 595 + } 596 + l.Debug("indexed all pulls from the db", "count", len(ids)) 597 + } 598 + 510 599 pulls, err := db.GetPulls( 511 600 s.db, 512 - db.FilterEq("repo_at", f.RepoAt()), 513 - db.FilterEq("state", state), 601 + orm.FilterIn("id", ids), 514 602 ) 515 603 if err != nil { 516 604 log.Println("failed to get pulls", err) ··· 558 646 } 559 647 pulls = pulls[:n] 560 648 561 - repoInfo := f.RepoInfo(user) 562 649 ps, err := db.GetPipelineStatuses( 563 650 s.db, 564 - db.FilterEq("repo_owner", repoInfo.OwnerDid), 565 - db.FilterEq("repo_name", repoInfo.Name), 566 - db.FilterEq("knot", repoInfo.Knot), 567 - db.FilterIn("sha", shas), 651 + len(shas), 652 + orm.FilterEq("repo_owner", f.Did), 653 + orm.FilterEq("repo_name", f.Name), 654 + orm.FilterEq("knot", f.Knot), 655 + orm.FilterIn("sha", shas), 568 656 ) 569 657 if err != nil { 570 658 log.Printf("failed to fetch pipeline statuses: %s", err) ··· 577 665 578 666 labelDefs, err := db.GetLabelDefinitions( 579 667 s.db, 580 - db.FilterIn("at_uri", f.Repo.Labels), 581 - db.FilterContains("scope", tangled.RepoPullNSID), 668 + orm.FilterIn("at_uri", f.Labels), 669 + orm.FilterContains("scope", tangled.RepoPullNSID), 582 670 ) 583 671 if err != nil { 584 672 log.Println("failed to fetch labels", err) ··· 593 681 594 682 s.pages.RepoPulls(w, pages.RepoPullsParams{ 595 683 LoggedInUser: s.oauth.GetUser(r), 596 - RepoInfo: f.RepoInfo(user), 684 + RepoInfo: s.repoResolver.GetRepoInfo(r, user), 597 685 Pulls: pulls, 598 686 LabelDefs: defs, 599 687 FilteringBy: state, 688 + FilterQuery: keyword, 600 689 Stacks: stacks, 601 690 Pipelines: m, 602 691 }) ··· 629 718 case http.MethodGet: 630 719 s.pages.PullNewCommentFragment(w, pages.PullNewCommentParams{ 631 720 LoggedInUser: user, 632 - RepoInfo: f.RepoInfo(user), 721 + RepoInfo: s.repoResolver.GetRepoInfo(r, user), 633 722 Pull: pull, 634 723 RoundNumber: roundNumber, 635 724 }) ··· 641 730 return 642 731 } 643 732 733 + mentions, references := s.mentionsResolver.Resolve(r.Context(), body) 734 + 644 735 // Start a transaction 645 736 tx, err := s.db.BeginTx(r.Context(), nil) 646 737 if err != nil { ··· 652 743 653 744 createdAt := time.Now().Format(time.RFC3339) 654 745 655 - pullAt, err := db.GetPullAt(s.db, f.RepoAt(), pull.PullId) 656 - if err != nil { 657 - log.Println("failed to get pull at", err) 658 - s.pages.Notice(w, "pull-comment", "Failed to create comment.") 659 - return 660 - } 661 - 662 746 client, err := s.oauth.AuthorizedClient(r) 663 747 if err != nil { 664 748 log.Println("failed to get authorized client", err) ··· 671 755 Rkey: tid.TID(), 672 756 Record: &lexutil.LexiconTypeDecoder{ 673 757 Val: &tangled.RepoPullComment{ 674 - Pull: string(pullAt), 758 + Pull: pull.AtUri().String(), 675 759 Body: body, 676 760 CreatedAt: createdAt, 677 761 }, ··· 690 774 Body: body, 691 775 CommentAt: atResp.Uri, 692 776 SubmissionId: pull.Submissions[roundNumber].ID, 777 + Mentions: mentions, 778 + References: references, 693 779 } 694 780 695 781 // Create the pull comment in the database with the commentAt field ··· 707 793 return 708 794 } 709 795 710 - s.notifier.NewPullComment(r.Context(), comment) 796 + s.notifier.NewPullComment(r.Context(), comment, mentions) 711 797 712 - s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId)) 798 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 799 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", ownerSlashRepo, pull.PullId, commentId)) 713 800 return 714 801 } 715 802 } ··· 733 820 Host: host, 734 821 } 735 822 736 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 823 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 737 824 xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 738 825 if err != nil { 739 826 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { ··· 760 847 761 848 s.pages.RepoNewPull(w, pages.RepoNewPullParams{ 762 849 LoggedInUser: user, 763 - RepoInfo: f.RepoInfo(user), 850 + RepoInfo: s.repoResolver.GetRepoInfo(r, user), 764 851 Branches: result.Branches, 765 852 Strategy: strategy, 766 853 SourceBranch: sourceBranch, ··· 783 870 } 784 871 785 872 // Determine PR type based on input parameters 786 - isPushAllowed := f.RepoInfo(user).Roles.IsPushAllowed() 873 + roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())} 874 + isPushAllowed := roles.IsPushAllowed() 787 875 isBranchBased := isPushAllowed && sourceBranch != "" && fromFork == "" 788 876 isForkBased := fromFork != "" && sourceBranch != "" 789 877 isPatchBased := patch != "" && !isBranchBased && !isForkBased ··· 881 969 func (s *Pulls) handleBranchBasedPull( 882 970 w http.ResponseWriter, 883 971 r *http.Request, 884 - f *reporesolver.ResolvedRepo, 972 + repo *models.Repo, 885 973 user *oauth.User, 886 974 title, 887 975 body, ··· 893 981 if !s.config.Core.Dev { 894 982 scheme = "https" 895 983 } 896 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 984 + host := fmt.Sprintf("%s://%s", scheme, repo.Knot) 897 985 xrpcc := &indigoxrpc.Client{ 898 986 Host: host, 899 987 } 900 988 901 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 902 - xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, targetBranch, sourceBranch) 989 + didSlashRepo := fmt.Sprintf("%s/%s", repo.Did, repo.Name) 990 + xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, didSlashRepo, targetBranch, sourceBranch) 903 991 if err != nil { 904 992 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 905 993 log.Println("failed to call XRPC repo.compare", xrpcerr) ··· 919 1007 } 920 1008 921 1009 sourceRev := comparison.Rev2 922 - patch := comparison.Patch 1010 + patch := comparison.FormatPatchRaw 1011 + combined := comparison.CombinedPatchRaw 923 1012 924 - if !patchutil.IsPatchValid(patch) { 1013 + if err := s.validator.ValidatePatch(&patch); err != nil { 1014 + s.logger.Error("failed to validate patch", "err", err) 925 1015 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 926 1016 return 927 1017 } ··· 934 1024 Sha: comparison.Rev2, 935 1025 } 936 1026 937 - s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, pullSource, recordPullSource, isStacked) 1027 + s.createPullRequest(w, r, repo, user, title, body, targetBranch, patch, combined, sourceRev, pullSource, recordPullSource, isStacked) 938 1028 } 939 1029 940 - func (s *Pulls) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, f *reporesolver.ResolvedRepo, user *oauth.User, title, body, targetBranch, patch string, isStacked bool) { 941 - if !patchutil.IsPatchValid(patch) { 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) 942 1033 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 943 1034 return 944 1035 } 945 1036 946 - s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, "", nil, nil, isStacked) 1037 + s.createPullRequest(w, r, repo, user, title, body, targetBranch, patch, "", "", nil, nil, isStacked) 947 1038 } 948 1039 949 - func (s *Pulls) handleForkBasedPull(w http.ResponseWriter, r *http.Request, f *reporesolver.ResolvedRepo, user *oauth.User, forkRepo string, title, body, targetBranch, sourceBranch string, isStacked bool) { 1040 + func (s *Pulls) handleForkBasedPull(w http.ResponseWriter, r *http.Request, repo *models.Repo, user *oauth.User, forkRepo string, title, body, targetBranch, sourceBranch string, isStacked bool) { 950 1041 repoString := strings.SplitN(forkRepo, "/", 2) 951 1042 forkOwnerDid := repoString[0] 952 1043 repoName := repoString[1] ··· 1026 1117 } 1027 1118 1028 1119 sourceRev := comparison.Rev2 1029 - patch := comparison.Patch 1120 + patch := comparison.FormatPatchRaw 1121 + combined := comparison.CombinedPatchRaw 1030 1122 1031 - if !patchutil.IsPatchValid(patch) { 1123 + if err := s.validator.ValidatePatch(&patch); err != nil { 1124 + s.logger.Error("failed to validate patch", "err", err) 1032 1125 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 1033 1126 return 1034 1127 } ··· 1046 1139 Sha: sourceRev, 1047 1140 } 1048 1141 1049 - s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, pullSource, recordPullSource, isStacked) 1142 + s.createPullRequest(w, r, repo, user, title, body, targetBranch, patch, combined, sourceRev, pullSource, recordPullSource, isStacked) 1050 1143 } 1051 1144 1052 1145 func (s *Pulls) createPullRequest( 1053 1146 w http.ResponseWriter, 1054 1147 r *http.Request, 1055 - f *reporesolver.ResolvedRepo, 1148 + repo *models.Repo, 1056 1149 user *oauth.User, 1057 1150 title, body, targetBranch string, 1058 1151 patch string, 1152 + combined string, 1059 1153 sourceRev string, 1060 1154 pullSource *models.PullSource, 1061 1155 recordPullSource *tangled.RepoPull_Source, ··· 1066 1160 s.createStackedPullRequest( 1067 1161 w, 1068 1162 r, 1069 - f, 1163 + repo, 1070 1164 user, 1071 1165 targetBranch, 1072 1166 patch, ··· 1112 1206 } 1113 1207 } 1114 1208 1209 + mentions, references := s.mentionsResolver.Resolve(r.Context(), body) 1210 + 1115 1211 rkey := tid.TID() 1116 1212 initialSubmission := models.PullSubmission{ 1117 1213 Patch: patch, 1214 + Combined: combined, 1118 1215 SourceRev: sourceRev, 1119 1216 } 1120 1217 pull := &models.Pull{ ··· 1122 1219 Body: body, 1123 1220 TargetBranch: targetBranch, 1124 1221 OwnerDid: user.Did, 1125 - RepoAt: f.RepoAt(), 1222 + RepoAt: repo.RepoAt(), 1126 1223 Rkey: rkey, 1224 + Mentions: mentions, 1225 + References: references, 1127 1226 Submissions: []*models.PullSubmission{ 1128 1227 &initialSubmission, 1129 1228 }, ··· 1135 1234 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1136 1235 return 1137 1236 } 1138 - pullId, err := db.NextPullId(tx, f.RepoAt()) 1237 + pullId, err := db.NextPullId(tx, repo.RepoAt()) 1139 1238 if err != nil { 1140 1239 log.Println("failed to get pull id", err) 1141 1240 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") ··· 1150 1249 Val: &tangled.RepoPull{ 1151 1250 Title: title, 1152 1251 Target: &tangled.RepoPull_Target{ 1153 - Repo: string(f.RepoAt()), 1252 + Repo: string(repo.RepoAt()), 1154 1253 Branch: targetBranch, 1155 1254 }, 1156 - Patch: patch, 1157 - Source: recordPullSource, 1255 + Patch: patch, 1256 + Source: recordPullSource, 1257 + CreatedAt: time.Now().Format(time.RFC3339), 1158 1258 }, 1159 1259 }, 1160 1260 }) ··· 1172 1272 1173 1273 s.notifier.NewPull(r.Context(), pull) 1174 1274 1175 - s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId)) 1275 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo) 1276 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pullId)) 1176 1277 } 1177 1278 1178 1279 func (s *Pulls) createStackedPullRequest( 1179 1280 w http.ResponseWriter, 1180 1281 r *http.Request, 1181 - f *reporesolver.ResolvedRepo, 1282 + repo *models.Repo, 1182 1283 user *oauth.User, 1183 1284 targetBranch string, 1184 1285 patch string, ··· 1210 1311 1211 1312 // build a stack out of this patch 1212 1313 stackId := uuid.New() 1213 - stack, err := newStack(f, user, targetBranch, patch, pullSource, stackId.String()) 1314 + stack, err := s.newStack(r.Context(), repo, user, targetBranch, patch, pullSource, stackId.String()) 1214 1315 if err != nil { 1215 1316 log.Println("failed to create stack", err) 1216 1317 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to create stack: %v", err)) ··· 1265 1366 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1266 1367 return 1267 1368 } 1369 + 1268 1370 } 1269 1371 1270 1372 if err = tx.Commit(); err != nil { ··· 1273 1375 return 1274 1376 } 1275 1377 1276 - s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls", f.OwnerSlashRepo())) 1378 + // notify about each pull 1379 + // 1380 + // this is performed after tx.Commit, because it could result in a locked DB otherwise 1381 + for _, p := range stack { 1382 + s.notifier.NewPull(r.Context(), p) 1383 + } 1384 + 1385 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo) 1386 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls", ownerSlashRepo)) 1277 1387 } 1278 1388 1279 1389 func (s *Pulls) ValidatePatch(w http.ResponseWriter, r *http.Request) { ··· 1289 1399 return 1290 1400 } 1291 1401 1292 - if patch == "" || !patchutil.IsPatchValid(patch) { 1402 + if err := s.validator.ValidatePatch(&patch); err != nil { 1403 + s.logger.Error("faield to validate patch", "err", err) 1293 1404 s.pages.Notice(w, "patch-error", "Invalid patch format. Please provide a valid git diff or format-patch.") 1294 1405 return 1295 1406 } ··· 1303 1414 1304 1415 func (s *Pulls) PatchUploadFragment(w http.ResponseWriter, r *http.Request) { 1305 1416 user := s.oauth.GetUser(r) 1306 - f, err := s.repoResolver.Resolve(r) 1307 - if err != nil { 1308 - log.Println("failed to get repo and knot", err) 1309 - return 1310 - } 1311 1417 1312 1418 s.pages.PullPatchUploadFragment(w, pages.PullPatchUploadParams{ 1313 - RepoInfo: f.RepoInfo(user), 1419 + RepoInfo: s.repoResolver.GetRepoInfo(r, user), 1314 1420 }) 1315 1421 } 1316 1422 ··· 1331 1437 Host: host, 1332 1438 } 1333 1439 1334 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1440 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 1335 1441 xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 1336 1442 if err != nil { 1337 1443 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { ··· 1364 1470 } 1365 1471 1366 1472 s.pages.PullCompareBranchesFragment(w, pages.PullCompareBranchesParams{ 1367 - RepoInfo: f.RepoInfo(user), 1473 + RepoInfo: s.repoResolver.GetRepoInfo(r, user), 1368 1474 Branches: withoutDefault, 1369 1475 }) 1370 1476 } 1371 1477 1372 1478 func (s *Pulls) CompareForksFragment(w http.ResponseWriter, r *http.Request) { 1373 1479 user := s.oauth.GetUser(r) 1374 - f, err := s.repoResolver.Resolve(r) 1375 - if err != nil { 1376 - log.Println("failed to get repo and knot", err) 1377 - return 1378 - } 1379 1480 1380 1481 forks, err := db.GetForksByDid(s.db, user.Did) 1381 1482 if err != nil { ··· 1384 1485 } 1385 1486 1386 1487 s.pages.PullCompareForkFragment(w, pages.PullCompareForkParams{ 1387 - RepoInfo: f.RepoInfo(user), 1488 + RepoInfo: s.repoResolver.GetRepoInfo(r, user), 1388 1489 Forks: forks, 1389 1490 Selected: r.URL.Query().Get("fork"), 1390 1491 }) ··· 1406 1507 // fork repo 1407 1508 repo, err := db.GetRepo( 1408 1509 s.db, 1409 - db.FilterEq("did", forkOwnerDid), 1410 - db.FilterEq("name", forkName), 1510 + orm.FilterEq("did", forkOwnerDid), 1511 + orm.FilterEq("name", forkName), 1411 1512 ) 1412 1513 if err != nil { 1413 1514 log.Println("failed to get repo", "did", forkOwnerDid, "name", forkName, "err", err) ··· 1452 1553 Host: targetHost, 1453 1554 } 1454 1555 1455 - targetRepo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1556 + targetRepo := fmt.Sprintf("%s/%s", f.Did, f.Name) 1456 1557 targetXrpcBytes, err := tangled.RepoBranches(r.Context(), targetXrpcc, "", 0, targetRepo) 1457 1558 if err != nil { 1458 1559 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { ··· 1477 1578 }) 1478 1579 1479 1580 s.pages.PullCompareForkBranchesFragment(w, pages.PullCompareForkBranchesParams{ 1480 - RepoInfo: f.RepoInfo(user), 1581 + RepoInfo: s.repoResolver.GetRepoInfo(r, user), 1481 1582 SourceBranches: sourceBranches.Branches, 1482 1583 TargetBranches: targetBranches.Branches, 1483 1584 }) ··· 1485 1586 1486 1587 func (s *Pulls) ResubmitPull(w http.ResponseWriter, r *http.Request) { 1487 1588 user := s.oauth.GetUser(r) 1488 - f, err := s.repoResolver.Resolve(r) 1489 - if err != nil { 1490 - log.Println("failed to get repo and knot", err) 1491 - return 1492 - } 1493 1589 1494 1590 pull, ok := r.Context().Value("pull").(*models.Pull) 1495 1591 if !ok { ··· 1501 1597 switch r.Method { 1502 1598 case http.MethodGet: 1503 1599 s.pages.PullResubmitFragment(w, pages.PullResubmitParams{ 1504 - RepoInfo: f.RepoInfo(user), 1600 + RepoInfo: s.repoResolver.GetRepoInfo(r, user), 1505 1601 Pull: pull, 1506 1602 }) 1507 1603 return ··· 1543 1639 1544 1640 patch := r.FormValue("patch") 1545 1641 1546 - s.resubmitPullHelper(w, r, f, user, pull, patch, "") 1642 + s.resubmitPullHelper(w, r, f, user, pull, patch, "", "") 1547 1643 } 1548 1644 1549 1645 func (s *Pulls) resubmitBranch(w http.ResponseWriter, r *http.Request) { ··· 1568 1664 return 1569 1665 } 1570 1666 1571 - if !f.RepoInfo(user).Roles.IsPushAllowed() { 1667 + roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())} 1668 + if !roles.IsPushAllowed() { 1572 1669 log.Println("unauthorized user") 1573 1670 w.WriteHeader(http.StatusUnauthorized) 1574 1671 return ··· 1583 1680 Host: host, 1584 1681 } 1585 1682 1586 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1683 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 1587 1684 xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, pull.TargetBranch, pull.PullSource.Branch) 1588 1685 if err != nil { 1589 1686 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { ··· 1604 1701 } 1605 1702 1606 1703 sourceRev := comparison.Rev2 1607 - patch := comparison.Patch 1704 + patch := comparison.FormatPatchRaw 1705 + combined := comparison.CombinedPatchRaw 1608 1706 1609 - s.resubmitPullHelper(w, r, f, user, pull, patch, sourceRev) 1707 + s.resubmitPullHelper(w, r, f, user, pull, patch, combined, sourceRev) 1610 1708 } 1611 1709 1612 1710 func (s *Pulls) resubmitFork(w http.ResponseWriter, r *http.Request) { ··· 1638 1736 return 1639 1737 } 1640 1738 1641 - // extract patch by performing compare 1642 - forkScheme := "http" 1643 - if !s.config.Core.Dev { 1644 - forkScheme = "https" 1645 - } 1646 - forkHost := fmt.Sprintf("%s://%s", forkScheme, forkRepo.Knot) 1647 - forkRepoId := fmt.Sprintf("%s/%s", forkRepo.Did, forkRepo.Name) 1648 - forkXrpcBytes, err := tangled.RepoCompare(r.Context(), &indigoxrpc.Client{Host: forkHost}, forkRepoId, pull.TargetBranch, pull.PullSource.Branch) 1649 - if err != nil { 1650 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1651 - log.Println("failed to call XRPC repo.compare for fork", xrpcerr) 1652 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1653 - return 1654 - } 1655 - log.Printf("failed to compare branches: %s", err) 1656 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1657 - return 1658 - } 1659 - 1660 - var forkComparison types.RepoFormatPatchResponse 1661 - if err := json.Unmarshal(forkXrpcBytes, &forkComparison); err != nil { 1662 - log.Println("failed to decode XRPC compare response for fork", err) 1663 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1664 - return 1665 - } 1666 - 1667 1739 // update the hidden tracking branch to latest 1668 1740 client, err := s.oauth.ServiceClient( 1669 1741 r, ··· 1695 1767 return 1696 1768 } 1697 1769 1770 + hiddenRef := fmt.Sprintf("hidden/%s/%s", pull.PullSource.Branch, pull.TargetBranch) 1771 + // extract patch by performing compare 1772 + forkScheme := "http" 1773 + if !s.config.Core.Dev { 1774 + forkScheme = "https" 1775 + } 1776 + forkHost := fmt.Sprintf("%s://%s", forkScheme, forkRepo.Knot) 1777 + forkRepoId := fmt.Sprintf("%s/%s", forkRepo.Did, forkRepo.Name) 1778 + forkXrpcBytes, err := tangled.RepoCompare(r.Context(), &indigoxrpc.Client{Host: forkHost}, forkRepoId, hiddenRef, pull.PullSource.Branch) 1779 + if err != nil { 1780 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1781 + log.Println("failed to call XRPC repo.compare for fork", xrpcerr) 1782 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1783 + return 1784 + } 1785 + log.Printf("failed to compare branches: %s", err) 1786 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1787 + return 1788 + } 1789 + 1790 + var forkComparison types.RepoFormatPatchResponse 1791 + if err := json.Unmarshal(forkXrpcBytes, &forkComparison); err != nil { 1792 + log.Println("failed to decode XRPC compare response for fork", err) 1793 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1794 + return 1795 + } 1796 + 1698 1797 // Use the fork comparison we already made 1699 1798 comparison := forkComparison 1700 1799 1701 1800 sourceRev := comparison.Rev2 1702 - patch := comparison.Patch 1801 + patch := comparison.FormatPatchRaw 1802 + combined := comparison.CombinedPatchRaw 1703 1803 1704 - s.resubmitPullHelper(w, r, f, user, pull, patch, sourceRev) 1705 - } 1706 - 1707 - // validate a resubmission against a pull request 1708 - func validateResubmittedPatch(pull *models.Pull, patch string) error { 1709 - if patch == "" { 1710 - return fmt.Errorf("Patch is empty.") 1711 - } 1712 - 1713 - if patch == pull.LatestPatch() { 1714 - return fmt.Errorf("Patch is identical to previous submission.") 1715 - } 1716 - 1717 - if !patchutil.IsPatchValid(patch) { 1718 - return fmt.Errorf("Invalid patch format. Please provide a valid diff.") 1719 - } 1720 - 1721 - return nil 1804 + s.resubmitPullHelper(w, r, f, user, pull, patch, combined, sourceRev) 1722 1805 } 1723 1806 1724 1807 func (s *Pulls) resubmitPullHelper( 1725 1808 w http.ResponseWriter, 1726 1809 r *http.Request, 1727 - f *reporesolver.ResolvedRepo, 1810 + repo *models.Repo, 1728 1811 user *oauth.User, 1729 1812 pull *models.Pull, 1730 1813 patch string, 1814 + combined string, 1731 1815 sourceRev string, 1732 1816 ) { 1733 1817 if pull.IsStacked() { 1734 1818 log.Println("resubmitting stacked PR") 1735 - s.resubmitStackedPullHelper(w, r, f, user, pull, patch, pull.StackId) 1819 + s.resubmitStackedPullHelper(w, r, repo, user, pull, patch, pull.StackId) 1736 1820 return 1737 1821 } 1738 1822 1739 - if err := validateResubmittedPatch(pull, patch); err != nil { 1823 + if err := s.validator.ValidatePatch(&patch); err != nil { 1740 1824 s.pages.Notice(w, "resubmit-error", err.Error()) 1741 1825 return 1742 1826 } 1743 1827 1828 + if patch == pull.LatestPatch() { 1829 + s.pages.Notice(w, "resubmit-error", "Patch is identical to previous submission.") 1830 + return 1831 + } 1832 + 1744 1833 // validate sourceRev if branch/fork based 1745 1834 if pull.IsBranchBased() || pull.IsForkBased() { 1746 - if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev { 1835 + if sourceRev == pull.LatestSha() { 1747 1836 s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.") 1748 1837 return 1749 1838 } ··· 1757 1846 } 1758 1847 defer tx.Rollback() 1759 1848 1760 - err = db.ResubmitPull(tx, pull, patch, sourceRev) 1849 + pullAt := pull.AtUri() 1850 + newRoundNumber := len(pull.Submissions) 1851 + newPatch := patch 1852 + newSourceRev := sourceRev 1853 + combinedPatch := combined 1854 + err = db.ResubmitPull(tx, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev) 1761 1855 if err != nil { 1762 1856 log.Println("failed to create pull request", err) 1763 1857 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") ··· 1802 1896 Val: &tangled.RepoPull{ 1803 1897 Title: pull.Title, 1804 1898 Target: &tangled.RepoPull_Target{ 1805 - Repo: string(f.RepoAt()), 1899 + Repo: string(repo.RepoAt()), 1806 1900 Branch: pull.TargetBranch, 1807 1901 }, 1808 - Patch: patch, // new patch 1809 - Source: recordPullSource, 1902 + Patch: patch, // new patch 1903 + Source: recordPullSource, 1904 + CreatedAt: time.Now().Format(time.RFC3339), 1810 1905 }, 1811 1906 }, 1812 1907 }) ··· 1822 1917 return 1823 1918 } 1824 1919 1825 - s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 1920 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo) 1921 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 1826 1922 } 1827 1923 1828 1924 func (s *Pulls) resubmitStackedPullHelper( 1829 1925 w http.ResponseWriter, 1830 1926 r *http.Request, 1831 - f *reporesolver.ResolvedRepo, 1927 + repo *models.Repo, 1832 1928 user *oauth.User, 1833 1929 pull *models.Pull, 1834 1930 patch string, ··· 1837 1933 targetBranch := pull.TargetBranch 1838 1934 1839 1935 origStack, _ := r.Context().Value("stack").(models.Stack) 1840 - newStack, err := newStack(f, user, targetBranch, patch, pull.PullSource, stackId) 1936 + newStack, err := s.newStack(r.Context(), repo, user, targetBranch, patch, pull.PullSource, stackId) 1841 1937 if err != nil { 1842 1938 log.Println("failed to create resubmitted stack", err) 1843 1939 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") ··· 1857 1953 // commits that got deleted: corresponding pull is closed 1858 1954 // commits that got added: new pull is created 1859 1955 // commits that got updated: corresponding pull is resubmitted & new round begins 1860 - // 1861 - // for commits that were unchanged: no changes, parent-change-id is updated as necessary 1862 1956 additions := make(map[string]*models.Pull) 1863 1957 deletions := make(map[string]*models.Pull) 1864 - unchanged := make(map[string]struct{}) 1865 1958 updated := make(map[string]struct{}) 1866 1959 1867 1960 // pulls in orignal stack but not in new one ··· 1883 1976 for _, np := range newStack { 1884 1977 if op, ok := origById[np.ChangeId]; ok { 1885 1978 // pull exists in both stacks 1886 - // TODO: can we avoid reparse? 1887 - origFiles, origHeaderStr, _ := gitdiff.Parse(strings.NewReader(op.LatestPatch())) 1888 - newFiles, newHeaderStr, _ := gitdiff.Parse(strings.NewReader(np.LatestPatch())) 1889 - 1890 - origHeader, _ := gitdiff.ParsePatchHeader(origHeaderStr) 1891 - newHeader, _ := gitdiff.ParsePatchHeader(newHeaderStr) 1892 - 1893 - patchutil.SortPatch(newFiles) 1894 - patchutil.SortPatch(origFiles) 1895 - 1896 - // text content of patch may be identical, but a jj rebase might have forwarded it 1897 - // 1898 - // we still need to update the hash in submission.Patch and submission.SourceRev 1899 - if patchutil.Equal(newFiles, origFiles) && 1900 - origHeader.Title == newHeader.Title && 1901 - origHeader.Body == newHeader.Body { 1902 - unchanged[op.ChangeId] = struct{}{} 1903 - } else { 1904 - updated[op.ChangeId] = struct{}{} 1905 - } 1979 + updated[op.ChangeId] = struct{}{} 1906 1980 } 1907 1981 } 1908 1982 ··· 1969 2043 continue 1970 2044 } 1971 2045 1972 - submission := np.Submissions[np.LastRoundNumber()] 1973 - 1974 - // resubmit the old pull 1975 - err := db.ResubmitPull(tx, op, submission.Patch, submission.SourceRev) 1976 - 2046 + // resubmit the new pull 2047 + pullAt := op.AtUri() 2048 + newRoundNumber := len(op.Submissions) 2049 + newPatch := np.LatestPatch() 2050 + combinedPatch := np.LatestSubmission().Combined 2051 + newSourceRev := np.LatestSha() 2052 + err := db.ResubmitPull(tx, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev) 1977 2053 if err != nil { 1978 2054 log.Println("failed to update pull", err, op.PullId) 1979 2055 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 1980 2056 return 1981 2057 } 1982 2058 1983 - record := op.AsRecord() 1984 - record.Patch = submission.Patch 1985 - 1986 - writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 1987 - RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{ 1988 - Collection: tangled.RepoPullNSID, 1989 - Rkey: op.Rkey, 1990 - Value: &lexutil.LexiconTypeDecoder{ 1991 - Val: &record, 1992 - }, 1993 - }, 1994 - }) 1995 - } 1996 - 1997 - // unchanged pulls are edited without starting a new round 1998 - // 1999 - // update source-revs & patches without advancing rounds 2000 - for changeId := range unchanged { 2001 - op, _ := origById[changeId] 2002 - np, _ := newById[changeId] 2003 - 2004 - origSubmission := op.Submissions[op.LastRoundNumber()] 2005 - newSubmission := np.Submissions[np.LastRoundNumber()] 2006 - 2007 - log.Println("moving unchanged change id : ", changeId) 2008 - 2009 - err := db.UpdatePull( 2010 - tx, 2011 - newSubmission.Patch, 2012 - newSubmission.SourceRev, 2013 - db.FilterEq("id", origSubmission.ID), 2014 - ) 2015 - 2016 - if err != nil { 2017 - log.Println("failed to update pull", err, op.PullId) 2018 - s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 2019 - return 2020 - } 2021 - 2022 - record := op.AsRecord() 2023 - record.Patch = newSubmission.Patch 2059 + record := np.AsRecord() 2024 2060 2025 2061 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 2026 2062 RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{ ··· 2039 2075 tx, 2040 2076 p.ParentChangeId, 2041 2077 // these should be enough filters to be unique per-stack 2042 - db.FilterEq("repo_at", p.RepoAt.String()), 2043 - db.FilterEq("owner_did", p.OwnerDid), 2044 - db.FilterEq("change_id", p.ChangeId), 2078 + orm.FilterEq("repo_at", p.RepoAt.String()), 2079 + orm.FilterEq("owner_did", p.OwnerDid), 2080 + orm.FilterEq("change_id", p.ChangeId), 2045 2081 ) 2046 2082 2047 2083 if err != nil { ··· 2075 2111 return 2076 2112 } 2077 2113 2078 - s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 2114 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo) 2115 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 2079 2116 } 2080 2117 2081 2118 func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) { 2119 + user := s.oauth.GetUser(r) 2082 2120 f, err := s.repoResolver.Resolve(r) 2083 2121 if err != nil { 2084 2122 log.Println("failed to resolve repo:", err) ··· 2127 2165 2128 2166 authorName := ident.Handle.String() 2129 2167 mergeInput := &tangled.RepoMerge_Input{ 2130 - Did: f.OwnerDid(), 2168 + Did: f.Did, 2131 2169 Name: f.Name, 2132 2170 Branch: pull.TargetBranch, 2133 2171 Patch: patch, ··· 2176 2214 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 2177 2215 return 2178 2216 } 2217 + p.State = models.PullMerged 2179 2218 } 2180 2219 2181 2220 err = tx.Commit() ··· 2188 2227 2189 2228 // notify about the pull merge 2190 2229 for _, p := range pullsToMerge { 2191 - s.notifier.NewPullMerged(r.Context(), p) 2230 + s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p) 2192 2231 } 2193 2232 2194 - s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.Name, pull.PullId)) 2233 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 2234 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 2195 2235 } 2196 2236 2197 2237 func (s *Pulls) ClosePull(w http.ResponseWriter, r *http.Request) { ··· 2211 2251 } 2212 2252 2213 2253 // auth filter: only owner or collaborators can close 2214 - roles := f.RolesInRepo(user) 2254 + roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())} 2215 2255 isOwner := roles.IsOwner() 2216 2256 isCollaborator := roles.IsCollaborator() 2217 2257 isPullAuthor := user.Did == pull.OwnerDid ··· 2249 2289 s.pages.Notice(w, "pull-close", "Failed to close pull.") 2250 2290 return 2251 2291 } 2292 + p.State = models.PullClosed 2252 2293 } 2253 2294 2254 2295 // Commit the transaction ··· 2259 2300 } 2260 2301 2261 2302 for _, p := range pullsToClose { 2262 - s.notifier.NewPullClosed(r.Context(), p) 2303 + s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p) 2263 2304 } 2264 2305 2265 - s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 2306 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 2307 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 2266 2308 } 2267 2309 2268 2310 func (s *Pulls) ReopenPull(w http.ResponseWriter, r *http.Request) { ··· 2283 2325 } 2284 2326 2285 2327 // auth filter: only owner or collaborators can close 2286 - roles := f.RolesInRepo(user) 2328 + roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())} 2287 2329 isOwner := roles.IsOwner() 2288 2330 isCollaborator := roles.IsCollaborator() 2289 2331 isPullAuthor := user.Did == pull.OwnerDid ··· 2321 2363 s.pages.Notice(w, "pull-close", "Failed to close pull.") 2322 2364 return 2323 2365 } 2366 + p.State = models.PullOpen 2324 2367 } 2325 2368 2326 2369 // Commit the transaction ··· 2330 2373 return 2331 2374 } 2332 2375 2333 - s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 2376 + for _, p := range pullsToReopen { 2377 + s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p) 2378 + } 2379 + 2380 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 2381 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 2334 2382 } 2335 2383 2336 - func newStack(f *reporesolver.ResolvedRepo, user *oauth.User, targetBranch, patch string, pullSource *models.PullSource, stackId string) (models.Stack, error) { 2384 + func (s *Pulls) newStack(ctx context.Context, repo *models.Repo, user *oauth.User, targetBranch, patch string, pullSource *models.PullSource, stackId string) (models.Stack, error) { 2337 2385 formatPatches, err := patchutil.ExtractPatches(patch) 2338 2386 if err != nil { 2339 2387 return nil, fmt.Errorf("Failed to extract patches: %v", err) ··· 2358 2406 body := fp.Body 2359 2407 rkey := tid.TID() 2360 2408 2409 + mentions, references := s.mentionsResolver.Resolve(ctx, body) 2410 + 2361 2411 initialSubmission := models.PullSubmission{ 2362 2412 Patch: fp.Raw, 2363 2413 SourceRev: fp.SHA, 2414 + Combined: fp.Raw, 2364 2415 } 2365 2416 pull := models.Pull{ 2366 2417 Title: title, 2367 2418 Body: body, 2368 2419 TargetBranch: targetBranch, 2369 2420 OwnerDid: user.Did, 2370 - RepoAt: f.RepoAt(), 2421 + RepoAt: repo.RepoAt(), 2371 2422 Rkey: rkey, 2423 + Mentions: mentions, 2424 + References: references, 2372 2425 Submissions: []*models.PullSubmission{ 2373 2426 &initialSubmission, 2374 2427 },
+1
appview/pulls/router.go
··· 23 23 r.Route("/{pull}", func(r chi.Router) { 24 24 r.Use(mw.ResolvePull()) 25 25 r.Get("/", s.RepoSinglePull) 26 + r.Get("/opengraph", s.PullOpenGraphSummary) 26 27 27 28 r.Route("/round/{round}", func(r chi.Router) { 28 29 r.Get("/", s.RepoPullPatch)
+49
appview/repo/archive.go
··· 1 + package repo 2 + 3 + import ( 4 + "fmt" 5 + "net/http" 6 + "net/url" 7 + "strings" 8 + 9 + "tangled.org/core/api/tangled" 10 + xrpcclient "tangled.org/core/appview/xrpcclient" 11 + 12 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 13 + "github.com/go-chi/chi/v5" 14 + "github.com/go-git/go-git/v5/plumbing" 15 + ) 16 + 17 + func (rp *Repo) DownloadArchive(w http.ResponseWriter, r *http.Request) { 18 + l := rp.logger.With("handler", "DownloadArchive") 19 + ref := chi.URLParam(r, "ref") 20 + ref, _ = url.PathUnescape(ref) 21 + f, err := rp.repoResolver.Resolve(r) 22 + if err != nil { 23 + l.Error("failed to get repo and knot", "err", err) 24 + return 25 + } 26 + scheme := "http" 27 + if !rp.config.Core.Dev { 28 + scheme = "https" 29 + } 30 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 31 + xrpcc := &indigoxrpc.Client{ 32 + Host: host, 33 + } 34 + didSlashRepo := f.DidSlashRepo() 35 + archiveBytes, err := tangled.RepoArchive(r.Context(), xrpcc, "tar.gz", "", ref, didSlashRepo) 36 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 37 + l.Error("failed to call XRPC repo.archive", "err", xrpcerr) 38 + rp.pages.Error503(w) 39 + return 40 + } 41 + // Set headers for file download, just pass along whatever the knot specifies 42 + safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(ref).Short(), "/", "-") 43 + filename := fmt.Sprintf("%s-%s.tar.gz", f.Name, safeRefFilename) 44 + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) 45 + w.Header().Set("Content-Type", "application/gzip") 46 + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(archiveBytes))) 47 + // Write the archive data directly 48 + w.Write(archiveBytes) 49 + }
+21 -14
appview/repo/artifact.go
··· 14 14 "tangled.org/core/appview/db" 15 15 "tangled.org/core/appview/models" 16 16 "tangled.org/core/appview/pages" 17 - "tangled.org/core/appview/reporesolver" 18 17 "tangled.org/core/appview/xrpcclient" 18 + "tangled.org/core/orm" 19 19 "tangled.org/core/tid" 20 20 "tangled.org/core/types" 21 21 ··· 131 131 132 132 rp.pages.RepoArtifactFragment(w, pages.RepoArtifactParams{ 133 133 LoggedInUser: user, 134 - RepoInfo: f.RepoInfo(user), 134 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 135 135 Artifact: artifact, 136 136 }) 137 137 } ··· 156 156 157 157 artifacts, err := db.GetArtifact( 158 158 rp.db, 159 - db.FilterEq("repo_at", f.RepoAt()), 160 - db.FilterEq("tag", tag.Tag.Hash[:]), 161 - db.FilterEq("name", filename), 159 + orm.FilterEq("repo_at", f.RepoAt()), 160 + orm.FilterEq("tag", tag.Tag.Hash[:]), 161 + orm.FilterEq("name", filename), 162 162 ) 163 163 if err != nil { 164 164 log.Println("failed to get artifacts", err) ··· 174 174 175 175 artifact := artifacts[0] 176 176 177 - ownerPds := f.OwnerId.PDSEndpoint() 177 + ownerId, err := rp.idResolver.ResolveIdent(r.Context(), f.Did) 178 + if err != nil { 179 + log.Println("failed to resolve repo owner did", f.Did, err) 180 + http.Error(w, "repository owner not found", http.StatusNotFound) 181 + return 182 + } 183 + 184 + ownerPds := ownerId.PDSEndpoint() 178 185 url, _ := url.Parse(fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob", ownerPds)) 179 186 q := url.Query() 180 187 q.Set("cid", artifact.BlobCid.String()) ··· 228 235 229 236 artifacts, err := db.GetArtifact( 230 237 rp.db, 231 - db.FilterEq("repo_at", f.RepoAt()), 232 - db.FilterEq("tag", tag[:]), 233 - db.FilterEq("name", filename), 238 + orm.FilterEq("repo_at", f.RepoAt()), 239 + orm.FilterEq("tag", tag[:]), 240 + orm.FilterEq("name", filename), 234 241 ) 235 242 if err != nil { 236 243 log.Println("failed to get artifacts", err) ··· 270 277 defer tx.Rollback() 271 278 272 279 err = db.DeleteArtifact(tx, 273 - db.FilterEq("repo_at", f.RepoAt()), 274 - db.FilterEq("tag", artifact.Tag[:]), 275 - db.FilterEq("name", filename), 280 + orm.FilterEq("repo_at", f.RepoAt()), 281 + orm.FilterEq("tag", artifact.Tag[:]), 282 + orm.FilterEq("name", filename), 276 283 ) 277 284 if err != nil { 278 285 log.Println("failed to remove artifact record from db", err) ··· 290 297 w.Write([]byte{}) 291 298 } 292 299 293 - func (rp *Repo) resolveTag(ctx context.Context, f *reporesolver.ResolvedRepo, tagParam string) (*types.TagReference, error) { 300 + func (rp *Repo) resolveTag(ctx context.Context, f *models.Repo, tagParam string) (*types.TagReference, error) { 294 301 tagParam, err := url.QueryUnescape(tagParam) 295 302 if err != nil { 296 303 return nil, err ··· 305 312 Host: host, 306 313 } 307 314 308 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 315 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 309 316 xrpcBytes, err := tangled.RepoTags(ctx, xrpcc, "", 0, repo) 310 317 if err != nil { 311 318 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
+293
appview/repo/blob.go
··· 1 + package repo 2 + 3 + import ( 4 + "encoding/base64" 5 + "fmt" 6 + "io" 7 + "net/http" 8 + "net/url" 9 + "path/filepath" 10 + "slices" 11 + "strings" 12 + 13 + "tangled.org/core/api/tangled" 14 + "tangled.org/core/appview/config" 15 + "tangled.org/core/appview/models" 16 + "tangled.org/core/appview/pages" 17 + "tangled.org/core/appview/pages/markup" 18 + "tangled.org/core/appview/reporesolver" 19 + xrpcclient "tangled.org/core/appview/xrpcclient" 20 + 21 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 22 + "github.com/go-chi/chi/v5" 23 + ) 24 + 25 + // the content can be one of the following: 26 + // 27 + // - code : text | | raw 28 + // - markup : text | rendered | raw 29 + // - svg : text | rendered | raw 30 + // - png : | rendered | raw 31 + // - video : | rendered | raw 32 + // - submodule : | rendered | 33 + // - rest : | | 34 + func (rp *Repo) Blob(w http.ResponseWriter, r *http.Request) { 35 + l := rp.logger.With("handler", "RepoBlob") 36 + 37 + f, err := rp.repoResolver.Resolve(r) 38 + if err != nil { 39 + l.Error("failed to get repo and knot", "err", err) 40 + return 41 + } 42 + 43 + ref := chi.URLParam(r, "ref") 44 + ref, _ = url.PathUnescape(ref) 45 + 46 + filePath := chi.URLParam(r, "*") 47 + filePath, _ = url.PathUnescape(filePath) 48 + 49 + scheme := "http" 50 + if !rp.config.Core.Dev { 51 + scheme = "https" 52 + } 53 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 54 + xrpcc := &indigoxrpc.Client{ 55 + Host: host, 56 + } 57 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 58 + resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, repo) 59 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 60 + l.Error("failed to call XRPC repo.blob", "err", xrpcerr) 61 + rp.pages.Error503(w) 62 + return 63 + } 64 + 65 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 66 + 67 + // Use XRPC response directly instead of converting to internal types 68 + var breadcrumbs [][]string 69 + breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", ownerSlashRepo, url.PathEscape(ref))}) 70 + if filePath != "" { 71 + for idx, elem := range strings.Split(filePath, "/") { 72 + breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))}) 73 + } 74 + } 75 + 76 + // Create the blob view 77 + blobView := NewBlobView(resp, rp.config, f, ref, filePath, r.URL.Query()) 78 + 79 + user := rp.oauth.GetUser(r) 80 + 81 + rp.pages.RepoBlob(w, pages.RepoBlobParams{ 82 + LoggedInUser: user, 83 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 84 + BreadCrumbs: breadcrumbs, 85 + BlobView: blobView, 86 + RepoBlob_Output: resp, 87 + }) 88 + } 89 + 90 + func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) { 91 + l := rp.logger.With("handler", "RepoBlobRaw") 92 + 93 + f, err := rp.repoResolver.Resolve(r) 94 + if err != nil { 95 + l.Error("failed to get repo and knot", "err", err) 96 + w.WriteHeader(http.StatusBadRequest) 97 + return 98 + } 99 + 100 + ref := chi.URLParam(r, "ref") 101 + ref, _ = url.PathUnescape(ref) 102 + 103 + filePath := chi.URLParam(r, "*") 104 + filePath, _ = url.PathUnescape(filePath) 105 + 106 + scheme := "http" 107 + if !rp.config.Core.Dev { 108 + scheme = "https" 109 + } 110 + repo := f.DidSlashRepo() 111 + baseURL := &url.URL{ 112 + Scheme: scheme, 113 + Host: f.Knot, 114 + Path: "/xrpc/sh.tangled.repo.blob", 115 + } 116 + query := baseURL.Query() 117 + query.Set("repo", repo) 118 + query.Set("ref", ref) 119 + query.Set("path", filePath) 120 + query.Set("raw", "true") 121 + baseURL.RawQuery = query.Encode() 122 + blobURL := baseURL.String() 123 + req, err := http.NewRequest("GET", blobURL, nil) 124 + if err != nil { 125 + l.Error("failed to create request", "err", err) 126 + return 127 + } 128 + 129 + // forward the If-None-Match header 130 + if clientETag := r.Header.Get("If-None-Match"); clientETag != "" { 131 + req.Header.Set("If-None-Match", clientETag) 132 + } 133 + client := &http.Client{} 134 + 135 + resp, err := client.Do(req) 136 + if err != nil { 137 + l.Error("failed to reach knotserver", "err", err) 138 + rp.pages.Error503(w) 139 + return 140 + } 141 + 142 + defer resp.Body.Close() 143 + 144 + // forward 304 not modified 145 + if resp.StatusCode == http.StatusNotModified { 146 + w.WriteHeader(http.StatusNotModified) 147 + return 148 + } 149 + 150 + if resp.StatusCode != http.StatusOK { 151 + l.Error("knotserver returned non-OK status for raw blob", "url", blobURL, "statuscode", resp.StatusCode) 152 + w.WriteHeader(resp.StatusCode) 153 + _, _ = io.Copy(w, resp.Body) 154 + return 155 + } 156 + 157 + contentType := resp.Header.Get("Content-Type") 158 + body, err := io.ReadAll(resp.Body) 159 + if err != nil { 160 + l.Error("error reading response body from knotserver", "err", err) 161 + w.WriteHeader(http.StatusInternalServerError) 162 + return 163 + } 164 + 165 + if strings.HasPrefix(contentType, "text/") || isTextualMimeType(contentType) { 166 + // serve all textual content as text/plain 167 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 168 + w.Write(body) 169 + } else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") { 170 + // serve images and videos with their original content type 171 + w.Header().Set("Content-Type", contentType) 172 + w.Write(body) 173 + } else { 174 + w.WriteHeader(http.StatusUnsupportedMediaType) 175 + w.Write([]byte("unsupported content type")) 176 + return 177 + } 178 + } 179 + 180 + // NewBlobView creates a BlobView from the XRPC response 181 + func NewBlobView(resp *tangled.RepoBlob_Output, config *config.Config, repo *models.Repo, ref, filePath string, queryParams url.Values) models.BlobView { 182 + view := models.BlobView{ 183 + Contents: "", 184 + Lines: 0, 185 + } 186 + 187 + // Set size 188 + if resp.Size != nil { 189 + view.SizeHint = uint64(*resp.Size) 190 + } else if resp.Content != nil { 191 + view.SizeHint = uint64(len(*resp.Content)) 192 + } 193 + 194 + if resp.Submodule != nil { 195 + view.ContentType = models.BlobContentTypeSubmodule 196 + view.HasRenderedView = true 197 + view.ContentSrc = resp.Submodule.Url 198 + return view 199 + } 200 + 201 + // Determine if binary 202 + if resp.IsBinary != nil && *resp.IsBinary { 203 + view.ContentSrc = generateBlobURL(config, repo, ref, filePath) 204 + ext := strings.ToLower(filepath.Ext(resp.Path)) 205 + 206 + switch ext { 207 + case ".jpg", ".jpeg", ".png", ".gif", ".webp": 208 + view.ContentType = models.BlobContentTypeImage 209 + view.HasRawView = true 210 + view.HasRenderedView = true 211 + view.ShowingRendered = true 212 + 213 + case ".svg": 214 + view.ContentType = models.BlobContentTypeSvg 215 + view.HasRawView = true 216 + view.HasTextView = true 217 + view.HasRenderedView = true 218 + view.ShowingRendered = queryParams.Get("code") != "true" 219 + if resp.Content != nil { 220 + bytes, _ := base64.StdEncoding.DecodeString(*resp.Content) 221 + view.Contents = string(bytes) 222 + view.Lines = strings.Count(view.Contents, "\n") + 1 223 + } 224 + 225 + case ".mp4", ".webm", ".ogg", ".mov", ".avi": 226 + view.ContentType = models.BlobContentTypeVideo 227 + view.HasRawView = true 228 + view.HasRenderedView = true 229 + view.ShowingRendered = true 230 + } 231 + 232 + return view 233 + } 234 + 235 + // otherwise, we are dealing with text content 236 + view.HasRawView = true 237 + view.HasTextView = true 238 + 239 + if resp.Content != nil { 240 + view.Contents = *resp.Content 241 + view.Lines = strings.Count(view.Contents, "\n") + 1 242 + } 243 + 244 + // with text, we may be dealing with markdown 245 + format := markup.GetFormat(resp.Path) 246 + if format == markup.FormatMarkdown { 247 + view.ContentType = models.BlobContentTypeMarkup 248 + view.HasRenderedView = true 249 + view.ShowingRendered = queryParams.Get("code") != "true" 250 + } 251 + 252 + return view 253 + } 254 + 255 + func generateBlobURL(config *config.Config, repo *models.Repo, ref, filePath string) string { 256 + scheme := "http" 257 + if !config.Core.Dev { 258 + scheme = "https" 259 + } 260 + 261 + repoName := fmt.Sprintf("%s/%s", repo.Did, repo.Name) 262 + baseURL := &url.URL{ 263 + Scheme: scheme, 264 + Host: repo.Knot, 265 + Path: "/xrpc/sh.tangled.repo.blob", 266 + } 267 + query := baseURL.Query() 268 + query.Set("repo", repoName) 269 + query.Set("ref", ref) 270 + query.Set("path", filePath) 271 + query.Set("raw", "true") 272 + baseURL.RawQuery = query.Encode() 273 + blobURL := baseURL.String() 274 + 275 + if !config.Core.Dev { 276 + return markup.GenerateCamoURL(config.Camo.Host, config.Camo.SharedSecret, blobURL) 277 + } 278 + return blobURL 279 + } 280 + 281 + func isTextualMimeType(mimeType string) bool { 282 + textualTypes := []string{ 283 + "application/json", 284 + "application/xml", 285 + "application/yaml", 286 + "application/x-yaml", 287 + "application/toml", 288 + "application/javascript", 289 + "application/ecmascript", 290 + "message/", 291 + } 292 + return slices.Contains(textualTypes, mimeType) 293 + }
+95
appview/repo/branches.go
··· 1 + package repo 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + 8 + "tangled.org/core/api/tangled" 9 + "tangled.org/core/appview/oauth" 10 + "tangled.org/core/appview/pages" 11 + xrpcclient "tangled.org/core/appview/xrpcclient" 12 + "tangled.org/core/types" 13 + 14 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 15 + ) 16 + 17 + func (rp *Repo) Branches(w http.ResponseWriter, r *http.Request) { 18 + l := rp.logger.With("handler", "RepoBranches") 19 + f, err := rp.repoResolver.Resolve(r) 20 + if err != nil { 21 + l.Error("failed to get repo and knot", "err", err) 22 + return 23 + } 24 + scheme := "http" 25 + if !rp.config.Core.Dev { 26 + scheme = "https" 27 + } 28 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 29 + xrpcc := &indigoxrpc.Client{ 30 + Host: host, 31 + } 32 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 33 + xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 34 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 35 + l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 36 + rp.pages.Error503(w) 37 + return 38 + } 39 + var result types.RepoBranchesResponse 40 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 41 + l.Error("failed to decode XRPC response", "err", err) 42 + rp.pages.Error503(w) 43 + return 44 + } 45 + sortBranches(result.Branches) 46 + user := rp.oauth.GetUser(r) 47 + rp.pages.RepoBranches(w, pages.RepoBranchesParams{ 48 + LoggedInUser: user, 49 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 50 + RepoBranchesResponse: result, 51 + }) 52 + } 53 + 54 + func (rp *Repo) DeleteBranch(w http.ResponseWriter, r *http.Request) { 55 + l := rp.logger.With("handler", "DeleteBranch") 56 + f, err := rp.repoResolver.Resolve(r) 57 + if err != nil { 58 + l.Error("failed to get repo and knot", "err", err) 59 + return 60 + } 61 + noticeId := "delete-branch-error" 62 + fail := func(msg string, err error) { 63 + l.Error(msg, "err", err) 64 + rp.pages.Notice(w, noticeId, msg) 65 + } 66 + branch := r.FormValue("branch") 67 + if branch == "" { 68 + fail("No branch provided.", nil) 69 + return 70 + } 71 + client, err := rp.oauth.ServiceClient( 72 + r, 73 + oauth.WithService(f.Knot), 74 + oauth.WithLxm(tangled.RepoDeleteBranchNSID), 75 + oauth.WithDev(rp.config.Core.Dev), 76 + ) 77 + if err != nil { 78 + fail("Failed to connect to knotserver", nil) 79 + return 80 + } 81 + err = tangled.RepoDeleteBranch( 82 + r.Context(), 83 + client, 84 + &tangled.RepoDeleteBranch_Input{ 85 + Branch: branch, 86 + Repo: f.RepoAt().String(), 87 + }, 88 + ) 89 + if err := xrpcclient.HandleXrpcErr(err); err != nil { 90 + fail(fmt.Sprintf("Failed to delete branch: %s", err), err) 91 + return 92 + } 93 + l.Error("deleted branch from knot", "branch", branch, "repo", f.RepoAt()) 94 + rp.pages.HxRefresh(w) 95 + }
+214
appview/repo/compare.go
··· 1 + package repo 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + "net/url" 8 + "strings" 9 + 10 + "tangled.org/core/api/tangled" 11 + "tangled.org/core/appview/pages" 12 + xrpcclient "tangled.org/core/appview/xrpcclient" 13 + "tangled.org/core/patchutil" 14 + "tangled.org/core/types" 15 + 16 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 17 + "github.com/go-chi/chi/v5" 18 + ) 19 + 20 + func (rp *Repo) CompareNew(w http.ResponseWriter, r *http.Request) { 21 + l := rp.logger.With("handler", "RepoCompareNew") 22 + 23 + user := rp.oauth.GetUser(r) 24 + f, err := rp.repoResolver.Resolve(r) 25 + if err != nil { 26 + l.Error("failed to get repo and knot", "err", err) 27 + return 28 + } 29 + 30 + scheme := "http" 31 + if !rp.config.Core.Dev { 32 + scheme = "https" 33 + } 34 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 35 + xrpcc := &indigoxrpc.Client{ 36 + Host: host, 37 + } 38 + 39 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 40 + branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 41 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 42 + l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 43 + rp.pages.Error503(w) 44 + return 45 + } 46 + 47 + var branchResult types.RepoBranchesResponse 48 + if err := json.Unmarshal(branchBytes, &branchResult); err != nil { 49 + l.Error("failed to decode XRPC branches response", "err", err) 50 + rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 51 + return 52 + } 53 + branches := branchResult.Branches 54 + 55 + sortBranches(branches) 56 + 57 + var defaultBranch string 58 + for _, b := range branches { 59 + if b.IsDefault { 60 + defaultBranch = b.Name 61 + } 62 + } 63 + 64 + base := defaultBranch 65 + head := defaultBranch 66 + 67 + params := r.URL.Query() 68 + queryBase := params.Get("base") 69 + queryHead := params.Get("head") 70 + if queryBase != "" { 71 + base = queryBase 72 + } 73 + if queryHead != "" { 74 + head = queryHead 75 + } 76 + 77 + tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 78 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 79 + l.Error("failed to call XRPC repo.tags", "err", xrpcerr) 80 + rp.pages.Error503(w) 81 + return 82 + } 83 + 84 + var tags types.RepoTagsResponse 85 + if err := json.Unmarshal(tagBytes, &tags); err != nil { 86 + l.Error("failed to decode XRPC tags response", "err", err) 87 + rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 88 + return 89 + } 90 + 91 + rp.pages.RepoCompareNew(w, pages.RepoCompareNewParams{ 92 + LoggedInUser: user, 93 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 94 + Branches: branches, 95 + Tags: tags.Tags, 96 + Base: base, 97 + Head: head, 98 + }) 99 + } 100 + 101 + func (rp *Repo) Compare(w http.ResponseWriter, r *http.Request) { 102 + l := rp.logger.With("handler", "RepoCompare") 103 + 104 + user := rp.oauth.GetUser(r) 105 + f, err := rp.repoResolver.Resolve(r) 106 + if err != nil { 107 + l.Error("failed to get repo and knot", "err", err) 108 + return 109 + } 110 + 111 + var diffOpts types.DiffOpts 112 + if d := r.URL.Query().Get("diff"); d == "split" { 113 + diffOpts.Split = true 114 + } 115 + 116 + // if user is navigating to one of 117 + // /compare/{base}...{head} 118 + // /compare/{base}/{head} 119 + var base, head string 120 + rest := chi.URLParam(r, "*") 121 + 122 + var parts []string 123 + if strings.Contains(rest, "...") { 124 + parts = strings.SplitN(rest, "...", 2) 125 + } else if strings.Contains(rest, "/") { 126 + parts = strings.SplitN(rest, "/", 2) 127 + } 128 + 129 + if len(parts) == 2 { 130 + base = parts[0] 131 + head = parts[1] 132 + } 133 + 134 + base, _ = url.PathUnescape(base) 135 + head, _ = url.PathUnescape(head) 136 + 137 + if base == "" || head == "" { 138 + l.Error("invalid comparison") 139 + rp.pages.Error404(w) 140 + return 141 + } 142 + 143 + scheme := "http" 144 + if !rp.config.Core.Dev { 145 + scheme = "https" 146 + } 147 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 148 + xrpcc := &indigoxrpc.Client{ 149 + Host: host, 150 + } 151 + 152 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 153 + 154 + branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 155 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 156 + l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 157 + rp.pages.Error503(w) 158 + return 159 + } 160 + 161 + var branches types.RepoBranchesResponse 162 + if err := json.Unmarshal(branchBytes, &branches); err != nil { 163 + l.Error("failed to decode XRPC branches response", "err", err) 164 + rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 165 + return 166 + } 167 + 168 + tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 169 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 170 + l.Error("failed to call XRPC repo.tags", "err", xrpcerr) 171 + rp.pages.Error503(w) 172 + return 173 + } 174 + 175 + var tags types.RepoTagsResponse 176 + if err := json.Unmarshal(tagBytes, &tags); err != nil { 177 + l.Error("failed to decode XRPC tags response", "err", err) 178 + rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 179 + return 180 + } 181 + 182 + compareBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, base, head) 183 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 184 + l.Error("failed to call XRPC repo.compare", "err", xrpcerr) 185 + rp.pages.Error503(w) 186 + return 187 + } 188 + 189 + var formatPatch types.RepoFormatPatchResponse 190 + if err := json.Unmarshal(compareBytes, &formatPatch); err != nil { 191 + l.Error("failed to decode XRPC compare response", "err", err) 192 + rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 193 + return 194 + } 195 + 196 + var diff types.NiceDiff 197 + if formatPatch.CombinedPatchRaw != "" { 198 + diff = patchutil.AsNiceDiff(formatPatch.CombinedPatchRaw, base) 199 + } else { 200 + diff = patchutil.AsNiceDiff(formatPatch.FormatPatchRaw, base) 201 + } 202 + 203 + rp.pages.RepoCompare(w, pages.RepoCompareParams{ 204 + LoggedInUser: user, 205 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 206 + Branches: branches.Branches, 207 + Tags: tags.Tags, 208 + Base: base, 209 + Head: head, 210 + Diff: &diff, 211 + DiffOpts: diffOpts, 212 + }) 213 + 214 + }
+25 -18
appview/repo/feed.go
··· 11 11 "tangled.org/core/appview/db" 12 12 "tangled.org/core/appview/models" 13 13 "tangled.org/core/appview/pagination" 14 - "tangled.org/core/appview/reporesolver" 14 + "tangled.org/core/orm" 15 15 16 + "github.com/bluesky-social/indigo/atproto/identity" 16 17 "github.com/bluesky-social/indigo/atproto/syntax" 17 18 "github.com/gorilla/feeds" 18 19 ) 19 20 20 - func (rp *Repo) getRepoFeed(ctx context.Context, f *reporesolver.ResolvedRepo) (*feeds.Feed, error) { 21 + func (rp *Repo) getRepoFeed(ctx context.Context, repo *models.Repo, ownerSlashRepo string) (*feeds.Feed, error) { 21 22 const feedLimitPerType = 100 22 23 23 - pulls, err := db.GetPullsWithLimit(rp.db, feedLimitPerType, db.FilterEq("repo_at", f.RepoAt())) 24 + pulls, err := db.GetPullsWithLimit(rp.db, feedLimitPerType, orm.FilterEq("repo_at", repo.RepoAt())) 24 25 if err != nil { 25 26 return nil, err 26 27 } ··· 28 29 issues, err := db.GetIssuesPaginated( 29 30 rp.db, 30 31 pagination.Page{Limit: feedLimitPerType}, 31 - db.FilterEq("repo_at", f.RepoAt()), 32 + orm.FilterEq("repo_at", repo.RepoAt()), 32 33 ) 33 34 if err != nil { 34 35 return nil, err 35 36 } 36 37 37 38 feed := &feeds.Feed{ 38 - Title: fmt.Sprintf("activity feed for %s", f.OwnerSlashRepo()), 39 - Link: &feeds.Link{Href: fmt.Sprintf("%s/%s", rp.config.Core.AppviewHost, f.OwnerSlashRepo()), Type: "text/html", Rel: "alternate"}, 39 + Title: fmt.Sprintf("activity feed for @%s", ownerSlashRepo), 40 + Link: &feeds.Link{Href: fmt.Sprintf("%s/%s", rp.config.Core.AppviewHost, ownerSlashRepo), Type: "text/html", Rel: "alternate"}, 40 41 Items: make([]*feeds.Item, 0), 41 42 Updated: time.UnixMilli(0), 42 43 } 43 44 44 45 for _, pull := range pulls { 45 - items, err := rp.createPullItems(ctx, pull, f) 46 + items, err := rp.createPullItems(ctx, pull, repo, ownerSlashRepo) 46 47 if err != nil { 47 48 return nil, err 48 49 } ··· 50 51 } 51 52 52 53 for _, issue := range issues { 53 - item, err := rp.createIssueItem(ctx, issue, f) 54 + item, err := rp.createIssueItem(ctx, issue, repo, ownerSlashRepo) 54 55 if err != nil { 55 56 return nil, err 56 57 } ··· 71 72 return feed, nil 72 73 } 73 74 74 - func (rp *Repo) createPullItems(ctx context.Context, pull *models.Pull, f *reporesolver.ResolvedRepo) ([]*feeds.Item, error) { 75 + func (rp *Repo) createPullItems(ctx context.Context, pull *models.Pull, repo *models.Repo, ownerSlashRepo string) ([]*feeds.Item, error) { 75 76 owner, err := rp.idResolver.ResolveIdent(ctx, pull.OwnerDid) 76 77 if err != nil { 77 78 return nil, err ··· 80 81 var items []*feeds.Item 81 82 82 83 state := rp.getPullState(pull) 83 - description := rp.buildPullDescription(owner.Handle, state, pull, f.OwnerSlashRepo()) 84 + description := rp.buildPullDescription(owner.Handle, state, pull, ownerSlashRepo) 84 85 85 86 mainItem := &feeds.Item{ 86 87 Title: fmt.Sprintf("[PR #%d] %s", pull.PullId, pull.Title), 87 88 Description: description, 88 - Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/pulls/%d", rp.config.Core.AppviewHost, f.OwnerSlashRepo(), pull.PullId)}, 89 + Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/pulls/%d", rp.config.Core.AppviewHost, ownerSlashRepo, pull.PullId)}, 89 90 Created: pull.Created, 90 91 Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)}, 91 92 } ··· 98 99 99 100 roundItem := &feeds.Item{ 100 101 Title: fmt.Sprintf("[PR #%d] %s (round #%d)", pull.PullId, pull.Title, round.RoundNumber), 101 - Description: fmt.Sprintf("@%s submitted changes (at round #%d) on PR #%d in %s", owner.Handle, round.RoundNumber, pull.PullId, f.OwnerSlashRepo()), 102 - Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/pulls/%d/round/%d/", rp.config.Core.AppviewHost, f.OwnerSlashRepo(), pull.PullId, round.RoundNumber)}, 102 + Description: fmt.Sprintf("@%s submitted changes (at round #%d) on PR #%d in @%s", owner.Handle, round.RoundNumber, pull.PullId, ownerSlashRepo), 103 + Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/pulls/%d/round/%d/", rp.config.Core.AppviewHost, ownerSlashRepo, pull.PullId, round.RoundNumber)}, 103 104 Created: round.Created, 104 105 Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)}, 105 106 } ··· 109 110 return items, nil 110 111 } 111 112 112 - func (rp *Repo) createIssueItem(ctx context.Context, issue models.Issue, f *reporesolver.ResolvedRepo) (*feeds.Item, error) { 113 + func (rp *Repo) createIssueItem(ctx context.Context, issue models.Issue, repo *models.Repo, ownerSlashRepo string) (*feeds.Item, error) { 113 114 owner, err := rp.idResolver.ResolveIdent(ctx, issue.Did) 114 115 if err != nil { 115 116 return nil, err ··· 122 123 123 124 return &feeds.Item{ 124 125 Title: fmt.Sprintf("[Issue #%d] %s", issue.IssueId, issue.Title), 125 - Description: fmt.Sprintf("@%s %s issue #%d in %s", owner.Handle, state, issue.IssueId, f.OwnerSlashRepo()), 126 - Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/issues/%d", rp.config.Core.AppviewHost, f.OwnerSlashRepo(), issue.IssueId)}, 126 + Description: fmt.Sprintf("@%s %s issue #%d in @%s", owner.Handle, state, issue.IssueId, ownerSlashRepo), 127 + Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/issues/%d", rp.config.Core.AppviewHost, ownerSlashRepo, issue.IssueId)}, 127 128 Created: issue.Created, 128 129 Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)}, 129 130 }, nil ··· 146 147 return fmt.Sprintf("%s in %s", base, repoName) 147 148 } 148 149 149 - func (rp *Repo) RepoAtomFeed(w http.ResponseWriter, r *http.Request) { 150 + func (rp *Repo) AtomFeed(w http.ResponseWriter, r *http.Request) { 150 151 f, err := rp.repoResolver.Resolve(r) 151 152 if err != nil { 152 153 log.Println("failed to fully resolve repo:", err) 153 154 return 154 155 } 156 + repoOwnerId, ok := r.Context().Value("resolvedId").(identity.Identity) 157 + if !ok || repoOwnerId.Handle.IsInvalidHandle() { 158 + log.Println("failed to get resolved repo owner id") 159 + return 160 + } 161 + ownerSlashRepo := repoOwnerId.Handle.String() + "/" + f.Name 155 162 156 - feed, err := rp.getRepoFeed(r.Context(), f) 163 + feed, err := rp.getRepoFeed(r.Context(), f, ownerSlashRepo) 157 164 if err != nil { 158 165 log.Println("failed to get repo feed:", err) 159 166 rp.pages.Error500(w)
+42 -41
appview/repo/index.go
··· 3 3 import ( 4 4 "errors" 5 5 "fmt" 6 - "log" 6 + "log/slog" 7 7 "net/http" 8 8 "net/url" 9 9 "slices" ··· 22 22 "tangled.org/core/appview/db" 23 23 "tangled.org/core/appview/models" 24 24 "tangled.org/core/appview/pages" 25 - "tangled.org/core/appview/reporesolver" 26 25 "tangled.org/core/appview/xrpcclient" 26 + "tangled.org/core/orm" 27 27 "tangled.org/core/types" 28 28 29 29 "github.com/go-chi/chi/v5" 30 30 "github.com/go-enry/go-enry/v2" 31 31 ) 32 32 33 - func (rp *Repo) RepoIndex(w http.ResponseWriter, r *http.Request) { 33 + func (rp *Repo) Index(w http.ResponseWriter, r *http.Request) { 34 + l := rp.logger.With("handler", "RepoIndex") 35 + 34 36 ref := chi.URLParam(r, "ref") 35 37 ref, _ = url.PathUnescape(ref) 36 38 37 39 f, err := rp.repoResolver.Resolve(r) 38 40 if err != nil { 39 - log.Println("failed to fully resolve repo", err) 41 + l.Error("failed to fully resolve repo", "err", err) 40 42 return 41 43 } 42 44 ··· 50 52 } 51 53 52 54 user := rp.oauth.GetUser(r) 53 - repoInfo := f.RepoInfo(user) 54 55 55 56 // Build index response from multiple XRPC calls 56 57 result, err := rp.buildIndexResponse(r.Context(), xrpcc, f, ref) 57 58 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 58 59 if errors.Is(xrpcerr, xrpcclient.ErrXrpcUnsupported) { 59 - log.Println("failed to call XRPC repo.index", err) 60 + l.Error("failed to call XRPC repo.index", "err", err) 60 61 rp.pages.RepoIndexPage(w, pages.RepoIndexParams{ 61 62 LoggedInUser: user, 62 63 NeedsKnotUpgrade: true, 63 - RepoInfo: repoInfo, 64 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 64 65 }) 65 66 return 66 67 } 67 68 68 69 rp.pages.Error503(w) 69 - log.Println("failed to build index response", err) 70 + l.Error("failed to build index response", "err", err) 70 71 return 71 72 } 72 73 ··· 119 120 emails := uniqueEmails(commitsTrunc) 120 121 emailToDidMap, err := db.GetEmailToDid(rp.db, emails, true) 121 122 if err != nil { 122 - log.Println("failed to get email to did map", err) 123 + l.Error("failed to get email to did map", "err", err) 123 124 } 124 125 125 - vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, commitsTrunc) 126 + vc, err := commitverify.GetVerifiedCommits(rp.db, emailToDidMap, commitsTrunc) 126 127 if err != nil { 127 - log.Println(err) 128 + l.Error("failed to GetVerifiedObjectCommits", "err", err) 128 129 } 129 130 130 131 // TODO: a bit dirty 131 - languageInfo, err := rp.getLanguageInfo(r.Context(), f, xrpcc, result.Ref, ref == "") 132 + languageInfo, err := rp.getLanguageInfo(r.Context(), l, f, xrpcc, result.Ref, ref == "") 132 133 if err != nil { 133 - log.Printf("failed to compute language percentages: %s", err) 134 + l.Warn("failed to compute language percentages", "err", err) 134 135 // non-fatal 135 136 } 136 137 ··· 138 139 for _, c := range commitsTrunc { 139 140 shas = append(shas, c.Hash.String()) 140 141 } 141 - pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas) 142 + pipelines, err := getPipelineStatuses(rp.db, f, shas) 142 143 if err != nil { 143 - log.Printf("failed to fetch pipeline statuses: %s", err) 144 + l.Error("failed to fetch pipeline statuses", "err", err) 144 145 // non-fatal 145 146 } 146 147 147 148 rp.pages.RepoIndexPage(w, pages.RepoIndexParams{ 148 149 LoggedInUser: user, 149 - RepoInfo: repoInfo, 150 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 150 151 TagMap: tagMap, 151 152 RepoIndexResponse: *result, 152 153 CommitsTrunc: commitsTrunc, 153 154 TagsTrunc: tagsTrunc, 154 155 // ForkInfo: forkInfo, // TODO: reinstate this after xrpc properly lands 155 - BranchesTrunc: branchesTrunc, 156 - EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap), 157 - VerifiedCommits: vc, 158 - Languages: languageInfo, 159 - Pipelines: pipelines, 156 + BranchesTrunc: branchesTrunc, 157 + EmailToDid: emailToDidMap, 158 + VerifiedCommits: vc, 159 + Languages: languageInfo, 160 + Pipelines: pipelines, 160 161 }) 161 162 } 162 163 163 164 func (rp *Repo) getLanguageInfo( 164 165 ctx context.Context, 165 - f *reporesolver.ResolvedRepo, 166 + l *slog.Logger, 167 + repo *models.Repo, 166 168 xrpcc *indigoxrpc.Client, 167 169 currentRef string, 168 170 isDefaultRef bool, ··· 170 172 // first attempt to fetch from db 171 173 langs, err := db.GetRepoLanguages( 172 174 rp.db, 173 - db.FilterEq("repo_at", f.RepoAt()), 174 - db.FilterEq("ref", currentRef), 175 + orm.FilterEq("repo_at", repo.RepoAt()), 176 + orm.FilterEq("ref", currentRef), 175 177 ) 176 178 177 179 if err != nil || langs == nil { 178 180 // non-fatal, fetch langs from ks via XRPC 179 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 180 - ls, err := tangled.RepoLanguages(ctx, xrpcc, currentRef, repo) 181 + didSlashRepo := fmt.Sprintf("%s/%s", repo.Did, repo.Name) 182 + ls, err := tangled.RepoLanguages(ctx, xrpcc, currentRef, didSlashRepo) 181 183 if err != nil { 182 184 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 183 - log.Println("failed to call XRPC repo.languages", xrpcerr) 185 + l.Error("failed to call XRPC repo.languages", "err", xrpcerr) 184 186 return nil, xrpcerr 185 187 } 186 188 return nil, err ··· 192 194 193 195 for _, lang := range ls.Languages { 194 196 langs = append(langs, models.RepoLanguage{ 195 - RepoAt: f.RepoAt(), 197 + RepoAt: repo.RepoAt(), 196 198 Ref: currentRef, 197 199 IsDefaultRef: isDefaultRef, 198 200 Language: lang.Name, ··· 207 209 defer tx.Rollback() 208 210 209 211 // update appview's cache 210 - err = db.UpdateRepoLanguages(tx, f.RepoAt(), currentRef, langs) 212 + err = db.UpdateRepoLanguages(tx, repo.RepoAt(), currentRef, langs) 211 213 if err != nil { 212 214 // non-fatal 213 - log.Println("failed to cache lang results", err) 215 + l.Error("failed to cache lang results", "err", err) 214 216 } 215 217 216 218 err = tx.Commit() ··· 252 254 } 253 255 254 256 // buildIndexResponse creates a RepoIndexResponse by combining multiple xrpc calls in parallel 255 - func (rp *Repo) buildIndexResponse(ctx context.Context, xrpcc *indigoxrpc.Client, f *reporesolver.ResolvedRepo, ref string) (*types.RepoIndexResponse, error) { 256 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 257 + func (rp *Repo) buildIndexResponse(ctx context.Context, xrpcc *indigoxrpc.Client, repo *models.Repo, ref string) (*types.RepoIndexResponse, error) { 258 + didSlashRepo := fmt.Sprintf("%s/%s", repo.Did, repo.Name) 257 259 258 260 // first get branches to determine the ref if not specified 259 - branchesBytes, err := tangled.RepoBranches(ctx, xrpcc, "", 0, repo) 261 + branchesBytes, err := tangled.RepoBranches(ctx, xrpcc, "", 0, didSlashRepo) 260 262 if err != nil { 261 263 return nil, fmt.Errorf("failed to call repoBranches: %w", err) 262 264 } ··· 300 302 wg.Add(1) 301 303 go func() { 302 304 defer wg.Done() 303 - tagsBytes, err := tangled.RepoTags(ctx, xrpcc, "", 0, repo) 305 + tagsBytes, err := tangled.RepoTags(ctx, xrpcc, "", 0, didSlashRepo) 304 306 if err != nil { 305 307 errs = errors.Join(errs, fmt.Errorf("failed to call repoTags: %w", err)) 306 308 return ··· 315 317 wg.Add(1) 316 318 go func() { 317 319 defer wg.Done() 318 - resp, err := tangled.RepoTree(ctx, xrpcc, "", ref, repo) 320 + resp, err := tangled.RepoTree(ctx, xrpcc, "", ref, didSlashRepo) 319 321 if err != nil { 320 322 errs = errors.Join(errs, fmt.Errorf("failed to call repoTree: %w", err)) 321 323 return ··· 327 329 wg.Add(1) 328 330 go func() { 329 331 defer wg.Done() 330 - logBytes, err := tangled.RepoLog(ctx, xrpcc, "", 50, "", ref, repo) 332 + logBytes, err := tangled.RepoLog(ctx, xrpcc, "", 50, "", ref, didSlashRepo) 331 333 if err != nil { 332 334 errs = errors.Join(errs, fmt.Errorf("failed to call repoLog: %w", err)) 333 335 return ··· 348 350 if treeResp != nil && treeResp.Files != nil { 349 351 for _, file := range treeResp.Files { 350 352 niceFile := types.NiceTree{ 351 - IsFile: file.Is_file, 352 - IsSubtree: file.Is_subtree, 353 - Name: file.Name, 354 - Mode: file.Mode, 355 - Size: file.Size, 353 + Name: file.Name, 354 + Mode: file.Mode, 355 + Size: file.Size, 356 356 } 357 + 357 358 if file.Last_commit != nil { 358 359 when, _ := time.Parse(time.RFC3339, file.Last_commit.When) 359 360 niceFile.LastCommit = &types.LastCommitInfo{
+220
appview/repo/log.go
··· 1 + package repo 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + "net/url" 8 + "strconv" 9 + 10 + "tangled.org/core/api/tangled" 11 + "tangled.org/core/appview/commitverify" 12 + "tangled.org/core/appview/db" 13 + "tangled.org/core/appview/models" 14 + "tangled.org/core/appview/pages" 15 + xrpcclient "tangled.org/core/appview/xrpcclient" 16 + "tangled.org/core/types" 17 + 18 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 19 + "github.com/go-chi/chi/v5" 20 + "github.com/go-git/go-git/v5/plumbing" 21 + ) 22 + 23 + func (rp *Repo) Log(w http.ResponseWriter, r *http.Request) { 24 + l := rp.logger.With("handler", "RepoLog") 25 + 26 + f, err := rp.repoResolver.Resolve(r) 27 + if err != nil { 28 + l.Error("failed to fully resolve repo", "err", err) 29 + return 30 + } 31 + 32 + page := 1 33 + if r.URL.Query().Get("page") != "" { 34 + page, err = strconv.Atoi(r.URL.Query().Get("page")) 35 + if err != nil { 36 + page = 1 37 + } 38 + } 39 + 40 + ref := chi.URLParam(r, "ref") 41 + ref, _ = url.PathUnescape(ref) 42 + 43 + scheme := "http" 44 + if !rp.config.Core.Dev { 45 + scheme = "https" 46 + } 47 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 48 + xrpcc := &indigoxrpc.Client{ 49 + Host: host, 50 + } 51 + 52 + limit := int64(60) 53 + cursor := "" 54 + if page > 1 { 55 + // Convert page number to cursor (offset) 56 + offset := (page - 1) * int(limit) 57 + cursor = strconv.Itoa(offset) 58 + } 59 + 60 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 61 + xrpcBytes, err := tangled.RepoLog(r.Context(), xrpcc, cursor, limit, "", ref, repo) 62 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 63 + l.Error("failed to call XRPC repo.log", "err", xrpcerr) 64 + rp.pages.Error503(w) 65 + return 66 + } 67 + 68 + var xrpcResp types.RepoLogResponse 69 + if err := json.Unmarshal(xrpcBytes, &xrpcResp); err != nil { 70 + l.Error("failed to decode XRPC response", "err", err) 71 + rp.pages.Error503(w) 72 + return 73 + } 74 + 75 + tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 76 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 77 + l.Error("failed to call XRPC repo.tags", "err", xrpcerr) 78 + rp.pages.Error503(w) 79 + return 80 + } 81 + 82 + tagMap := make(map[string][]string) 83 + if tagBytes != nil { 84 + var tagResp types.RepoTagsResponse 85 + if err := json.Unmarshal(tagBytes, &tagResp); err == nil { 86 + for _, tag := range tagResp.Tags { 87 + hash := tag.Hash 88 + if tag.Tag != nil { 89 + hash = tag.Tag.Target.String() 90 + } 91 + tagMap[hash] = append(tagMap[hash], tag.Name) 92 + } 93 + } 94 + } 95 + 96 + branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 97 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 98 + l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 99 + rp.pages.Error503(w) 100 + return 101 + } 102 + 103 + if branchBytes != nil { 104 + var branchResp types.RepoBranchesResponse 105 + if err := json.Unmarshal(branchBytes, &branchResp); err == nil { 106 + for _, branch := range branchResp.Branches { 107 + tagMap[branch.Hash] = append(tagMap[branch.Hash], branch.Name) 108 + } 109 + } 110 + } 111 + 112 + user := rp.oauth.GetUser(r) 113 + 114 + emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(xrpcResp.Commits), true) 115 + if err != nil { 116 + l.Error("failed to fetch email to did mapping", "err", err) 117 + } 118 + 119 + vc, err := commitverify.GetVerifiedCommits(rp.db, emailToDidMap, xrpcResp.Commits) 120 + if err != nil { 121 + l.Error("failed to GetVerifiedObjectCommits", "err", err) 122 + } 123 + 124 + var shas []string 125 + for _, c := range xrpcResp.Commits { 126 + shas = append(shas, c.Hash.String()) 127 + } 128 + pipelines, err := getPipelineStatuses(rp.db, f, shas) 129 + if err != nil { 130 + l.Error("failed to getPipelineStatuses", "err", err) 131 + // non-fatal 132 + } 133 + 134 + rp.pages.RepoLog(w, pages.RepoLogParams{ 135 + LoggedInUser: user, 136 + TagMap: tagMap, 137 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 138 + RepoLogResponse: xrpcResp, 139 + EmailToDid: emailToDidMap, 140 + VerifiedCommits: vc, 141 + Pipelines: pipelines, 142 + }) 143 + } 144 + 145 + func (rp *Repo) Commit(w http.ResponseWriter, r *http.Request) { 146 + l := rp.logger.With("handler", "RepoCommit") 147 + 148 + f, err := rp.repoResolver.Resolve(r) 149 + if err != nil { 150 + l.Error("failed to fully resolve repo", "err", err) 151 + return 152 + } 153 + ref := chi.URLParam(r, "ref") 154 + ref, _ = url.PathUnescape(ref) 155 + 156 + var diffOpts types.DiffOpts 157 + if d := r.URL.Query().Get("diff"); d == "split" { 158 + diffOpts.Split = true 159 + } 160 + 161 + if !plumbing.IsHash(ref) { 162 + rp.pages.Error404(w) 163 + return 164 + } 165 + 166 + scheme := "http" 167 + if !rp.config.Core.Dev { 168 + scheme = "https" 169 + } 170 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 171 + xrpcc := &indigoxrpc.Client{ 172 + Host: host, 173 + } 174 + 175 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 176 + xrpcBytes, err := tangled.RepoDiff(r.Context(), xrpcc, ref, repo) 177 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 178 + l.Error("failed to call XRPC repo.diff", "err", xrpcerr) 179 + rp.pages.Error503(w) 180 + return 181 + } 182 + 183 + var result types.RepoCommitResponse 184 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 185 + l.Error("failed to decode XRPC response", "err", err) 186 + rp.pages.Error503(w) 187 + return 188 + } 189 + 190 + emailToDidMap, err := db.GetEmailToDid(rp.db, []string{result.Diff.Commit.Committer.Email, result.Diff.Commit.Author.Email}, true) 191 + if err != nil { 192 + l.Error("failed to get email to did mapping", "err", err) 193 + } 194 + 195 + vc, err := commitverify.GetVerifiedCommits(rp.db, emailToDidMap, []types.Commit{result.Diff.Commit}) 196 + if err != nil { 197 + l.Error("failed to GetVerifiedCommits", "err", err) 198 + } 199 + 200 + user := rp.oauth.GetUser(r) 201 + pipelines, err := getPipelineStatuses(rp.db, f, []string{result.Diff.Commit.This}) 202 + if err != nil { 203 + l.Error("failed to getPipelineStatuses", "err", err) 204 + // non-fatal 205 + } 206 + var pipeline *models.Pipeline 207 + if p, ok := pipelines[result.Diff.Commit.This]; ok { 208 + pipeline = &p 209 + } 210 + 211 + rp.pages.RepoCommit(w, pages.RepoCommitParams{ 212 + LoggedInUser: user, 213 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 214 + RepoCommitResponse: result, 215 + EmailToDid: emailToDidMap, 216 + VerifiedCommit: vc, 217 + Pipeline: pipeline, 218 + DiffOpts: diffOpts, 219 + }) 220 + }
-500
appview/repo/ogcard/card.go
··· 1 - // Copyright 2024 The Forgejo Authors. All rights reserved. 2 - // Copyright 2025 The Tangled Authors -- repurposed for Tangled use. 3 - // SPDX-License-Identifier: MIT 4 - 5 - package ogcard 6 - 7 - import ( 8 - "bytes" 9 - "fmt" 10 - "image" 11 - "image/color" 12 - "io" 13 - "log" 14 - "math" 15 - "net/http" 16 - "strings" 17 - "sync" 18 - "time" 19 - 20 - "github.com/goki/freetype" 21 - "github.com/goki/freetype/truetype" 22 - "github.com/srwiley/oksvg" 23 - "github.com/srwiley/rasterx" 24 - "golang.org/x/image/draw" 25 - "golang.org/x/image/font" 26 - "tangled.org/core/appview/pages" 27 - 28 - _ "golang.org/x/image/webp" // for processing webp images 29 - ) 30 - 31 - type Card struct { 32 - Img *image.RGBA 33 - Font *truetype.Font 34 - Margin int 35 - Width int 36 - Height int 37 - } 38 - 39 - var fontCache = sync.OnceValues(func() (*truetype.Font, error) { 40 - interVar, err := pages.Files.ReadFile("static/fonts/InterVariable.ttf") 41 - if err != nil { 42 - return nil, err 43 - } 44 - return truetype.Parse(interVar) 45 - }) 46 - 47 - // DefaultSize returns the default size for a card 48 - func DefaultSize() (int, int) { 49 - return 1200, 600 50 - } 51 - 52 - // NewCard creates a new card with the given dimensions in pixels 53 - func NewCard(width, height int) (*Card, error) { 54 - img := image.NewRGBA(image.Rect(0, 0, width, height)) 55 - draw.Draw(img, img.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src) 56 - 57 - font, err := fontCache() 58 - if err != nil { 59 - return nil, err 60 - } 61 - 62 - return &Card{ 63 - Img: img, 64 - Font: font, 65 - Margin: 0, 66 - Width: width, 67 - Height: height, 68 - }, nil 69 - } 70 - 71 - // Split splits the card horizontally or vertically by a given percentage; the first card returned has the percentage 72 - // size, and the second card has the remainder. Both cards draw to a subsection of the same image buffer. 73 - func (c *Card) Split(vertical bool, percentage int) (*Card, *Card) { 74 - bounds := c.Img.Bounds() 75 - bounds = image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin) 76 - if vertical { 77 - mid := (bounds.Dx() * percentage / 100) + bounds.Min.X 78 - subleft := c.Img.SubImage(image.Rect(bounds.Min.X, bounds.Min.Y, mid, bounds.Max.Y)).(*image.RGBA) 79 - subright := c.Img.SubImage(image.Rect(mid, bounds.Min.Y, bounds.Max.X, bounds.Max.Y)).(*image.RGBA) 80 - return &Card{Img: subleft, Font: c.Font, Width: subleft.Bounds().Dx(), Height: subleft.Bounds().Dy()}, 81 - &Card{Img: subright, Font: c.Font, Width: subright.Bounds().Dx(), Height: subright.Bounds().Dy()} 82 - } 83 - mid := (bounds.Dy() * percentage / 100) + bounds.Min.Y 84 - subtop := c.Img.SubImage(image.Rect(bounds.Min.X, bounds.Min.Y, bounds.Max.X, mid)).(*image.RGBA) 85 - subbottom := c.Img.SubImage(image.Rect(bounds.Min.X, mid, bounds.Max.X, bounds.Max.Y)).(*image.RGBA) 86 - return &Card{Img: subtop, Font: c.Font, Width: subtop.Bounds().Dx(), Height: subtop.Bounds().Dy()}, 87 - &Card{Img: subbottom, Font: c.Font, Width: subbottom.Bounds().Dx(), Height: subbottom.Bounds().Dy()} 88 - } 89 - 90 - // SetMargin sets the margins for the card 91 - func (c *Card) SetMargin(margin int) { 92 - c.Margin = margin 93 - } 94 - 95 - type ( 96 - VAlign int64 97 - HAlign int64 98 - ) 99 - 100 - const ( 101 - Top VAlign = iota 102 - Middle 103 - Bottom 104 - ) 105 - 106 - const ( 107 - Left HAlign = iota 108 - Center 109 - Right 110 - ) 111 - 112 - // DrawText draws text within the card, respecting margins and alignment 113 - func (c *Card) DrawText(text string, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) ([]string, error) { 114 - ft := freetype.NewContext() 115 - ft.SetDPI(72) 116 - ft.SetFont(c.Font) 117 - ft.SetFontSize(sizePt) 118 - ft.SetClip(c.Img.Bounds()) 119 - ft.SetDst(c.Img) 120 - ft.SetSrc(image.NewUniform(textColor)) 121 - 122 - face := truetype.NewFace(c.Font, &truetype.Options{Size: sizePt, DPI: 72}) 123 - fontHeight := ft.PointToFixed(sizePt).Ceil() 124 - 125 - bounds := c.Img.Bounds() 126 - bounds = image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin) 127 - boxWidth, boxHeight := bounds.Size().X, bounds.Size().Y 128 - // draw.Draw(c.Img, bounds, image.NewUniform(color.Gray{128}), image.Point{}, draw.Src) // Debug draw box 129 - 130 - // Try to apply wrapping to this text; we'll find the most text that will fit into one line, record that line, move 131 - // on. We precalculate each line before drawing so that we can support valign="middle" correctly which requires 132 - // knowing the total height, which is related to how many lines we'll have. 133 - lines := make([]string, 0) 134 - textWords := strings.Split(text, " ") 135 - currentLine := "" 136 - heightTotal := 0 137 - 138 - for { 139 - if len(textWords) == 0 { 140 - // Ran out of words. 141 - if currentLine != "" { 142 - heightTotal += fontHeight 143 - lines = append(lines, currentLine) 144 - } 145 - break 146 - } 147 - 148 - nextWord := textWords[0] 149 - proposedLine := currentLine 150 - if proposedLine != "" { 151 - proposedLine += " " 152 - } 153 - proposedLine += nextWord 154 - 155 - proposedLineWidth := font.MeasureString(face, proposedLine) 156 - if proposedLineWidth.Ceil() > boxWidth { 157 - // no, proposed line is too big; we'll use the last "currentLine" 158 - heightTotal += fontHeight 159 - if currentLine != "" { 160 - lines = append(lines, currentLine) 161 - currentLine = "" 162 - // leave nextWord in textWords and keep going 163 - } else { 164 - // just nextWord by itself doesn't fit on a line; well, we can't skip it, but we'll consume it 165 - // regardless as a line by itself. It will be clipped by the drawing routine. 166 - lines = append(lines, nextWord) 167 - textWords = textWords[1:] 168 - } 169 - } else { 170 - // yes, it will fit 171 - currentLine = proposedLine 172 - textWords = textWords[1:] 173 - } 174 - } 175 - 176 - textY := 0 177 - switch valign { 178 - case Top: 179 - textY = fontHeight 180 - case Bottom: 181 - textY = boxHeight - heightTotal + fontHeight 182 - case Middle: 183 - textY = ((boxHeight - heightTotal) / 2) + fontHeight 184 - } 185 - 186 - for _, line := range lines { 187 - lineWidth := font.MeasureString(face, line) 188 - 189 - textX := 0 190 - switch halign { 191 - case Left: 192 - textX = 0 193 - case Right: 194 - textX = boxWidth - lineWidth.Ceil() 195 - case Center: 196 - textX = (boxWidth - lineWidth.Ceil()) / 2 197 - } 198 - 199 - pt := freetype.Pt(bounds.Min.X+textX, bounds.Min.Y+textY) 200 - _, err := ft.DrawString(line, pt) 201 - if err != nil { 202 - return nil, err 203 - } 204 - 205 - textY += fontHeight 206 - } 207 - 208 - return lines, nil 209 - } 210 - 211 - // DrawTextAt draws text at a specific position with the given alignment 212 - func (c *Card) DrawTextAt(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) error { 213 - _, err := c.DrawTextAtWithWidth(text, x, y, textColor, sizePt, valign, halign) 214 - return err 215 - } 216 - 217 - // DrawTextAtWithWidth draws text at a specific position and returns the text width 218 - func (c *Card) DrawTextAtWithWidth(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) (int, error) { 219 - ft := freetype.NewContext() 220 - ft.SetDPI(72) 221 - ft.SetFont(c.Font) 222 - ft.SetFontSize(sizePt) 223 - ft.SetClip(c.Img.Bounds()) 224 - ft.SetDst(c.Img) 225 - ft.SetSrc(image.NewUniform(textColor)) 226 - 227 - face := truetype.NewFace(c.Font, &truetype.Options{Size: sizePt, DPI: 72}) 228 - fontHeight := ft.PointToFixed(sizePt).Ceil() 229 - lineWidth := font.MeasureString(face, text) 230 - textWidth := lineWidth.Ceil() 231 - 232 - // Adjust position based on alignment 233 - adjustedX := x 234 - adjustedY := y 235 - 236 - switch halign { 237 - case Left: 238 - // x is already at the left position 239 - case Right: 240 - adjustedX = x - textWidth 241 - case Center: 242 - adjustedX = x - textWidth/2 243 - } 244 - 245 - switch valign { 246 - case Top: 247 - adjustedY = y + fontHeight 248 - case Bottom: 249 - adjustedY = y 250 - case Middle: 251 - adjustedY = y + fontHeight/2 252 - } 253 - 254 - pt := freetype.Pt(adjustedX, adjustedY) 255 - _, err := ft.DrawString(text, pt) 256 - return textWidth, err 257 - } 258 - 259 - // DrawBoldText draws bold text by rendering multiple times with slight offsets 260 - func (c *Card) DrawBoldText(text string, x, y int, textColor color.Color, sizePt float64, valign VAlign, halign HAlign) (int, error) { 261 - // Draw the text multiple times with slight offsets to create bold effect 262 - offsets := []struct{ dx, dy int }{ 263 - {0, 0}, // original 264 - {1, 0}, // right 265 - {0, 1}, // down 266 - {1, 1}, // diagonal 267 - } 268 - 269 - var width int 270 - for _, offset := range offsets { 271 - w, err := c.DrawTextAtWithWidth(text, x+offset.dx, y+offset.dy, textColor, sizePt, valign, halign) 272 - if err != nil { 273 - return 0, err 274 - } 275 - if width == 0 { 276 - width = w 277 - } 278 - } 279 - return width, nil 280 - } 281 - 282 - // DrawSVGIcon draws an SVG icon from the embedded files at the specified position 283 - func (c *Card) DrawSVGIcon(svgPath string, x, y, size int, iconColor color.Color) error { 284 - svgData, err := pages.Files.ReadFile(svgPath) 285 - if err != nil { 286 - return fmt.Errorf("failed to read SVG file %s: %w", svgPath, err) 287 - } 288 - 289 - // Convert color to hex string for SVG 290 - rgba, isRGBA := iconColor.(color.RGBA) 291 - if !isRGBA { 292 - r, g, b, a := iconColor.RGBA() 293 - rgba = color.RGBA{uint8(r >> 8), uint8(g >> 8), uint8(b >> 8), uint8(a >> 8)} 294 - } 295 - colorHex := fmt.Sprintf("#%02x%02x%02x", rgba.R, rgba.G, rgba.B) 296 - 297 - // Replace currentColor with our desired color in the SVG 298 - svgString := string(svgData) 299 - svgString = strings.ReplaceAll(svgString, "currentColor", colorHex) 300 - 301 - // Make the stroke thicker 302 - svgString = strings.ReplaceAll(svgString, `stroke-width="2"`, `stroke-width="3"`) 303 - 304 - // Parse SVG 305 - icon, err := oksvg.ReadIconStream(strings.NewReader(svgString)) 306 - if err != nil { 307 - return fmt.Errorf("failed to parse SVG %s: %w", svgPath, err) 308 - } 309 - 310 - // Set the icon size 311 - w, h := float64(size), float64(size) 312 - icon.SetTarget(0, 0, w, h) 313 - 314 - // Create a temporary RGBA image for the icon 315 - iconImg := image.NewRGBA(image.Rect(0, 0, size, size)) 316 - 317 - // Create scanner and rasterizer 318 - scanner := rasterx.NewScannerGV(size, size, iconImg, iconImg.Bounds()) 319 - raster := rasterx.NewDasher(size, size, scanner) 320 - 321 - // Draw the icon 322 - icon.Draw(raster, 1.0) 323 - 324 - // Draw the icon onto the card at the specified position 325 - bounds := c.Img.Bounds() 326 - destRect := image.Rect(x, y, x+size, y+size) 327 - 328 - // Make sure we don't draw outside the card bounds 329 - if destRect.Max.X > bounds.Max.X { 330 - destRect.Max.X = bounds.Max.X 331 - } 332 - if destRect.Max.Y > bounds.Max.Y { 333 - destRect.Max.Y = bounds.Max.Y 334 - } 335 - 336 - draw.Draw(c.Img, destRect, iconImg, image.Point{}, draw.Over) 337 - 338 - return nil 339 - } 340 - 341 - // DrawImage fills the card with an image, scaled to maintain the original aspect ratio and centered with respect to the non-filled dimension 342 - func (c *Card) DrawImage(img image.Image) { 343 - bounds := c.Img.Bounds() 344 - targetRect := image.Rect(bounds.Min.X+c.Margin, bounds.Min.Y+c.Margin, bounds.Max.X-c.Margin, bounds.Max.Y-c.Margin) 345 - srcBounds := img.Bounds() 346 - srcAspect := float64(srcBounds.Dx()) / float64(srcBounds.Dy()) 347 - targetAspect := float64(targetRect.Dx()) / float64(targetRect.Dy()) 348 - 349 - var scale float64 350 - if srcAspect > targetAspect { 351 - // Image is wider than target, scale by width 352 - scale = float64(targetRect.Dx()) / float64(srcBounds.Dx()) 353 - } else { 354 - // Image is taller or equal, scale by height 355 - scale = float64(targetRect.Dy()) / float64(srcBounds.Dy()) 356 - } 357 - 358 - newWidth := int(math.Round(float64(srcBounds.Dx()) * scale)) 359 - newHeight := int(math.Round(float64(srcBounds.Dy()) * scale)) 360 - 361 - // Center the image within the target rectangle 362 - offsetX := (targetRect.Dx() - newWidth) / 2 363 - offsetY := (targetRect.Dy() - newHeight) / 2 364 - 365 - scaledRect := image.Rect(targetRect.Min.X+offsetX, targetRect.Min.Y+offsetY, targetRect.Min.X+offsetX+newWidth, targetRect.Min.Y+offsetY+newHeight) 366 - draw.CatmullRom.Scale(c.Img, scaledRect, img, srcBounds, draw.Over, nil) 367 - } 368 - 369 - func fallbackImage() image.Image { 370 - // can't usage image.Uniform(color.White) because it's infinitely sized causing a panic in the scaler in DrawImage 371 - img := image.NewRGBA(image.Rect(0, 0, 1, 1)) 372 - img.Set(0, 0, color.White) 373 - return img 374 - } 375 - 376 - // As defensively as possible, attempt to load an image from a presumed external and untrusted URL 377 - func (c *Card) fetchExternalImage(url string) (image.Image, bool) { 378 - // Use a short timeout; in the event of any failure we'll be logging and returning a placeholder, but we don't want 379 - // this rendering process to be slowed down 380 - client := &http.Client{ 381 - Timeout: 1 * time.Second, // 1 second timeout 382 - } 383 - 384 - resp, err := client.Get(url) 385 - if err != nil { 386 - log.Printf("error when fetching external image from %s: %v", url, err) 387 - return nil, false 388 - } 389 - defer resp.Body.Close() 390 - 391 - if resp.StatusCode != http.StatusOK { 392 - log.Printf("non-OK error code when fetching external image from %s: %s", url, resp.Status) 393 - return nil, false 394 - } 395 - 396 - contentType := resp.Header.Get("Content-Type") 397 - // Support content types are in-sync with the allowed custom avatar file types 398 - if contentType != "image/png" && contentType != "image/jpeg" && contentType != "image/gif" && contentType != "image/webp" { 399 - log.Printf("fetching external image returned unsupported Content-Type which was ignored: %s", contentType) 400 - return nil, false 401 - } 402 - 403 - body := resp.Body 404 - bodyBytes, err := io.ReadAll(body) 405 - if err != nil { 406 - log.Printf("error when fetching external image from %s: %v", url, err) 407 - return nil, false 408 - } 409 - 410 - bodyBuffer := bytes.NewReader(bodyBytes) 411 - _, imgType, err := image.DecodeConfig(bodyBuffer) 412 - if err != nil { 413 - log.Printf("error when decoding external image from %s: %v", url, err) 414 - return nil, false 415 - } 416 - 417 - // Verify that we have a match between actual data understood in the image body and the reported Content-Type 418 - if (contentType == "image/png" && imgType != "png") || 419 - (contentType == "image/jpeg" && imgType != "jpeg") || 420 - (contentType == "image/gif" && imgType != "gif") || 421 - (contentType == "image/webp" && imgType != "webp") { 422 - log.Printf("while fetching external image, mismatched image body (%s) and Content-Type (%s)", imgType, contentType) 423 - return nil, false 424 - } 425 - 426 - _, err = bodyBuffer.Seek(0, io.SeekStart) // reset for actual decode 427 - if err != nil { 428 - log.Printf("error w/ bodyBuffer.Seek") 429 - return nil, false 430 - } 431 - img, _, err := image.Decode(bodyBuffer) 432 - if err != nil { 433 - log.Printf("error when decoding external image from %s: %v", url, err) 434 - return nil, false 435 - } 436 - 437 - return img, true 438 - } 439 - 440 - func (c *Card) DrawExternalImage(url string) { 441 - image, ok := c.fetchExternalImage(url) 442 - if !ok { 443 - image = fallbackImage() 444 - } 445 - c.DrawImage(image) 446 - } 447 - 448 - // DrawCircularExternalImage draws an external image as a circle at the specified position 449 - func (c *Card) DrawCircularExternalImage(url string, x, y, size int) error { 450 - img, ok := c.fetchExternalImage(url) 451 - if !ok { 452 - img = fallbackImage() 453 - } 454 - 455 - // Create a circular mask 456 - circle := image.NewRGBA(image.Rect(0, 0, size, size)) 457 - center := size / 2 458 - radius := float64(size / 2) 459 - 460 - // Scale the source image to fit the circle 461 - srcBounds := img.Bounds() 462 - scaledImg := image.NewRGBA(image.Rect(0, 0, size, size)) 463 - draw.CatmullRom.Scale(scaledImg, scaledImg.Bounds(), img, srcBounds, draw.Src, nil) 464 - 465 - // Draw the image with circular clipping 466 - for cy := 0; cy < size; cy++ { 467 - for cx := 0; cx < size; cx++ { 468 - // Calculate distance from center 469 - dx := float64(cx - center) 470 - dy := float64(cy - center) 471 - distance := math.Sqrt(dx*dx + dy*dy) 472 - 473 - // Only draw pixels within the circle 474 - if distance <= radius { 475 - circle.Set(cx, cy, scaledImg.At(cx, cy)) 476 - } 477 - } 478 - } 479 - 480 - // Draw the circle onto the card 481 - bounds := c.Img.Bounds() 482 - destRect := image.Rect(x, y, x+size, y+size) 483 - 484 - // Make sure we don't draw outside the card bounds 485 - if destRect.Max.X > bounds.Max.X { 486 - destRect.Max.X = bounds.Max.X 487 - } 488 - if destRect.Max.Y > bounds.Max.Y { 489 - destRect.Max.Y = bounds.Max.Y 490 - } 491 - 492 - draw.Draw(c.Img, destRect, circle, image.Point{}, draw.Over) 493 - 494 - return nil 495 - } 496 - 497 - // DrawRect draws a rect with the given color 498 - func (c *Card) DrawRect(startX, startY, endX, endY int, color color.Color) { 499 - draw.Draw(c.Img, image.Rect(startX, startY, endX, endY), &image.Uniform{color}, image.Point{}, draw.Src) 500 - }
+65 -38
appview/repo/opengraph.go
··· 15 15 "github.com/go-enry/go-enry/v2" 16 16 "tangled.org/core/appview/db" 17 17 "tangled.org/core/appview/models" 18 - "tangled.org/core/appview/repo/ogcard" 18 + "tangled.org/core/appview/ogcard" 19 + "tangled.org/core/orm" 19 20 "tangled.org/core/types" 20 21 ) 21 22 ··· 30 31 contentCard, bottomArea := mainCard.Split(false, 75) 31 32 32 33 // Add padding to content 33 - contentCard.SetMargin(30) 34 + contentCard.SetMargin(50) 34 35 35 36 // Split content horizontally: main content (80%) and avatar area (20%) 36 37 mainContent, avatarArea := contentCard.Split(true, 80) 37 38 38 - // Split main content: 50% for name/description, 50% for spacing 39 - topSection, _ := mainContent.Split(false, 50) 40 - 41 - // Split top section: 40% for repo name, 60% for description 42 - repoNameCard, descriptionCard := topSection.Split(false, 50) 39 + // Use main content area for both repo name and description to allow dynamic wrapping. 40 + mainContent.SetMargin(10) 43 41 44 - // Draw repo name with owner in regular and repo name in bold 45 - repoNameCard.SetMargin(10) 46 42 var ownerHandle string 47 43 owner, err := rp.idResolver.ResolveIdent(context.Background(), repo.Did) 48 44 if err != nil { ··· 51 47 ownerHandle = "@" + owner.Handle.String() 52 48 } 53 49 54 - // Draw repo name with wrapping support 55 - repoNameCard.SetMargin(10) 56 - bounds := repoNameCard.Img.Bounds() 57 - startX := bounds.Min.X + repoNameCard.Margin 58 - startY := bounds.Min.Y + repoNameCard.Margin 50 + bounds := mainContent.Img.Bounds() 51 + startX := bounds.Min.X + mainContent.Margin 52 + startY := bounds.Min.Y + mainContent.Margin 59 53 currentX := startX 54 + currentY := startY 55 + lineHeight := 64 // Font size 54 + padding 60 56 textColor := color.RGBA{88, 96, 105, 255} 61 57 62 - // Draw owner handle in gray 63 - ownerWidth, err := repoNameCard.DrawTextAtWithWidth(ownerHandle, currentX, startY, textColor, 54, ogcard.Top, ogcard.Left) 58 + // Draw owner handle 59 + ownerWidth, err := mainContent.DrawTextAtWithWidth(ownerHandle, currentX, currentY, textColor, 54, ogcard.Top, ogcard.Left) 64 60 if err != nil { 65 61 return nil, err 66 62 } 67 63 currentX += ownerWidth 68 64 69 65 // Draw separator 70 - sepWidth, err := repoNameCard.DrawTextAtWithWidth(" / ", currentX, startY, textColor, 54, ogcard.Top, ogcard.Left) 66 + sepWidth, err := mainContent.DrawTextAtWithWidth(" / ", currentX, currentY, textColor, 54, ogcard.Top, ogcard.Left) 71 67 if err != nil { 72 68 return nil, err 73 69 } 74 70 currentX += sepWidth 75 71 76 - // Draw repo name in bold 77 - _, err = repoNameCard.DrawBoldText(repo.Name, currentX, startY, color.Black, 54, ogcard.Top, ogcard.Left) 78 - if err != nil { 79 - return nil, err 72 + words := strings.Fields(repo.Name) 73 + spaceWidth, _ := mainContent.DrawTextAtWithWidth(" ", -1000, -1000, color.Black, 54, ogcard.Top, ogcard.Left) 74 + if spaceWidth == 0 { 75 + spaceWidth = 15 80 76 } 81 77 82 - // Draw description (DrawText handles multi-line wrapping automatically) 83 - descriptionCard.SetMargin(10) 84 - description := repo.Description 85 - if len(description) > 80 { 86 - description = description[:100] + "โ€ฆ" 78 + for _, word := range words { 79 + // estimate bold width by measuring regular width and adding a multiplier 80 + regularWidth, _ := mainContent.DrawTextAtWithWidth(word, -1000, -1000, color.Black, 54, ogcard.Top, ogcard.Left) 81 + estimatedBoldWidth := int(float64(regularWidth) * 1.15) // Heuristic for bold text 82 + 83 + if currentX+estimatedBoldWidth > (bounds.Max.X - mainContent.Margin) { 84 + currentX = startX 85 + currentY += lineHeight 86 + } 87 + 88 + _, err := mainContent.DrawBoldText(word, currentX, currentY, color.Black, 54, ogcard.Top, ogcard.Left) 89 + if err != nil { 90 + return nil, err 91 + } 92 + currentX += estimatedBoldWidth + spaceWidth 87 93 } 88 94 89 - _, err = descriptionCard.DrawText(description, color.RGBA{88, 96, 105, 255}, 36, ogcard.Top, ogcard.Left) 90 - if err != nil { 91 - log.Printf("failed to draw description: %v", err) 92 - return nil, err 95 + // update Y position for the description 96 + currentY += lineHeight 97 + 98 + // draw description 99 + if currentY < bounds.Max.Y-mainContent.Margin { 100 + totalHeight := float64(bounds.Dy()) 101 + repoNameHeight := float64(currentY - bounds.Min.Y) 102 + 103 + if totalHeight > 0 && repoNameHeight < totalHeight { 104 + repoNamePercent := (repoNameHeight / totalHeight) * 100 105 + if repoNamePercent < 95 { // Ensure there's space left for description 106 + _, descriptionCard := mainContent.Split(false, int(repoNamePercent)) 107 + descriptionCard.SetMargin(8) 108 + 109 + description := repo.Description 110 + if len(description) > 70 { 111 + description = description[:70] + "โ€ฆ" 112 + } 113 + 114 + _, err = descriptionCard.DrawText(description, color.RGBA{88, 96, 105, 255}, 36, ogcard.Top, ogcard.Left) 115 + if err != nil { 116 + log.Printf("failed to draw description: %v", err) 117 + } 118 + } 119 + } 93 120 } 94 121 95 122 // Draw avatar circle on the right side ··· 132 159 // Draw star icon, count, and label 133 160 // Align icon baseline with text baseline 134 161 iconBaselineOffset := int(textSize) / 2 135 - err = statsArea.DrawSVGIcon("static/icons/star.svg", currentX, statsY+iconBaselineOffset-iconSize/2, iconSize, iconColor) 162 + err = statsArea.DrawLucideIcon("star", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) 136 163 if err != nil { 137 164 log.Printf("failed to draw star icon: %v", err) 138 165 } ··· 159 186 160 187 // Draw issues icon, count, and label 161 188 issueStartX := currentX 162 - err = statsArea.DrawSVGIcon("static/icons/circle-dot.svg", currentX, statsY+iconBaselineOffset-iconSize/2, iconSize, iconColor) 189 + err = statsArea.DrawLucideIcon("circle-dot", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) 163 190 if err != nil { 164 191 log.Printf("failed to draw circle-dot icon: %v", err) 165 192 } ··· 184 211 185 212 // Draw pull request icon, count, and label 186 213 prStartX := currentX 187 - err = statsArea.DrawSVGIcon("static/icons/git-pull-request.svg", currentX, statsY+iconBaselineOffset-iconSize/2, iconSize, iconColor) 214 + err = statsArea.DrawLucideIcon("git-pull-request", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) 188 215 if err != nil { 189 216 log.Printf("failed to draw git-pull-request icon: %v", err) 190 217 } ··· 210 237 dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2) 211 238 dollyY := statsY + iconBaselineOffset - dollySize/2 + 25 212 239 dollyColor := color.RGBA{180, 180, 180, 255} // light gray 213 - err = dollyArea.DrawSVGIcon("templates/fragments/dolly/silhouette.svg", dollyX, dollyY, dollySize, dollyColor) 240 + err = dollyArea.DrawDollySilhouette(dollyX, dollyY, dollySize, dollyColor) 214 241 if err != nil { 215 242 log.Printf("dolly silhouette not available (this is ok): %v", err) 216 243 } ··· 301 328 return nil 302 329 } 303 330 304 - func (rp *Repo) RepoOpenGraphSummary(w http.ResponseWriter, r *http.Request) { 331 + func (rp *Repo) Opengraph(w http.ResponseWriter, r *http.Request) { 305 332 f, err := rp.repoResolver.Resolve(r) 306 333 if err != nil { 307 334 log.Println("failed to get repo and knot", err) ··· 312 339 var languageStats []types.RepoLanguageDetails 313 340 langs, err := db.GetRepoLanguages( 314 341 rp.db, 315 - db.FilterEq("repo_at", f.RepoAt()), 316 - db.FilterEq("is_default_ref", 1), 342 + orm.FilterEq("repo_at", f.RepoAt()), 343 + orm.FilterEq("is_default_ref", 1), 317 344 ) 318 345 if err != nil { 319 346 log.Printf("failed to get language stats from db: %v", err) ··· 348 375 }) 349 376 } 350 377 351 - card, err := rp.drawRepoSummaryCard(&f.Repo, languageStats) 378 + card, err := rp.drawRepoSummaryCard(f, languageStats) 352 379 if err != nil { 353 380 log.Println("failed to draw repo summary card", err) 354 381 http.Error(w, "failed to draw repo summary card", http.StatusInternalServerError)
+67 -1347
appview/repo/repo.go
··· 3 3 import ( 4 4 "context" 5 5 "database/sql" 6 - "encoding/json" 7 6 "errors" 8 7 "fmt" 9 - "io" 10 - "log" 11 8 "log/slog" 12 9 "net/http" 13 10 "net/url" 14 - "path/filepath" 15 11 "slices" 16 - "strconv" 17 12 "strings" 18 13 "time" 19 14 20 15 "tangled.org/core/api/tangled" 21 - "tangled.org/core/appview/commitverify" 22 16 "tangled.org/core/appview/config" 23 17 "tangled.org/core/appview/db" 24 18 "tangled.org/core/appview/models" 25 19 "tangled.org/core/appview/notify" 26 20 "tangled.org/core/appview/oauth" 27 21 "tangled.org/core/appview/pages" 28 - "tangled.org/core/appview/pages/markup" 29 22 "tangled.org/core/appview/reporesolver" 30 23 "tangled.org/core/appview/validator" 31 24 xrpcclient "tangled.org/core/appview/xrpcclient" 32 25 "tangled.org/core/eventconsumer" 33 26 "tangled.org/core/idresolver" 34 - "tangled.org/core/patchutil" 27 + "tangled.org/core/orm" 35 28 "tangled.org/core/rbac" 36 29 "tangled.org/core/tid" 37 - "tangled.org/core/types" 38 30 "tangled.org/core/xrpc/serviceauth" 39 31 40 32 comatproto "github.com/bluesky-social/indigo/api/atproto" 41 33 atpclient "github.com/bluesky-social/indigo/atproto/client" 42 34 "github.com/bluesky-social/indigo/atproto/syntax" 43 35 lexutil "github.com/bluesky-social/indigo/lex/util" 44 - indigoxrpc "github.com/bluesky-social/indigo/xrpc" 45 36 securejoin "github.com/cyphar/filepath-securejoin" 46 37 "github.com/go-chi/chi/v5" 47 - "github.com/go-git/go-git/v5/plumbing" 48 38 ) 49 39 50 40 type Repo struct { ··· 89 79 } 90 80 } 91 81 92 - func (rp *Repo) DownloadArchive(w http.ResponseWriter, r *http.Request) { 93 - ref := chi.URLParam(r, "ref") 94 - ref, _ = url.PathUnescape(ref) 95 - 96 - f, err := rp.repoResolver.Resolve(r) 97 - if err != nil { 98 - log.Println("failed to get repo and knot", err) 99 - return 100 - } 101 - 102 - scheme := "http" 103 - if !rp.config.Core.Dev { 104 - scheme = "https" 105 - } 106 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 107 - xrpcc := &indigoxrpc.Client{ 108 - Host: host, 109 - } 110 - 111 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 112 - archiveBytes, err := tangled.RepoArchive(r.Context(), xrpcc, "tar.gz", "", ref, repo) 113 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 114 - log.Println("failed to call XRPC repo.archive", xrpcerr) 115 - rp.pages.Error503(w) 116 - return 117 - } 118 - 119 - // Set headers for file download, just pass along whatever the knot specifies 120 - safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(ref).Short(), "/", "-") 121 - filename := fmt.Sprintf("%s-%s.tar.gz", f.Name, safeRefFilename) 122 - w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) 123 - w.Header().Set("Content-Type", "application/gzip") 124 - w.Header().Set("Content-Length", fmt.Sprintf("%d", len(archiveBytes))) 125 - 126 - // Write the archive data directly 127 - w.Write(archiveBytes) 128 - } 129 - 130 - func (rp *Repo) RepoLog(w http.ResponseWriter, r *http.Request) { 131 - f, err := rp.repoResolver.Resolve(r) 132 - if err != nil { 133 - log.Println("failed to fully resolve repo", err) 134 - return 135 - } 136 - 137 - page := 1 138 - if r.URL.Query().Get("page") != "" { 139 - page, err = strconv.Atoi(r.URL.Query().Get("page")) 140 - if err != nil { 141 - page = 1 142 - } 143 - } 144 - 145 - ref := chi.URLParam(r, "ref") 146 - ref, _ = url.PathUnescape(ref) 147 - 148 - scheme := "http" 149 - if !rp.config.Core.Dev { 150 - scheme = "https" 151 - } 152 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 153 - xrpcc := &indigoxrpc.Client{ 154 - Host: host, 155 - } 156 - 157 - limit := int64(60) 158 - cursor := "" 159 - if page > 1 { 160 - // Convert page number to cursor (offset) 161 - offset := (page - 1) * int(limit) 162 - cursor = strconv.Itoa(offset) 163 - } 164 - 165 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 166 - xrpcBytes, err := tangled.RepoLog(r.Context(), xrpcc, cursor, limit, "", ref, repo) 167 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 168 - log.Println("failed to call XRPC repo.log", xrpcerr) 169 - rp.pages.Error503(w) 170 - return 171 - } 172 - 173 - var xrpcResp types.RepoLogResponse 174 - if err := json.Unmarshal(xrpcBytes, &xrpcResp); err != nil { 175 - log.Println("failed to decode XRPC response", err) 176 - rp.pages.Error503(w) 177 - return 178 - } 179 - 180 - tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 181 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 182 - log.Println("failed to call XRPC repo.tags", xrpcerr) 183 - rp.pages.Error503(w) 184 - return 185 - } 186 - 187 - tagMap := make(map[string][]string) 188 - if tagBytes != nil { 189 - var tagResp types.RepoTagsResponse 190 - if err := json.Unmarshal(tagBytes, &tagResp); err == nil { 191 - for _, tag := range tagResp.Tags { 192 - tagMap[tag.Hash] = append(tagMap[tag.Hash], tag.Name) 193 - } 194 - } 195 - } 196 - 197 - branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 198 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 199 - log.Println("failed to call XRPC repo.branches", xrpcerr) 200 - rp.pages.Error503(w) 201 - return 202 - } 203 - 204 - if branchBytes != nil { 205 - var branchResp types.RepoBranchesResponse 206 - if err := json.Unmarshal(branchBytes, &branchResp); err == nil { 207 - for _, branch := range branchResp.Branches { 208 - tagMap[branch.Hash] = append(tagMap[branch.Hash], branch.Name) 209 - } 210 - } 211 - } 212 - 213 - user := rp.oauth.GetUser(r) 214 - 215 - emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(xrpcResp.Commits), true) 216 - if err != nil { 217 - log.Println("failed to fetch email to did mapping", err) 218 - } 219 - 220 - vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, xrpcResp.Commits) 221 - if err != nil { 222 - log.Println(err) 223 - } 224 - 225 - repoInfo := f.RepoInfo(user) 226 - 227 - var shas []string 228 - for _, c := range xrpcResp.Commits { 229 - shas = append(shas, c.Hash.String()) 230 - } 231 - pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas) 232 - if err != nil { 233 - log.Println(err) 234 - // non-fatal 235 - } 236 - 237 - rp.pages.RepoLog(w, pages.RepoLogParams{ 238 - LoggedInUser: user, 239 - TagMap: tagMap, 240 - RepoInfo: repoInfo, 241 - RepoLogResponse: xrpcResp, 242 - EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap), 243 - VerifiedCommits: vc, 244 - Pipelines: pipelines, 245 - }) 246 - } 247 - 248 - func (rp *Repo) RepoDescriptionEdit(w http.ResponseWriter, r *http.Request) { 249 - f, err := rp.repoResolver.Resolve(r) 250 - if err != nil { 251 - log.Println("failed to get repo and knot", err) 252 - w.WriteHeader(http.StatusBadRequest) 253 - return 254 - } 255 - 256 - user := rp.oauth.GetUser(r) 257 - rp.pages.EditRepoDescriptionFragment(w, pages.RepoDescriptionParams{ 258 - RepoInfo: f.RepoInfo(user), 259 - }) 260 - } 261 - 262 - func (rp *Repo) RepoDescription(w http.ResponseWriter, r *http.Request) { 263 - f, err := rp.repoResolver.Resolve(r) 264 - if err != nil { 265 - log.Println("failed to get repo and knot", err) 266 - w.WriteHeader(http.StatusBadRequest) 267 - return 268 - } 269 - 270 - repoAt := f.RepoAt() 271 - rkey := repoAt.RecordKey().String() 272 - if rkey == "" { 273 - log.Println("invalid aturi for repo", err) 274 - w.WriteHeader(http.StatusInternalServerError) 275 - return 276 - } 277 - 278 - user := rp.oauth.GetUser(r) 279 - 280 - switch r.Method { 281 - case http.MethodGet: 282 - rp.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{ 283 - RepoInfo: f.RepoInfo(user), 284 - }) 285 - return 286 - case http.MethodPut: 287 - newDescription := r.FormValue("description") 288 - client, err := rp.oauth.AuthorizedClient(r) 289 - if err != nil { 290 - log.Println("failed to get client") 291 - rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 292 - return 293 - } 294 - 295 - // optimistic update 296 - err = db.UpdateDescription(rp.db, string(repoAt), newDescription) 297 - if err != nil { 298 - log.Println("failed to perferom update-description query", err) 299 - rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 300 - return 301 - } 302 - 303 - newRepo := f.Repo 304 - newRepo.Description = newDescription 305 - record := newRepo.AsRecord() 306 - 307 - // this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field 308 - // 309 - // SwapRecord is optional and should happen automagically, but given that it does not, we have to perform two requests 310 - ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 311 - if err != nil { 312 - // failed to get record 313 - rp.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.") 314 - return 315 - } 316 - _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 317 - Collection: tangled.RepoNSID, 318 - Repo: newRepo.Did, 319 - Rkey: newRepo.Rkey, 320 - SwapRecord: ex.Cid, 321 - Record: &lexutil.LexiconTypeDecoder{ 322 - Val: &record, 323 - }, 324 - }) 325 - 326 - if err != nil { 327 - log.Println("failed to perferom update-description query", err) 328 - // failed to get record 329 - rp.pages.Notice(w, "repo-notice", "Failed to update description, unable to save to PDS.") 330 - return 331 - } 332 - 333 - newRepoInfo := f.RepoInfo(user) 334 - newRepoInfo.Description = newDescription 335 - 336 - rp.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{ 337 - RepoInfo: newRepoInfo, 338 - }) 339 - return 340 - } 341 - } 342 - 343 - func (rp *Repo) RepoCommit(w http.ResponseWriter, r *http.Request) { 344 - f, err := rp.repoResolver.Resolve(r) 345 - if err != nil { 346 - log.Println("failed to fully resolve repo", err) 347 - return 348 - } 349 - ref := chi.URLParam(r, "ref") 350 - ref, _ = url.PathUnescape(ref) 351 - 352 - var diffOpts types.DiffOpts 353 - if d := r.URL.Query().Get("diff"); d == "split" { 354 - diffOpts.Split = true 355 - } 356 - 357 - if !plumbing.IsHash(ref) { 358 - rp.pages.Error404(w) 359 - return 360 - } 361 - 362 - scheme := "http" 363 - if !rp.config.Core.Dev { 364 - scheme = "https" 365 - } 366 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 367 - xrpcc := &indigoxrpc.Client{ 368 - Host: host, 369 - } 370 - 371 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 372 - xrpcBytes, err := tangled.RepoDiff(r.Context(), xrpcc, ref, repo) 373 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 374 - log.Println("failed to call XRPC repo.diff", xrpcerr) 375 - rp.pages.Error503(w) 376 - return 377 - } 378 - 379 - var result types.RepoCommitResponse 380 - if err := json.Unmarshal(xrpcBytes, &result); err != nil { 381 - log.Println("failed to decode XRPC response", err) 382 - rp.pages.Error503(w) 383 - return 384 - } 385 - 386 - emailToDidMap, err := db.GetEmailToDid(rp.db, []string{result.Diff.Commit.Committer.Email, result.Diff.Commit.Author.Email}, true) 387 - if err != nil { 388 - log.Println("failed to get email to did mapping:", err) 389 - } 390 - 391 - vc, err := commitverify.GetVerifiedCommits(rp.db, emailToDidMap, []types.NiceDiff{*result.Diff}) 392 - if err != nil { 393 - log.Println(err) 394 - } 395 - 396 - user := rp.oauth.GetUser(r) 397 - repoInfo := f.RepoInfo(user) 398 - pipelines, err := getPipelineStatuses(rp.db, repoInfo, []string{result.Diff.Commit.This}) 399 - if err != nil { 400 - log.Println(err) 401 - // non-fatal 402 - } 403 - var pipeline *models.Pipeline 404 - if p, ok := pipelines[result.Diff.Commit.This]; ok { 405 - pipeline = &p 406 - } 407 - 408 - rp.pages.RepoCommit(w, pages.RepoCommitParams{ 409 - LoggedInUser: user, 410 - RepoInfo: f.RepoInfo(user), 411 - RepoCommitResponse: result, 412 - EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap), 413 - VerifiedCommit: vc, 414 - Pipeline: pipeline, 415 - DiffOpts: diffOpts, 416 - }) 417 - } 418 - 419 - func (rp *Repo) RepoTree(w http.ResponseWriter, r *http.Request) { 420 - f, err := rp.repoResolver.Resolve(r) 421 - if err != nil { 422 - log.Println("failed to fully resolve repo", err) 423 - return 424 - } 425 - 426 - ref := chi.URLParam(r, "ref") 427 - ref, _ = url.PathUnescape(ref) 428 - 429 - // if the tree path has a trailing slash, let's strip it 430 - // so we don't 404 431 - treePath := chi.URLParam(r, "*") 432 - treePath, _ = url.PathUnescape(treePath) 433 - treePath = strings.TrimSuffix(treePath, "/") 434 - 435 - scheme := "http" 436 - if !rp.config.Core.Dev { 437 - scheme = "https" 438 - } 439 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 440 - xrpcc := &indigoxrpc.Client{ 441 - Host: host, 442 - } 443 - 444 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 445 - xrpcResp, err := tangled.RepoTree(r.Context(), xrpcc, treePath, ref, repo) 446 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 447 - log.Println("failed to call XRPC repo.tree", xrpcerr) 448 - rp.pages.Error503(w) 449 - return 450 - } 451 - 452 - // Convert XRPC response to internal types.RepoTreeResponse 453 - files := make([]types.NiceTree, len(xrpcResp.Files)) 454 - for i, xrpcFile := range xrpcResp.Files { 455 - file := types.NiceTree{ 456 - Name: xrpcFile.Name, 457 - Mode: xrpcFile.Mode, 458 - Size: int64(xrpcFile.Size), 459 - IsFile: xrpcFile.Is_file, 460 - IsSubtree: xrpcFile.Is_subtree, 461 - } 462 - 463 - // Convert last commit info if present 464 - if xrpcFile.Last_commit != nil { 465 - commitWhen, _ := time.Parse(time.RFC3339, xrpcFile.Last_commit.When) 466 - file.LastCommit = &types.LastCommitInfo{ 467 - Hash: plumbing.NewHash(xrpcFile.Last_commit.Hash), 468 - Message: xrpcFile.Last_commit.Message, 469 - When: commitWhen, 470 - } 471 - } 472 - 473 - files[i] = file 474 - } 475 - 476 - result := types.RepoTreeResponse{ 477 - Ref: xrpcResp.Ref, 478 - Files: files, 479 - } 480 - 481 - if xrpcResp.Parent != nil { 482 - result.Parent = *xrpcResp.Parent 483 - } 484 - if xrpcResp.Dotdot != nil { 485 - result.DotDot = *xrpcResp.Dotdot 486 - } 487 - if xrpcResp.Readme != nil { 488 - result.ReadmeFileName = xrpcResp.Readme.Filename 489 - result.Readme = xrpcResp.Readme.Contents 490 - } 491 - 492 - // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated, 493 - // so we can safely redirect to the "parent" (which is the same file). 494 - if len(result.Files) == 0 && result.Parent == treePath { 495 - redirectTo := fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), url.PathEscape(ref), result.Parent) 496 - http.Redirect(w, r, redirectTo, http.StatusFound) 497 - return 498 - } 499 - 500 - user := rp.oauth.GetUser(r) 501 - 502 - var breadcrumbs [][]string 503 - breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))}) 504 - if treePath != "" { 505 - for idx, elem := range strings.Split(treePath, "/") { 506 - breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))}) 507 - } 508 - } 509 - 510 - sortFiles(result.Files) 511 - 512 - rp.pages.RepoTree(w, pages.RepoTreeParams{ 513 - LoggedInUser: user, 514 - BreadCrumbs: breadcrumbs, 515 - TreePath: treePath, 516 - RepoInfo: f.RepoInfo(user), 517 - RepoTreeResponse: result, 518 - }) 519 - } 520 - 521 - func (rp *Repo) RepoTags(w http.ResponseWriter, r *http.Request) { 522 - f, err := rp.repoResolver.Resolve(r) 523 - if err != nil { 524 - log.Println("failed to get repo and knot", err) 525 - return 526 - } 527 - 528 - scheme := "http" 529 - if !rp.config.Core.Dev { 530 - scheme = "https" 531 - } 532 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 533 - xrpcc := &indigoxrpc.Client{ 534 - Host: host, 535 - } 536 - 537 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 538 - xrpcBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 539 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 540 - log.Println("failed to call XRPC repo.tags", xrpcerr) 541 - rp.pages.Error503(w) 542 - return 543 - } 544 - 545 - var result types.RepoTagsResponse 546 - if err := json.Unmarshal(xrpcBytes, &result); err != nil { 547 - log.Println("failed to decode XRPC response", err) 548 - rp.pages.Error503(w) 549 - return 550 - } 551 - 552 - artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt())) 553 - if err != nil { 554 - log.Println("failed grab artifacts", err) 555 - return 556 - } 557 - 558 - // convert artifacts to map for easy UI building 559 - artifactMap := make(map[plumbing.Hash][]models.Artifact) 560 - for _, a := range artifacts { 561 - artifactMap[a.Tag] = append(artifactMap[a.Tag], a) 562 - } 563 - 564 - var danglingArtifacts []models.Artifact 565 - for _, a := range artifacts { 566 - found := false 567 - for _, t := range result.Tags { 568 - if t.Tag != nil { 569 - if t.Tag.Hash == a.Tag { 570 - found = true 571 - } 572 - } 573 - } 574 - 575 - if !found { 576 - danglingArtifacts = append(danglingArtifacts, a) 577 - } 578 - } 579 - 580 - user := rp.oauth.GetUser(r) 581 - rp.pages.RepoTags(w, pages.RepoTagsParams{ 582 - LoggedInUser: user, 583 - RepoInfo: f.RepoInfo(user), 584 - RepoTagsResponse: result, 585 - ArtifactMap: artifactMap, 586 - DanglingArtifacts: danglingArtifacts, 587 - }) 588 - } 589 - 590 - func (rp *Repo) RepoBranches(w http.ResponseWriter, r *http.Request) { 591 - f, err := rp.repoResolver.Resolve(r) 592 - if err != nil { 593 - log.Println("failed to get repo and knot", err) 594 - return 595 - } 596 - 597 - scheme := "http" 598 - if !rp.config.Core.Dev { 599 - scheme = "https" 600 - } 601 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 602 - xrpcc := &indigoxrpc.Client{ 603 - Host: host, 604 - } 605 - 606 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 607 - xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 608 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 609 - log.Println("failed to call XRPC repo.branches", xrpcerr) 610 - rp.pages.Error503(w) 611 - return 612 - } 613 - 614 - var result types.RepoBranchesResponse 615 - if err := json.Unmarshal(xrpcBytes, &result); err != nil { 616 - log.Println("failed to decode XRPC response", err) 617 - rp.pages.Error503(w) 618 - return 619 - } 620 - 621 - sortBranches(result.Branches) 622 - 623 - user := rp.oauth.GetUser(r) 624 - rp.pages.RepoBranches(w, pages.RepoBranchesParams{ 625 - LoggedInUser: user, 626 - RepoInfo: f.RepoInfo(user), 627 - RepoBranchesResponse: result, 628 - }) 629 - } 630 - 631 - func (rp *Repo) RepoBlob(w http.ResponseWriter, r *http.Request) { 632 - f, err := rp.repoResolver.Resolve(r) 633 - if err != nil { 634 - log.Println("failed to get repo and knot", err) 635 - return 636 - } 637 - 638 - ref := chi.URLParam(r, "ref") 639 - ref, _ = url.PathUnescape(ref) 640 - 641 - filePath := chi.URLParam(r, "*") 642 - filePath, _ = url.PathUnescape(filePath) 643 - 644 - scheme := "http" 645 - if !rp.config.Core.Dev { 646 - scheme = "https" 647 - } 648 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 649 - xrpcc := &indigoxrpc.Client{ 650 - Host: host, 651 - } 652 - 653 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name) 654 - resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, repo) 655 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 656 - log.Println("failed to call XRPC repo.blob", xrpcerr) 657 - rp.pages.Error503(w) 658 - return 659 - } 660 - 661 - // Use XRPC response directly instead of converting to internal types 662 - 663 - var breadcrumbs [][]string 664 - breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))}) 665 - if filePath != "" { 666 - for idx, elem := range strings.Split(filePath, "/") { 667 - breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))}) 668 - } 669 - } 670 - 671 - showRendered := false 672 - renderToggle := false 673 - 674 - if markup.GetFormat(resp.Path) == markup.FormatMarkdown { 675 - renderToggle = true 676 - showRendered = r.URL.Query().Get("code") != "true" 677 - } 678 - 679 - var unsupported bool 680 - var isImage bool 681 - var isVideo bool 682 - var contentSrc string 683 - 684 - if resp.IsBinary != nil && *resp.IsBinary { 685 - ext := strings.ToLower(filepath.Ext(resp.Path)) 686 - switch ext { 687 - case ".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp": 688 - isImage = true 689 - case ".mp4", ".webm", ".ogg", ".mov", ".avi": 690 - isVideo = true 691 - default: 692 - unsupported = true 693 - } 694 - 695 - // fetch the raw binary content using sh.tangled.repo.blob xrpc 696 - repoName := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 697 - 698 - baseURL := &url.URL{ 699 - Scheme: scheme, 700 - Host: f.Knot, 701 - Path: "/xrpc/sh.tangled.repo.blob", 702 - } 703 - query := baseURL.Query() 704 - query.Set("repo", repoName) 705 - query.Set("ref", ref) 706 - query.Set("path", filePath) 707 - query.Set("raw", "true") 708 - baseURL.RawQuery = query.Encode() 709 - blobURL := baseURL.String() 710 - 711 - contentSrc = blobURL 712 - if !rp.config.Core.Dev { 713 - contentSrc = markup.GenerateCamoURL(rp.config.Camo.Host, rp.config.Camo.SharedSecret, blobURL) 714 - } 715 - } 716 - 717 - lines := 0 718 - if resp.IsBinary == nil || !*resp.IsBinary { 719 - lines = strings.Count(resp.Content, "\n") + 1 720 - } 721 - 722 - var sizeHint uint64 723 - if resp.Size != nil { 724 - sizeHint = uint64(*resp.Size) 725 - } else { 726 - sizeHint = uint64(len(resp.Content)) 727 - } 728 - 729 - user := rp.oauth.GetUser(r) 730 - 731 - // Determine if content is binary (dereference pointer) 732 - isBinary := false 733 - if resp.IsBinary != nil { 734 - isBinary = *resp.IsBinary 735 - } 736 - 737 - rp.pages.RepoBlob(w, pages.RepoBlobParams{ 738 - LoggedInUser: user, 739 - RepoInfo: f.RepoInfo(user), 740 - BreadCrumbs: breadcrumbs, 741 - ShowRendered: showRendered, 742 - RenderToggle: renderToggle, 743 - Unsupported: unsupported, 744 - IsImage: isImage, 745 - IsVideo: isVideo, 746 - ContentSrc: contentSrc, 747 - RepoBlob_Output: resp, 748 - Contents: resp.Content, 749 - Lines: lines, 750 - SizeHint: sizeHint, 751 - IsBinary: isBinary, 752 - }) 753 - } 754 - 755 - func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) { 756 - f, err := rp.repoResolver.Resolve(r) 757 - if err != nil { 758 - log.Println("failed to get repo and knot", err) 759 - w.WriteHeader(http.StatusBadRequest) 760 - return 761 - } 762 - 763 - ref := chi.URLParam(r, "ref") 764 - ref, _ = url.PathUnescape(ref) 765 - 766 - filePath := chi.URLParam(r, "*") 767 - filePath, _ = url.PathUnescape(filePath) 768 - 769 - scheme := "http" 770 - if !rp.config.Core.Dev { 771 - scheme = "https" 772 - } 773 - 774 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name) 775 - baseURL := &url.URL{ 776 - Scheme: scheme, 777 - Host: f.Knot, 778 - Path: "/xrpc/sh.tangled.repo.blob", 779 - } 780 - query := baseURL.Query() 781 - query.Set("repo", repo) 782 - query.Set("ref", ref) 783 - query.Set("path", filePath) 784 - query.Set("raw", "true") 785 - baseURL.RawQuery = query.Encode() 786 - blobURL := baseURL.String() 787 - 788 - req, err := http.NewRequest("GET", blobURL, nil) 789 - if err != nil { 790 - log.Println("failed to create request", err) 791 - return 792 - } 793 - 794 - // forward the If-None-Match header 795 - if clientETag := r.Header.Get("If-None-Match"); clientETag != "" { 796 - req.Header.Set("If-None-Match", clientETag) 797 - } 798 - 799 - client := &http.Client{} 800 - resp, err := client.Do(req) 801 - if err != nil { 802 - log.Println("failed to reach knotserver", err) 803 - rp.pages.Error503(w) 804 - return 805 - } 806 - defer resp.Body.Close() 807 - 808 - // forward 304 not modified 809 - if resp.StatusCode == http.StatusNotModified { 810 - w.WriteHeader(http.StatusNotModified) 811 - return 812 - } 813 - 814 - if resp.StatusCode != http.StatusOK { 815 - log.Printf("knotserver returned non-OK status for raw blob %s: %d", blobURL, resp.StatusCode) 816 - w.WriteHeader(resp.StatusCode) 817 - _, _ = io.Copy(w, resp.Body) 818 - return 819 - } 820 - 821 - contentType := resp.Header.Get("Content-Type") 822 - body, err := io.ReadAll(resp.Body) 823 - if err != nil { 824 - log.Printf("error reading response body from knotserver: %v", err) 825 - w.WriteHeader(http.StatusInternalServerError) 826 - return 827 - } 828 - 829 - if strings.HasPrefix(contentType, "text/") || isTextualMimeType(contentType) { 830 - // serve all textual content as text/plain 831 - w.Header().Set("Content-Type", "text/plain; charset=utf-8") 832 - w.Write(body) 833 - } else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") { 834 - // serve images and videos with their original content type 835 - w.Header().Set("Content-Type", contentType) 836 - w.Write(body) 837 - } else { 838 - w.WriteHeader(http.StatusUnsupportedMediaType) 839 - w.Write([]byte("unsupported content type")) 840 - return 841 - } 842 - } 843 - 844 - // isTextualMimeType returns true if the MIME type represents textual content 845 - // that should be served as text/plain 846 - func isTextualMimeType(mimeType string) bool { 847 - textualTypes := []string{ 848 - "application/json", 849 - "application/xml", 850 - "application/yaml", 851 - "application/x-yaml", 852 - "application/toml", 853 - "application/javascript", 854 - "application/ecmascript", 855 - "message/", 856 - } 857 - 858 - return slices.Contains(textualTypes, mimeType) 859 - } 860 - 861 82 // modify the spindle configured for this repo 862 83 func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) { 863 84 user := rp.oauth.GetUser(r) ··· 898 119 } 899 120 } 900 121 901 - newRepo := f.Repo 122 + newRepo := *f 902 123 newRepo.Spindle = newSpindle 903 124 record := newRepo.AsRecord() 904 125 ··· 1037 258 l.Info("wrote label record to PDS") 1038 259 1039 260 // update the repo to subscribe to this label 1040 - newRepo := f.Repo 261 + newRepo := *f 1041 262 newRepo.Labels = append(newRepo.Labels, aturi) 1042 263 repoRecord := newRepo.AsRecord() 1043 264 ··· 1125 346 // get form values 1126 347 labelId := r.FormValue("label-id") 1127 348 1128 - label, err := db.GetLabelDefinition(rp.db, db.FilterEq("id", labelId)) 349 + label, err := db.GetLabelDefinition(rp.db, orm.FilterEq("id", labelId)) 1129 350 if err != nil { 1130 351 fail("Failed to find label definition.", err) 1131 352 return ··· 1149 370 } 1150 371 1151 372 // update repo record to remove the label reference 1152 - newRepo := f.Repo 373 + newRepo := *f 1153 374 var updated []string 1154 375 removedAt := label.AtUri().String() 1155 376 for _, l := range newRepo.Labels { ··· 1189 410 1190 411 err = db.UnsubscribeLabel( 1191 412 tx, 1192 - db.FilterEq("repo_at", f.RepoAt()), 1193 - db.FilterEq("label_at", removedAt), 413 + orm.FilterEq("repo_at", f.RepoAt()), 414 + orm.FilterEq("label_at", removedAt), 1194 415 ) 1195 416 if err != nil { 1196 417 fail("Failed to unsubscribe label.", err) 1197 418 return 1198 419 } 1199 420 1200 - err = db.DeleteLabelDefinition(tx, db.FilterEq("id", label.Id)) 421 + err = db.DeleteLabelDefinition(tx, orm.FilterEq("id", label.Id)) 1201 422 if err != nil { 1202 423 fail("Failed to delete label definition.", err) 1203 424 return ··· 1236 457 } 1237 458 1238 459 labelAts := r.Form["label"] 1239 - _, err = db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", labelAts)) 460 + _, err = db.GetLabelDefinitions(rp.db, orm.FilterIn("at_uri", labelAts)) 1240 461 if err != nil { 1241 462 fail("Failed to subscribe to label.", err) 1242 463 return 1243 464 } 1244 465 1245 - newRepo := f.Repo 466 + newRepo := *f 1246 467 newRepo.Labels = append(newRepo.Labels, labelAts...) 1247 468 1248 469 // dedup ··· 1257 478 return 1258 479 } 1259 480 1260 - ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey) 481 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Did, f.Rkey) 1261 482 if err != nil { 1262 483 fail("Failed to update labels, no record found on PDS.", err) 1263 484 return ··· 1322 543 } 1323 544 1324 545 labelAts := r.Form["label"] 1325 - _, err = db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", labelAts)) 546 + _, err = db.GetLabelDefinitions(rp.db, orm.FilterIn("at_uri", labelAts)) 1326 547 if err != nil { 1327 548 fail("Failed to unsubscribe to label.", err) 1328 549 return 1329 550 } 1330 551 1331 552 // update repo record to remove the label reference 1332 - newRepo := f.Repo 553 + newRepo := *f 1333 554 var updated []string 1334 555 for _, l := range newRepo.Labels { 1335 556 if !slices.Contains(labelAts, l) { ··· 1345 566 return 1346 567 } 1347 568 1348 - ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey) 569 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Did, f.Rkey) 1349 570 if err != nil { 1350 571 fail("Failed to update labels, no record found on PDS.", err) 1351 572 return ··· 1362 583 1363 584 err = db.UnsubscribeLabel( 1364 585 rp.db, 1365 - db.FilterEq("repo_at", f.RepoAt()), 1366 - db.FilterIn("label_at", labelAts), 586 + orm.FilterEq("repo_at", f.RepoAt()), 587 + orm.FilterIn("label_at", labelAts), 1367 588 ) 1368 589 if err != nil { 1369 590 fail("Failed to unsubscribe label.", err) ··· 1392 613 1393 614 labelDefs, err := db.GetLabelDefinitions( 1394 615 rp.db, 1395 - db.FilterIn("at_uri", f.Repo.Labels), 1396 - db.FilterContains("scope", subject.Collection().String()), 616 + orm.FilterIn("at_uri", f.Labels), 617 + orm.FilterContains("scope", subject.Collection().String()), 1397 618 ) 1398 619 if err != nil { 1399 - log.Println("failed to fetch label defs", err) 620 + l.Error("failed to fetch label defs", "err", err) 1400 621 return 1401 622 } 1402 623 ··· 1405 626 defs[l.AtUri().String()] = &l 1406 627 } 1407 628 1408 - states, err := db.GetLabels(rp.db, db.FilterEq("subject", subject)) 629 + states, err := db.GetLabels(rp.db, orm.FilterEq("subject", subject)) 1409 630 if err != nil { 1410 - log.Println("failed to build label state", err) 631 + l.Error("failed to build label state", "err", err) 1411 632 return 1412 633 } 1413 634 state := states[subject] ··· 1415 636 user := rp.oauth.GetUser(r) 1416 637 rp.pages.LabelPanel(w, pages.LabelPanelParams{ 1417 638 LoggedInUser: user, 1418 - RepoInfo: f.RepoInfo(user), 639 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 1419 640 Defs: defs, 1420 641 Subject: subject.String(), 1421 642 State: state, ··· 1440 661 1441 662 labelDefs, err := db.GetLabelDefinitions( 1442 663 rp.db, 1443 - db.FilterIn("at_uri", f.Repo.Labels), 1444 - db.FilterContains("scope", subject.Collection().String()), 664 + orm.FilterIn("at_uri", f.Labels), 665 + orm.FilterContains("scope", subject.Collection().String()), 1445 666 ) 1446 667 if err != nil { 1447 - log.Println("failed to fetch labels", err) 668 + l.Error("failed to fetch labels", "err", err) 1448 669 return 1449 670 } 1450 671 ··· 1453 674 defs[l.AtUri().String()] = &l 1454 675 } 1455 676 1456 - states, err := db.GetLabels(rp.db, db.FilterEq("subject", subject)) 677 + states, err := db.GetLabels(rp.db, orm.FilterEq("subject", subject)) 1457 678 if err != nil { 1458 - log.Println("failed to build label state", err) 679 + l.Error("failed to build label state", "err", err) 1459 680 return 1460 681 } 1461 682 state := states[subject] ··· 1463 684 user := rp.oauth.GetUser(r) 1464 685 rp.pages.EditLabelPanel(w, pages.EditLabelPanelParams{ 1465 686 LoggedInUser: user, 1466 - RepoInfo: f.RepoInfo(user), 687 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 1467 688 Defs: defs, 1468 689 Subject: subject.String(), 1469 690 State: state, ··· 1602 823 1603 824 func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) { 1604 825 user := rp.oauth.GetUser(r) 826 + l := rp.logger.With("handler", "DeleteRepo") 1605 827 1606 828 noticeId := "operation-error" 1607 829 f, err := rp.repoResolver.Resolve(r) 1608 830 if err != nil { 1609 - log.Println("failed to get repo and knot", err) 831 + l.Error("failed to get repo and knot", "err", err) 1610 832 return 1611 833 } 1612 834 1613 835 // remove record from pds 1614 836 atpClient, err := rp.oauth.AuthorizedClient(r) 1615 837 if err != nil { 1616 - log.Println("failed to get authorized client", err) 838 + l.Error("failed to get authorized client", "err", err) 1617 839 return 1618 840 } 1619 841 _, err = comatproto.RepoDeleteRecord(r.Context(), atpClient, &comatproto.RepoDeleteRecord_Input{ ··· 1622 844 Rkey: f.Rkey, 1623 845 }) 1624 846 if err != nil { 1625 - log.Printf("failed to delete record: %s", err) 847 + l.Error("failed to delete record", "err", err) 1626 848 rp.pages.Notice(w, noticeId, "Failed to delete repository from PDS.") 1627 849 return 1628 850 } 1629 - log.Println("removed repo record ", f.RepoAt().String()) 851 + l.Info("removed repo record", "aturi", f.RepoAt().String()) 1630 852 1631 853 client, err := rp.oauth.ServiceClient( 1632 854 r, ··· 1635 857 oauth.WithDev(rp.config.Core.Dev), 1636 858 ) 1637 859 if err != nil { 1638 - log.Println("failed to connect to knot server:", err) 860 + l.Error("failed to connect to knot server", "err", err) 1639 861 return 1640 862 } 1641 863 ··· 1643 865 r.Context(), 1644 866 client, 1645 867 &tangled.RepoDelete_Input{ 1646 - Did: f.OwnerDid(), 868 + Did: f.Did, 1647 869 Name: f.Name, 1648 870 Rkey: f.Rkey, 1649 871 }, ··· 1652 874 rp.pages.Notice(w, noticeId, err.Error()) 1653 875 return 1654 876 } 1655 - log.Println("deleted repo from knot") 877 + l.Info("deleted repo from knot") 1656 878 1657 879 tx, err := rp.db.BeginTx(r.Context(), nil) 1658 880 if err != nil { 1659 - log.Println("failed to start tx") 881 + l.Error("failed to start tx") 1660 882 w.Write(fmt.Append(nil, "failed to add collaborator: ", err)) 1661 883 return 1662 884 } ··· 1664 886 tx.Rollback() 1665 887 err = rp.enforcer.E.LoadPolicy() 1666 888 if err != nil { 1667 - log.Println("failed to rollback policies") 889 + l.Error("failed to rollback policies") 1668 890 } 1669 891 }() 1670 892 ··· 1678 900 did := c[0] 1679 901 rp.enforcer.RemoveCollaborator(did, f.Knot, f.DidSlashRepo()) 1680 902 } 1681 - log.Println("removed collaborators") 903 + l.Info("removed collaborators") 1682 904 1683 905 // remove repo RBAC 1684 - err = rp.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo()) 906 + err = rp.enforcer.RemoveRepo(f.Did, f.Knot, f.DidSlashRepo()) 1685 907 if err != nil { 1686 908 rp.pages.Notice(w, noticeId, "Failed to update RBAC rules") 1687 909 return 1688 910 } 1689 911 1690 912 // remove repo from db 1691 - err = db.RemoveRepo(tx, f.OwnerDid(), f.Name) 913 + err = db.RemoveRepo(tx, f.Did, f.Name) 1692 914 if err != nil { 1693 915 rp.pages.Notice(w, noticeId, "Failed to update appview") 1694 916 return 1695 917 } 1696 - log.Println("removed repo from db") 918 + l.Info("removed repo from db") 1697 919 1698 920 err = tx.Commit() 1699 921 if err != nil { 1700 - log.Println("failed to commit changes", err) 922 + l.Error("failed to commit changes", "err", err) 1701 923 http.Error(w, err.Error(), http.StatusInternalServerError) 1702 924 return 1703 925 } 1704 926 1705 927 err = rp.enforcer.E.SavePolicy() 1706 928 if err != nil { 1707 - log.Println("failed to update ACLs", err) 929 + l.Error("failed to update ACLs", "err", err) 1708 930 http.Error(w, err.Error(), http.StatusInternalServerError) 1709 931 return 1710 932 } 1711 933 1712 - rp.pages.HxRedirect(w, fmt.Sprintf("/%s", f.OwnerDid())) 1713 - } 1714 - 1715 - func (rp *Repo) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { 1716 - f, err := rp.repoResolver.Resolve(r) 1717 - if err != nil { 1718 - log.Println("failed to get repo and knot", err) 1719 - return 1720 - } 1721 - 1722 - noticeId := "operation-error" 1723 - branch := r.FormValue("branch") 1724 - if branch == "" { 1725 - http.Error(w, "malformed form", http.StatusBadRequest) 1726 - return 1727 - } 1728 - 1729 - client, err := rp.oauth.ServiceClient( 1730 - r, 1731 - oauth.WithService(f.Knot), 1732 - oauth.WithLxm(tangled.RepoSetDefaultBranchNSID), 1733 - oauth.WithDev(rp.config.Core.Dev), 1734 - ) 1735 - if err != nil { 1736 - log.Println("failed to connect to knot server:", err) 1737 - rp.pages.Notice(w, noticeId, "Failed to connect to knot server.") 1738 - return 1739 - } 1740 - 1741 - xe := tangled.RepoSetDefaultBranch( 1742 - r.Context(), 1743 - client, 1744 - &tangled.RepoSetDefaultBranch_Input{ 1745 - Repo: f.RepoAt().String(), 1746 - DefaultBranch: branch, 1747 - }, 1748 - ) 1749 - if err := xrpcclient.HandleXrpcErr(xe); err != nil { 1750 - log.Println("xrpc failed", "err", xe) 1751 - rp.pages.Notice(w, noticeId, err.Error()) 1752 - return 1753 - } 1754 - 1755 - rp.pages.HxRefresh(w) 934 + rp.pages.HxRedirect(w, fmt.Sprintf("/%s", f.Did)) 1756 935 } 1757 936 1758 - func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) { 1759 - user := rp.oauth.GetUser(r) 1760 - l := rp.logger.With("handler", "Secrets") 1761 - l = l.With("did", user.Did) 1762 - 1763 - f, err := rp.repoResolver.Resolve(r) 1764 - if err != nil { 1765 - log.Println("failed to get repo and knot", err) 1766 - return 1767 - } 1768 - 1769 - if f.Spindle == "" { 1770 - log.Println("empty spindle cannot add/rm secret", err) 1771 - return 1772 - } 1773 - 1774 - lxm := tangled.RepoAddSecretNSID 1775 - if r.Method == http.MethodDelete { 1776 - lxm = tangled.RepoRemoveSecretNSID 1777 - } 1778 - 1779 - spindleClient, err := rp.oauth.ServiceClient( 1780 - r, 1781 - oauth.WithService(f.Spindle), 1782 - oauth.WithLxm(lxm), 1783 - oauth.WithExp(60), 1784 - oauth.WithDev(rp.config.Core.Dev), 1785 - ) 1786 - if err != nil { 1787 - log.Println("failed to create spindle client", err) 1788 - return 1789 - } 1790 - 1791 - key := r.FormValue("key") 1792 - if key == "" { 1793 - w.WriteHeader(http.StatusBadRequest) 1794 - return 1795 - } 1796 - 1797 - switch r.Method { 1798 - case http.MethodPut: 1799 - errorId := "add-secret-error" 1800 - 1801 - value := r.FormValue("value") 1802 - if value == "" { 1803 - w.WriteHeader(http.StatusBadRequest) 1804 - return 1805 - } 1806 - 1807 - err = tangled.RepoAddSecret( 1808 - r.Context(), 1809 - spindleClient, 1810 - &tangled.RepoAddSecret_Input{ 1811 - Repo: f.RepoAt().String(), 1812 - Key: key, 1813 - Value: value, 1814 - }, 1815 - ) 1816 - if err != nil { 1817 - l.Error("Failed to add secret.", "err", err) 1818 - rp.pages.Notice(w, errorId, "Failed to add secret.") 1819 - return 1820 - } 1821 - 1822 - case http.MethodDelete: 1823 - errorId := "operation-error" 1824 - 1825 - err = tangled.RepoRemoveSecret( 1826 - r.Context(), 1827 - spindleClient, 1828 - &tangled.RepoRemoveSecret_Input{ 1829 - Repo: f.RepoAt().String(), 1830 - Key: key, 1831 - }, 1832 - ) 1833 - if err != nil { 1834 - l.Error("Failed to delete secret.", "err", err) 1835 - rp.pages.Notice(w, errorId, "Failed to delete secret.") 1836 - return 1837 - } 1838 - } 1839 - 1840 - rp.pages.HxRefresh(w) 1841 - } 1842 - 1843 - type tab = map[string]any 1844 - 1845 - var ( 1846 - // would be great to have ordered maps right about now 1847 - settingsTabs []tab = []tab{ 1848 - {"Name": "general", "Icon": "sliders-horizontal"}, 1849 - {"Name": "access", "Icon": "users"}, 1850 - {"Name": "pipelines", "Icon": "layers-2"}, 1851 - } 1852 - ) 1853 - 1854 - func (rp *Repo) RepoSettings(w http.ResponseWriter, r *http.Request) { 1855 - tabVal := r.URL.Query().Get("tab") 1856 - if tabVal == "" { 1857 - tabVal = "general" 1858 - } 1859 - 1860 - switch tabVal { 1861 - case "general": 1862 - rp.generalSettings(w, r) 1863 - 1864 - case "access": 1865 - rp.accessSettings(w, r) 1866 - 1867 - case "pipelines": 1868 - rp.pipelineSettings(w, r) 1869 - } 1870 - } 1871 - 1872 - func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) { 1873 - f, err := rp.repoResolver.Resolve(r) 1874 - user := rp.oauth.GetUser(r) 1875 - 1876 - scheme := "http" 1877 - if !rp.config.Core.Dev { 1878 - scheme = "https" 1879 - } 1880 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 1881 - xrpcc := &indigoxrpc.Client{ 1882 - Host: host, 1883 - } 1884 - 1885 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1886 - xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 1887 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1888 - log.Println("failed to call XRPC repo.branches", xrpcerr) 1889 - rp.pages.Error503(w) 1890 - return 1891 - } 1892 - 1893 - var result types.RepoBranchesResponse 1894 - if err := json.Unmarshal(xrpcBytes, &result); err != nil { 1895 - log.Println("failed to decode XRPC response", err) 1896 - rp.pages.Error503(w) 1897 - return 1898 - } 1899 - 1900 - defaultLabels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", models.DefaultLabelDefs())) 1901 - if err != nil { 1902 - log.Println("failed to fetch labels", err) 1903 - rp.pages.Error503(w) 1904 - return 1905 - } 937 + func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) { 938 + l := rp.logger.With("handler", "SyncRepoFork") 1906 939 1907 - labels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Repo.Labels)) 1908 - if err != nil { 1909 - log.Println("failed to fetch labels", err) 1910 - rp.pages.Error503(w) 1911 - return 1912 - } 1913 - // remove default labels from the labels list, if present 1914 - defaultLabelMap := make(map[string]bool) 1915 - for _, dl := range defaultLabels { 1916 - defaultLabelMap[dl.AtUri().String()] = true 1917 - } 1918 - n := 0 1919 - for _, l := range labels { 1920 - if !defaultLabelMap[l.AtUri().String()] { 1921 - labels[n] = l 1922 - n++ 1923 - } 1924 - } 1925 - labels = labels[:n] 1926 - 1927 - subscribedLabels := make(map[string]struct{}) 1928 - for _, l := range f.Repo.Labels { 1929 - subscribedLabels[l] = struct{}{} 1930 - } 1931 - 1932 - // if there is atleast 1 unsubbed default label, show the "subscribe all" button, 1933 - // if all default labels are subbed, show the "unsubscribe all" button 1934 - shouldSubscribeAll := false 1935 - for _, dl := range defaultLabels { 1936 - if _, ok := subscribedLabels[dl.AtUri().String()]; !ok { 1937 - // one of the default labels is not subscribed to 1938 - shouldSubscribeAll = true 1939 - break 1940 - } 1941 - } 1942 - 1943 - rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{ 1944 - LoggedInUser: user, 1945 - RepoInfo: f.RepoInfo(user), 1946 - Branches: result.Branches, 1947 - Labels: labels, 1948 - DefaultLabels: defaultLabels, 1949 - SubscribedLabels: subscribedLabels, 1950 - ShouldSubscribeAll: shouldSubscribeAll, 1951 - Tabs: settingsTabs, 1952 - Tab: "general", 1953 - }) 1954 - } 1955 - 1956 - func (rp *Repo) accessSettings(w http.ResponseWriter, r *http.Request) { 1957 - f, err := rp.repoResolver.Resolve(r) 1958 - user := rp.oauth.GetUser(r) 1959 - 1960 - repoCollaborators, err := f.Collaborators(r.Context()) 1961 - if err != nil { 1962 - log.Println("failed to get collaborators", err) 1963 - } 1964 - 1965 - rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{ 1966 - LoggedInUser: user, 1967 - RepoInfo: f.RepoInfo(user), 1968 - Tabs: settingsTabs, 1969 - Tab: "access", 1970 - Collaborators: repoCollaborators, 1971 - }) 1972 - } 1973 - 1974 - func (rp *Repo) pipelineSettings(w http.ResponseWriter, r *http.Request) { 1975 - f, err := rp.repoResolver.Resolve(r) 1976 - user := rp.oauth.GetUser(r) 1977 - 1978 - // all spindles that the repo owner is a member of 1979 - spindles, err := rp.enforcer.GetSpindlesForUser(f.OwnerDid()) 1980 - if err != nil { 1981 - log.Println("failed to fetch spindles", err) 1982 - return 1983 - } 1984 - 1985 - var secrets []*tangled.RepoListSecrets_Secret 1986 - if f.Spindle != "" { 1987 - if spindleClient, err := rp.oauth.ServiceClient( 1988 - r, 1989 - oauth.WithService(f.Spindle), 1990 - oauth.WithLxm(tangled.RepoListSecretsNSID), 1991 - oauth.WithExp(60), 1992 - oauth.WithDev(rp.config.Core.Dev), 1993 - ); err != nil { 1994 - log.Println("failed to create spindle client", err) 1995 - } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil { 1996 - log.Println("failed to fetch secrets", err) 1997 - } else { 1998 - secrets = resp.Secrets 1999 - } 2000 - } 2001 - 2002 - slices.SortFunc(secrets, func(a, b *tangled.RepoListSecrets_Secret) int { 2003 - return strings.Compare(a.Key, b.Key) 2004 - }) 2005 - 2006 - var dids []string 2007 - for _, s := range secrets { 2008 - dids = append(dids, s.CreatedBy) 2009 - } 2010 - resolvedIdents := rp.idResolver.ResolveIdents(r.Context(), dids) 2011 - 2012 - // convert to a more manageable form 2013 - var niceSecret []map[string]any 2014 - for id, s := range secrets { 2015 - when, _ := time.Parse(time.RFC3339, s.CreatedAt) 2016 - niceSecret = append(niceSecret, map[string]any{ 2017 - "Id": id, 2018 - "Key": s.Key, 2019 - "CreatedAt": when, 2020 - "CreatedBy": resolvedIdents[id].Handle.String(), 2021 - }) 2022 - } 2023 - 2024 - rp.pages.RepoPipelineSettings(w, pages.RepoPipelineSettingsParams{ 2025 - LoggedInUser: user, 2026 - RepoInfo: f.RepoInfo(user), 2027 - Tabs: settingsTabs, 2028 - Tab: "pipelines", 2029 - Spindles: spindles, 2030 - CurrentSpindle: f.Spindle, 2031 - Secrets: niceSecret, 2032 - }) 2033 - } 2034 - 2035 - func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) { 2036 940 ref := chi.URLParam(r, "ref") 2037 941 ref, _ = url.PathUnescape(ref) 2038 942 2039 943 user := rp.oauth.GetUser(r) 2040 944 f, err := rp.repoResolver.Resolve(r) 2041 945 if err != nil { 2042 - log.Printf("failed to resolve source repo: %v", err) 946 + l.Error("failed to resolve source repo", "err", err) 2043 947 return 2044 948 } 2045 949 ··· 2056 960 return 2057 961 } 2058 962 2059 - repoInfo := f.RepoInfo(user) 2060 - if repoInfo.Source == nil { 963 + if f.Source == "" { 2061 964 rp.pages.Notice(w, "repo", "This repository is not a fork.") 2062 965 return 2063 966 } ··· 2068 971 &tangled.RepoForkSync_Input{ 2069 972 Did: user.Did, 2070 973 Name: f.Name, 2071 - Source: repoInfo.Source.RepoAt().String(), 974 + Source: f.Source, 2072 975 Branch: ref, 2073 976 }, 2074 977 ) ··· 2083 986 } 2084 987 2085 988 func (rp *Repo) ForkRepo(w http.ResponseWriter, r *http.Request) { 989 + l := rp.logger.With("handler", "ForkRepo") 990 + 2086 991 user := rp.oauth.GetUser(r) 2087 992 f, err := rp.repoResolver.Resolve(r) 2088 993 if err != nil { 2089 - log.Printf("failed to resolve source repo: %v", err) 994 + l.Error("failed to resolve source repo", "err", err) 2090 995 return 2091 996 } 2092 997 ··· 2102 1007 rp.pages.ForkRepo(w, pages.ForkRepoParams{ 2103 1008 LoggedInUser: user, 2104 1009 Knots: knots, 2105 - RepoInfo: f.RepoInfo(user), 1010 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 2106 1011 }) 2107 1012 2108 1013 case http.MethodPost: ··· 2132 1037 // in the user's account. 2133 1038 existingRepo, err := db.GetRepo( 2134 1039 rp.db, 2135 - db.FilterEq("did", user.Did), 2136 - db.FilterEq("name", forkName), 1040 + orm.FilterEq("did", user.Did), 1041 + orm.FilterEq("name", forkName), 2137 1042 ) 2138 1043 if err != nil { 2139 1044 if !errors.Is(err, sql.ErrNoRows) { 2140 - log.Println("error fetching existing repo from db", "err", err) 1045 + l.Error("error fetching existing repo from db", "err", err) 2141 1046 rp.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.") 2142 1047 return 2143 1048 } ··· 2153 1058 uri = "http" 2154 1059 } 2155 1060 2156 - forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.Repo.Name) 1061 + forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.Did, f.Name) 2157 1062 l = l.With("cloneUrl", forkSourceUrl) 2158 1063 2159 1064 sourceAt := f.RepoAt().String() ··· 2166 1071 Knot: targetKnot, 2167 1072 Rkey: rkey, 2168 1073 Source: sourceAt, 2169 - Description: f.Repo.Description, 1074 + Description: f.Description, 2170 1075 Created: time.Now(), 2171 - Labels: models.DefaultLabelDefs(), 1076 + Labels: rp.config.Label.DefaultLabelDefs, 2172 1077 } 2173 1078 record := repo.AsRecord() 2174 1079 ··· 2225 1130 } 2226 1131 defer rollback() 2227 1132 1133 + // TODO: this could coordinate better with the knot to recieve a clone status 2228 1134 client, err := rp.oauth.ServiceClient( 2229 1135 r, 2230 1136 oauth.WithService(targetKnot), 2231 1137 oauth.WithLxm(tangled.RepoCreateNSID), 2232 1138 oauth.WithDev(rp.config.Core.Dev), 1139 + oauth.WithTimeout(time.Second*20), // big repos take time to clone 2233 1140 ) 2234 1141 if err != nil { 2235 1142 l.Error("could not create service client", "err", err) ··· 2252 1159 2253 1160 err = db.AddRepo(tx, repo) 2254 1161 if err != nil { 2255 - log.Println(err) 1162 + l.Error("failed to AddRepo", "err", err) 2256 1163 rp.pages.Notice(w, "repo", "Failed to save repository information.") 2257 1164 return 2258 1165 } ··· 2261 1168 p, _ := securejoin.SecureJoin(user.Did, forkName) 2262 1169 err = rp.enforcer.AddRepo(user.Did, targetKnot, p) 2263 1170 if err != nil { 2264 - log.Println(err) 1171 + l.Error("failed to add ACLs", "err", err) 2265 1172 rp.pages.Notice(w, "repo", "Failed to set up repository permissions.") 2266 1173 return 2267 1174 } 2268 1175 2269 1176 err = tx.Commit() 2270 1177 if err != nil { 2271 - log.Println("failed to commit changes", err) 1178 + l.Error("failed to commit changes", "err", err) 2272 1179 http.Error(w, err.Error(), http.StatusInternalServerError) 2273 1180 return 2274 1181 } 2275 1182 2276 1183 err = rp.enforcer.E.SavePolicy() 2277 1184 if err != nil { 2278 - log.Println("failed to update ACLs", err) 1185 + l.Error("failed to update ACLs", "err", err) 2279 1186 http.Error(w, err.Error(), http.StatusInternalServerError) 2280 1187 return 2281 1188 } ··· 2284 1191 aturi = "" 2285 1192 2286 1193 rp.notifier.NewRepo(r.Context(), repo) 2287 - rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Did, forkName)) 1194 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/%s", user.Did, forkName)) 2288 1195 } 2289 1196 } 2290 1197 ··· 2309 1216 }) 2310 1217 return err 2311 1218 } 2312 - 2313 - func (rp *Repo) RepoCompareNew(w http.ResponseWriter, r *http.Request) { 2314 - user := rp.oauth.GetUser(r) 2315 - f, err := rp.repoResolver.Resolve(r) 2316 - if err != nil { 2317 - log.Println("failed to get repo and knot", err) 2318 - return 2319 - } 2320 - 2321 - scheme := "http" 2322 - if !rp.config.Core.Dev { 2323 - scheme = "https" 2324 - } 2325 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 2326 - xrpcc := &indigoxrpc.Client{ 2327 - Host: host, 2328 - } 2329 - 2330 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 2331 - branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 2332 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2333 - log.Println("failed to call XRPC repo.branches", xrpcerr) 2334 - rp.pages.Error503(w) 2335 - return 2336 - } 2337 - 2338 - var branchResult types.RepoBranchesResponse 2339 - if err := json.Unmarshal(branchBytes, &branchResult); err != nil { 2340 - log.Println("failed to decode XRPC branches response", err) 2341 - rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2342 - return 2343 - } 2344 - branches := branchResult.Branches 2345 - 2346 - sortBranches(branches) 2347 - 2348 - var defaultBranch string 2349 - for _, b := range branches { 2350 - if b.IsDefault { 2351 - defaultBranch = b.Name 2352 - } 2353 - } 2354 - 2355 - base := defaultBranch 2356 - head := defaultBranch 2357 - 2358 - params := r.URL.Query() 2359 - queryBase := params.Get("base") 2360 - queryHead := params.Get("head") 2361 - if queryBase != "" { 2362 - base = queryBase 2363 - } 2364 - if queryHead != "" { 2365 - head = queryHead 2366 - } 2367 - 2368 - tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 2369 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2370 - log.Println("failed to call XRPC repo.tags", xrpcerr) 2371 - rp.pages.Error503(w) 2372 - return 2373 - } 2374 - 2375 - var tags types.RepoTagsResponse 2376 - if err := json.Unmarshal(tagBytes, &tags); err != nil { 2377 - log.Println("failed to decode XRPC tags response", err) 2378 - rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2379 - return 2380 - } 2381 - 2382 - repoinfo := f.RepoInfo(user) 2383 - 2384 - rp.pages.RepoCompareNew(w, pages.RepoCompareNewParams{ 2385 - LoggedInUser: user, 2386 - RepoInfo: repoinfo, 2387 - Branches: branches, 2388 - Tags: tags.Tags, 2389 - Base: base, 2390 - Head: head, 2391 - }) 2392 - } 2393 - 2394 - func (rp *Repo) RepoCompare(w http.ResponseWriter, r *http.Request) { 2395 - user := rp.oauth.GetUser(r) 2396 - f, err := rp.repoResolver.Resolve(r) 2397 - if err != nil { 2398 - log.Println("failed to get repo and knot", err) 2399 - return 2400 - } 2401 - 2402 - var diffOpts types.DiffOpts 2403 - if d := r.URL.Query().Get("diff"); d == "split" { 2404 - diffOpts.Split = true 2405 - } 2406 - 2407 - // if user is navigating to one of 2408 - // /compare/{base}/{head} 2409 - // /compare/{base}...{head} 2410 - base := chi.URLParam(r, "base") 2411 - head := chi.URLParam(r, "head") 2412 - if base == "" && head == "" { 2413 - rest := chi.URLParam(r, "*") // master...feature/xyz 2414 - parts := strings.SplitN(rest, "...", 2) 2415 - if len(parts) == 2 { 2416 - base = parts[0] 2417 - head = parts[1] 2418 - } 2419 - } 2420 - 2421 - base, _ = url.PathUnescape(base) 2422 - head, _ = url.PathUnescape(head) 2423 - 2424 - if base == "" || head == "" { 2425 - log.Printf("invalid comparison") 2426 - rp.pages.Error404(w) 2427 - return 2428 - } 2429 - 2430 - scheme := "http" 2431 - if !rp.config.Core.Dev { 2432 - scheme = "https" 2433 - } 2434 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 2435 - xrpcc := &indigoxrpc.Client{ 2436 - Host: host, 2437 - } 2438 - 2439 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 2440 - 2441 - branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 2442 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2443 - log.Println("failed to call XRPC repo.branches", xrpcerr) 2444 - rp.pages.Error503(w) 2445 - return 2446 - } 2447 - 2448 - var branches types.RepoBranchesResponse 2449 - if err := json.Unmarshal(branchBytes, &branches); err != nil { 2450 - log.Println("failed to decode XRPC branches response", err) 2451 - rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2452 - return 2453 - } 2454 - 2455 - tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 2456 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2457 - log.Println("failed to call XRPC repo.tags", xrpcerr) 2458 - rp.pages.Error503(w) 2459 - return 2460 - } 2461 - 2462 - var tags types.RepoTagsResponse 2463 - if err := json.Unmarshal(tagBytes, &tags); err != nil { 2464 - log.Println("failed to decode XRPC tags response", err) 2465 - rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2466 - return 2467 - } 2468 - 2469 - compareBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, base, head) 2470 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2471 - log.Println("failed to call XRPC repo.compare", xrpcerr) 2472 - rp.pages.Error503(w) 2473 - return 2474 - } 2475 - 2476 - var formatPatch types.RepoFormatPatchResponse 2477 - if err := json.Unmarshal(compareBytes, &formatPatch); err != nil { 2478 - log.Println("failed to decode XRPC compare response", err) 2479 - rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 2480 - return 2481 - } 2482 - 2483 - diff := patchutil.AsNiceDiff(formatPatch.Patch, base) 2484 - 2485 - repoinfo := f.RepoInfo(user) 2486 - 2487 - rp.pages.RepoCompare(w, pages.RepoCompareParams{ 2488 - LoggedInUser: user, 2489 - RepoInfo: repoinfo, 2490 - Branches: branches.Branches, 2491 - Tags: tags.Tags, 2492 - Base: base, 2493 - Head: head, 2494 - Diff: &diff, 2495 - DiffOpts: diffOpts, 2496 - }) 2497 - 2498 - }
+20 -70
appview/repo/repo_util.go
··· 1 1 package repo 2 2 3 3 import ( 4 - "context" 5 - "crypto/rand" 6 - "fmt" 7 - "math/big" 4 + "maps" 8 5 "slices" 9 6 "sort" 10 7 "strings" 11 8 12 9 "tangled.org/core/appview/db" 13 10 "tangled.org/core/appview/models" 14 - "tangled.org/core/appview/pages/repoinfo" 11 + "tangled.org/core/orm" 15 12 "tangled.org/core/types" 16 - 17 - "github.com/go-git/go-git/v5/plumbing/object" 18 13 ) 19 14 20 15 func sortFiles(files []types.NiceTree) { 21 16 sort.Slice(files, func(i, j int) bool { 22 - iIsFile := files[i].IsFile 23 - jIsFile := files[j].IsFile 17 + iIsFile := files[i].IsFile() 18 + jIsFile := files[j].IsFile() 24 19 if iIsFile != jIsFile { 25 20 return !iIsFile 26 21 } ··· 47 42 }) 48 43 } 49 44 50 - func uniqueEmails(commits []*object.Commit) []string { 45 + func uniqueEmails(commits []types.Commit) []string { 51 46 emails := make(map[string]struct{}) 52 47 for _, commit := range commits { 53 - if commit.Author.Email != "" { 54 - emails[commit.Author.Email] = struct{}{} 55 - } 56 - if commit.Committer.Email != "" { 57 - emails[commit.Committer.Email] = struct{}{} 48 + emails[commit.Author.Email] = struct{}{} 49 + emails[commit.Committer.Email] = struct{}{} 50 + for _, c := range commit.CoAuthors() { 51 + emails[c.Email] = struct{}{} 58 52 } 59 53 } 60 - var uniqueEmails []string 61 - for email := range emails { 62 - uniqueEmails = append(uniqueEmails, email) 63 - } 64 - return uniqueEmails 54 + 55 + // delete empty emails if any, from the set 56 + delete(emails, "") 57 + 58 + return slices.Collect(maps.Keys(emails)) 65 59 } 66 60 67 61 func balanceIndexItems(commitCount, branchCount, tagCount, fileCount int) (commitsTrunc int, branchesTrunc int, tagsTrunc int) { ··· 92 86 return 93 87 } 94 88 95 - // emailToDidOrHandle takes an emailToDidMap from db.GetEmailToDid 96 - // and resolves all dids to handles and returns a new map[string]string 97 - func emailToDidOrHandle(r *Repo, emailToDidMap map[string]string) map[string]string { 98 - if emailToDidMap == nil { 99 - return nil 100 - } 101 - 102 - var dids []string 103 - for _, v := range emailToDidMap { 104 - dids = append(dids, v) 105 - } 106 - resolvedIdents := r.idResolver.ResolveIdents(context.Background(), dids) 107 - 108 - didHandleMap := make(map[string]string) 109 - for _, identity := range resolvedIdents { 110 - if !identity.Handle.IsInvalidHandle() { 111 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 112 - } else { 113 - didHandleMap[identity.DID.String()] = identity.DID.String() 114 - } 115 - } 116 - 117 - // Create map of email to didOrHandle for commit display 118 - emailToDidOrHandle := make(map[string]string) 119 - for email, did := range emailToDidMap { 120 - if didOrHandle, ok := didHandleMap[did]; ok { 121 - emailToDidOrHandle[email] = didOrHandle 122 - } 123 - } 124 - 125 - return emailToDidOrHandle 126 - } 127 - 128 - func randomString(n int) string { 129 - const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" 130 - result := make([]byte, n) 131 - 132 - for i := 0; i < n; i++ { 133 - n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letters)))) 134 - result[i] = letters[n.Int64()] 135 - } 136 - 137 - return string(result) 138 - } 139 - 140 89 // grab pipelines from DB and munge that into a hashmap with commit sha as key 141 90 // 142 91 // golang is so blessed that it requires 35 lines of imperative code for this 143 92 func getPipelineStatuses( 144 93 d *db.DB, 145 - repoInfo repoinfo.RepoInfo, 94 + repo *models.Repo, 146 95 shas []string, 147 96 ) (map[string]models.Pipeline, error) { 148 97 m := make(map[string]models.Pipeline) ··· 153 102 154 103 ps, err := db.GetPipelineStatuses( 155 104 d, 156 - db.FilterEq("repo_owner", repoInfo.OwnerDid), 157 - db.FilterEq("repo_name", repoInfo.Name), 158 - db.FilterEq("knot", repoInfo.Knot), 159 - db.FilterIn("sha", shas), 105 + len(shas), 106 + orm.FilterEq("repo_owner", repo.Did), 107 + orm.FilterEq("repo_name", repo.Name), 108 + orm.FilterEq("knot", repo.Knot), 109 + orm.FilterIn("sha", shas), 160 110 ) 161 111 if err != nil { 162 112 return nil, err
+15 -20
appview/repo/router.go
··· 9 9 10 10 func (rp *Repo) Router(mw *middleware.Middleware) http.Handler { 11 11 r := chi.NewRouter() 12 - r.Get("/", rp.RepoIndex) 13 - r.Get("/opengraph", rp.RepoOpenGraphSummary) 14 - r.Get("/feed.atom", rp.RepoAtomFeed) 15 - r.Get("/commits/{ref}", rp.RepoLog) 12 + r.Get("/", rp.Index) 13 + r.Get("/opengraph", rp.Opengraph) 14 + r.Get("/feed.atom", rp.AtomFeed) 15 + r.Get("/commits/{ref}", rp.Log) 16 16 r.Route("/tree/{ref}", func(r chi.Router) { 17 - r.Get("/", rp.RepoIndex) 18 - r.Get("/*", rp.RepoTree) 17 + r.Get("/", rp.Index) 18 + r.Get("/*", rp.Tree) 19 19 }) 20 - r.Get("/commit/{ref}", rp.RepoCommit) 21 - r.Get("/branches", rp.RepoBranches) 20 + r.Get("/commit/{ref}", rp.Commit) 21 + r.Get("/branches", rp.Branches) 22 + r.Delete("/branches", rp.DeleteBranch) 22 23 r.Route("/tags", func(r chi.Router) { 23 - r.Get("/", rp.RepoTags) 24 + r.Get("/", rp.Tags) 24 25 r.Route("/{tag}", func(r chi.Router) { 25 26 r.Get("/download/{file}", rp.DownloadArtifact) 26 27 ··· 36 37 }) 37 38 }) 38 39 }) 39 - r.Get("/blob/{ref}/*", rp.RepoBlob) 40 + r.Get("/blob/{ref}/*", rp.Blob) 40 41 r.Get("/raw/{ref}/*", rp.RepoBlobRaw) 41 42 42 43 // intentionally doesn't use /* as this isn't ··· 53 54 }) 54 55 55 56 r.Route("/compare", func(r chi.Router) { 56 - r.Get("/", rp.RepoCompareNew) // start an new comparison 57 + r.Get("/", rp.CompareNew) // start an new comparison 57 58 58 59 // we have to wildcard here since we want to support GitHub's compare syntax 59 60 // /compare/{ref1}...{ref2} 60 61 // for example: 61 62 // /compare/master...some/feature 62 63 // /compare/master...example.com:another/feature <- this is a fork 63 - r.Get("/{base}/{head}", rp.RepoCompare) 64 - r.Get("/*", rp.RepoCompare) 64 + r.Get("/*", rp.Compare) 65 65 }) 66 66 67 67 // label panel in issues/pulls/discussions/tasks ··· 73 73 // settings routes, needs auth 74 74 r.Group(func(r chi.Router) { 75 75 r.Use(middleware.AuthMiddleware(rp.oauth)) 76 - // repo description can only be edited by owner 77 - r.With(mw.RepoPermissionMiddleware("repo:owner")).Route("/description", func(r chi.Router) { 78 - r.Put("/", rp.RepoDescription) 79 - r.Get("/", rp.RepoDescription) 80 - r.Get("/edit", rp.RepoDescriptionEdit) 81 - }) 82 76 r.With(mw.RepoPermissionMiddleware("repo:settings")).Route("/settings", func(r chi.Router) { 83 - r.Get("/", rp.RepoSettings) 77 + r.Get("/", rp.Settings) 78 + r.With(mw.RepoPermissionMiddleware("repo:owner")).Put("/base", rp.EditBaseSettings) 84 79 r.With(mw.RepoPermissionMiddleware("repo:owner")).Post("/spindle", rp.EditSpindle) 85 80 r.With(mw.RepoPermissionMiddleware("repo:owner")).Put("/label", rp.AddLabelDef) 86 81 r.With(mw.RepoPermissionMiddleware("repo:owner")).Delete("/label", rp.DeleteLabelDef)
+471
appview/repo/settings.go
··· 1 + package repo 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + "slices" 8 + "strings" 9 + "time" 10 + 11 + "tangled.org/core/api/tangled" 12 + "tangled.org/core/appview/db" 13 + "tangled.org/core/appview/models" 14 + "tangled.org/core/appview/oauth" 15 + "tangled.org/core/appview/pages" 16 + xrpcclient "tangled.org/core/appview/xrpcclient" 17 + "tangled.org/core/orm" 18 + "tangled.org/core/types" 19 + 20 + comatproto "github.com/bluesky-social/indigo/api/atproto" 21 + lexutil "github.com/bluesky-social/indigo/lex/util" 22 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 23 + ) 24 + 25 + type tab = map[string]any 26 + 27 + var ( 28 + // would be great to have ordered maps right about now 29 + settingsTabs []tab = []tab{ 30 + {"Name": "general", "Icon": "sliders-horizontal"}, 31 + {"Name": "access", "Icon": "users"}, 32 + {"Name": "pipelines", "Icon": "layers-2"}, 33 + } 34 + ) 35 + 36 + func (rp *Repo) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { 37 + l := rp.logger.With("handler", "SetDefaultBranch") 38 + 39 + f, err := rp.repoResolver.Resolve(r) 40 + if err != nil { 41 + l.Error("failed to get repo and knot", "err", err) 42 + return 43 + } 44 + 45 + noticeId := "operation-error" 46 + branch := r.FormValue("branch") 47 + if branch == "" { 48 + http.Error(w, "malformed form", http.StatusBadRequest) 49 + return 50 + } 51 + 52 + client, err := rp.oauth.ServiceClient( 53 + r, 54 + oauth.WithService(f.Knot), 55 + oauth.WithLxm(tangled.RepoSetDefaultBranchNSID), 56 + oauth.WithDev(rp.config.Core.Dev), 57 + ) 58 + if err != nil { 59 + l.Error("failed to connect to knot server", "err", err) 60 + rp.pages.Notice(w, noticeId, "Failed to connect to knot server.") 61 + return 62 + } 63 + 64 + xe := tangled.RepoSetDefaultBranch( 65 + r.Context(), 66 + client, 67 + &tangled.RepoSetDefaultBranch_Input{ 68 + Repo: f.RepoAt().String(), 69 + DefaultBranch: branch, 70 + }, 71 + ) 72 + if err := xrpcclient.HandleXrpcErr(xe); err != nil { 73 + l.Error("xrpc failed", "err", xe) 74 + rp.pages.Notice(w, noticeId, err.Error()) 75 + return 76 + } 77 + 78 + rp.pages.HxRefresh(w) 79 + } 80 + 81 + func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) { 82 + user := rp.oauth.GetUser(r) 83 + l := rp.logger.With("handler", "Secrets") 84 + l = l.With("did", user.Did) 85 + 86 + f, err := rp.repoResolver.Resolve(r) 87 + if err != nil { 88 + l.Error("failed to get repo and knot", "err", err) 89 + return 90 + } 91 + 92 + if f.Spindle == "" { 93 + l.Error("empty spindle cannot add/rm secret", "err", err) 94 + return 95 + } 96 + 97 + lxm := tangled.RepoAddSecretNSID 98 + if r.Method == http.MethodDelete { 99 + lxm = tangled.RepoRemoveSecretNSID 100 + } 101 + 102 + spindleClient, err := rp.oauth.ServiceClient( 103 + r, 104 + oauth.WithService(f.Spindle), 105 + oauth.WithLxm(lxm), 106 + oauth.WithExp(60), 107 + oauth.WithDev(rp.config.Core.Dev), 108 + ) 109 + if err != nil { 110 + l.Error("failed to create spindle client", "err", err) 111 + return 112 + } 113 + 114 + key := r.FormValue("key") 115 + if key == "" { 116 + w.WriteHeader(http.StatusBadRequest) 117 + return 118 + } 119 + 120 + switch r.Method { 121 + case http.MethodPut: 122 + errorId := "add-secret-error" 123 + 124 + value := r.FormValue("value") 125 + if value == "" { 126 + w.WriteHeader(http.StatusBadRequest) 127 + return 128 + } 129 + 130 + err = tangled.RepoAddSecret( 131 + r.Context(), 132 + spindleClient, 133 + &tangled.RepoAddSecret_Input{ 134 + Repo: f.RepoAt().String(), 135 + Key: key, 136 + Value: value, 137 + }, 138 + ) 139 + if err != nil { 140 + l.Error("Failed to add secret.", "err", err) 141 + rp.pages.Notice(w, errorId, "Failed to add secret.") 142 + return 143 + } 144 + 145 + case http.MethodDelete: 146 + errorId := "operation-error" 147 + 148 + err = tangled.RepoRemoveSecret( 149 + r.Context(), 150 + spindleClient, 151 + &tangled.RepoRemoveSecret_Input{ 152 + Repo: f.RepoAt().String(), 153 + Key: key, 154 + }, 155 + ) 156 + if err != nil { 157 + l.Error("Failed to delete secret.", "err", err) 158 + rp.pages.Notice(w, errorId, "Failed to delete secret.") 159 + return 160 + } 161 + } 162 + 163 + rp.pages.HxRefresh(w) 164 + } 165 + 166 + func (rp *Repo) Settings(w http.ResponseWriter, r *http.Request) { 167 + tabVal := r.URL.Query().Get("tab") 168 + if tabVal == "" { 169 + tabVal = "general" 170 + } 171 + 172 + switch tabVal { 173 + case "general": 174 + rp.generalSettings(w, r) 175 + 176 + case "access": 177 + rp.accessSettings(w, r) 178 + 179 + case "pipelines": 180 + rp.pipelineSettings(w, r) 181 + } 182 + } 183 + 184 + func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) { 185 + l := rp.logger.With("handler", "generalSettings") 186 + 187 + f, err := rp.repoResolver.Resolve(r) 188 + user := rp.oauth.GetUser(r) 189 + 190 + scheme := "http" 191 + if !rp.config.Core.Dev { 192 + scheme = "https" 193 + } 194 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 195 + xrpcc := &indigoxrpc.Client{ 196 + Host: host, 197 + } 198 + 199 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 200 + xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 201 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 202 + l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 203 + rp.pages.Error503(w) 204 + return 205 + } 206 + 207 + var result types.RepoBranchesResponse 208 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 209 + l.Error("failed to decode XRPC response", "err", err) 210 + rp.pages.Error503(w) 211 + return 212 + } 213 + 214 + defaultLabels, err := db.GetLabelDefinitions(rp.db, orm.FilterIn("at_uri", rp.config.Label.DefaultLabelDefs)) 215 + if err != nil { 216 + l.Error("failed to fetch labels", "err", err) 217 + rp.pages.Error503(w) 218 + return 219 + } 220 + 221 + labels, err := db.GetLabelDefinitions(rp.db, orm.FilterIn("at_uri", f.Labels)) 222 + if err != nil { 223 + l.Error("failed to fetch labels", "err", err) 224 + rp.pages.Error503(w) 225 + return 226 + } 227 + // remove default labels from the labels list, if present 228 + defaultLabelMap := make(map[string]bool) 229 + for _, dl := range defaultLabels { 230 + defaultLabelMap[dl.AtUri().String()] = true 231 + } 232 + n := 0 233 + for _, l := range labels { 234 + if !defaultLabelMap[l.AtUri().String()] { 235 + labels[n] = l 236 + n++ 237 + } 238 + } 239 + labels = labels[:n] 240 + 241 + subscribedLabels := make(map[string]struct{}) 242 + for _, l := range f.Labels { 243 + subscribedLabels[l] = struct{}{} 244 + } 245 + 246 + // if there is atleast 1 unsubbed default label, show the "subscribe all" button, 247 + // if all default labels are subbed, show the "unsubscribe all" button 248 + shouldSubscribeAll := false 249 + for _, dl := range defaultLabels { 250 + if _, ok := subscribedLabels[dl.AtUri().String()]; !ok { 251 + // one of the default labels is not subscribed to 252 + shouldSubscribeAll = true 253 + break 254 + } 255 + } 256 + 257 + rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{ 258 + LoggedInUser: user, 259 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 260 + Branches: result.Branches, 261 + Labels: labels, 262 + DefaultLabels: defaultLabels, 263 + SubscribedLabels: subscribedLabels, 264 + ShouldSubscribeAll: shouldSubscribeAll, 265 + Tabs: settingsTabs, 266 + Tab: "general", 267 + }) 268 + } 269 + 270 + func (rp *Repo) accessSettings(w http.ResponseWriter, r *http.Request) { 271 + l := rp.logger.With("handler", "accessSettings") 272 + 273 + f, err := rp.repoResolver.Resolve(r) 274 + user := rp.oauth.GetUser(r) 275 + 276 + collaborators, err := func(repo *models.Repo) ([]pages.Collaborator, error) { 277 + repoCollaborators, err := rp.enforcer.E.GetImplicitUsersForResourceByDomain(repo.DidSlashRepo(), repo.Knot) 278 + if err != nil { 279 + return nil, err 280 + } 281 + var collaborators []pages.Collaborator 282 + for _, item := range repoCollaborators { 283 + // currently only two roles: owner and member 284 + var role string 285 + switch item[3] { 286 + case "repo:owner": 287 + role = "owner" 288 + case "repo:collaborator": 289 + role = "collaborator" 290 + default: 291 + continue 292 + } 293 + 294 + did := item[0] 295 + 296 + c := pages.Collaborator{ 297 + Did: did, 298 + Role: role, 299 + } 300 + collaborators = append(collaborators, c) 301 + } 302 + return collaborators, nil 303 + }(f) 304 + if err != nil { 305 + l.Error("failed to get collaborators", "err", err) 306 + } 307 + 308 + rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{ 309 + LoggedInUser: user, 310 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 311 + Tabs: settingsTabs, 312 + Tab: "access", 313 + Collaborators: collaborators, 314 + }) 315 + } 316 + 317 + func (rp *Repo) pipelineSettings(w http.ResponseWriter, r *http.Request) { 318 + l := rp.logger.With("handler", "pipelineSettings") 319 + 320 + f, err := rp.repoResolver.Resolve(r) 321 + user := rp.oauth.GetUser(r) 322 + 323 + // all spindles that the repo owner is a member of 324 + spindles, err := rp.enforcer.GetSpindlesForUser(f.Did) 325 + if err != nil { 326 + l.Error("failed to fetch spindles", "err", err) 327 + return 328 + } 329 + 330 + var secrets []*tangled.RepoListSecrets_Secret 331 + if f.Spindle != "" { 332 + if spindleClient, err := rp.oauth.ServiceClient( 333 + r, 334 + oauth.WithService(f.Spindle), 335 + oauth.WithLxm(tangled.RepoListSecretsNSID), 336 + oauth.WithExp(60), 337 + oauth.WithDev(rp.config.Core.Dev), 338 + ); err != nil { 339 + l.Error("failed to create spindle client", "err", err) 340 + } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil { 341 + l.Error("failed to fetch secrets", "err", err) 342 + } else { 343 + secrets = resp.Secrets 344 + } 345 + } 346 + 347 + slices.SortFunc(secrets, func(a, b *tangled.RepoListSecrets_Secret) int { 348 + return strings.Compare(a.Key, b.Key) 349 + }) 350 + 351 + var dids []string 352 + for _, s := range secrets { 353 + dids = append(dids, s.CreatedBy) 354 + } 355 + resolvedIdents := rp.idResolver.ResolveIdents(r.Context(), dids) 356 + 357 + // convert to a more manageable form 358 + var niceSecret []map[string]any 359 + for id, s := range secrets { 360 + when, _ := time.Parse(time.RFC3339, s.CreatedAt) 361 + niceSecret = append(niceSecret, map[string]any{ 362 + "Id": id, 363 + "Key": s.Key, 364 + "CreatedAt": when, 365 + "CreatedBy": resolvedIdents[id].Handle.String(), 366 + }) 367 + } 368 + 369 + rp.pages.RepoPipelineSettings(w, pages.RepoPipelineSettingsParams{ 370 + LoggedInUser: user, 371 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 372 + Tabs: settingsTabs, 373 + Tab: "pipelines", 374 + Spindles: spindles, 375 + CurrentSpindle: f.Spindle, 376 + Secrets: niceSecret, 377 + }) 378 + } 379 + 380 + func (rp *Repo) EditBaseSettings(w http.ResponseWriter, r *http.Request) { 381 + l := rp.logger.With("handler", "EditBaseSettings") 382 + 383 + noticeId := "repo-base-settings-error" 384 + 385 + f, err := rp.repoResolver.Resolve(r) 386 + if err != nil { 387 + l.Error("failed to get repo and knot", "err", err) 388 + w.WriteHeader(http.StatusBadRequest) 389 + return 390 + } 391 + 392 + client, err := rp.oauth.AuthorizedClient(r) 393 + if err != nil { 394 + l.Error("failed to get client") 395 + rp.pages.Notice(w, noticeId, "Failed to update repository information, try again later.") 396 + return 397 + } 398 + 399 + var ( 400 + description = r.FormValue("description") 401 + website = r.FormValue("website") 402 + topicStr = r.FormValue("topics") 403 + ) 404 + 405 + err = rp.validator.ValidateURI(website) 406 + if website != "" && err != nil { 407 + l.Error("invalid uri", "err", err) 408 + rp.pages.Notice(w, noticeId, err.Error()) 409 + return 410 + } 411 + 412 + topics, err := rp.validator.ValidateRepoTopicStr(topicStr) 413 + if err != nil { 414 + l.Error("invalid topics", "err", err) 415 + rp.pages.Notice(w, noticeId, err.Error()) 416 + return 417 + } 418 + l.Debug("got", "topicsStr", topicStr, "topics", topics) 419 + 420 + newRepo := *f 421 + newRepo.Description = description 422 + newRepo.Website = website 423 + newRepo.Topics = topics 424 + record := newRepo.AsRecord() 425 + 426 + tx, err := rp.db.BeginTx(r.Context(), nil) 427 + if err != nil { 428 + l.Error("failed to begin transaction", "err", err) 429 + rp.pages.Notice(w, noticeId, "Failed to save repository information.") 430 + return 431 + } 432 + defer tx.Rollback() 433 + 434 + err = db.PutRepo(tx, newRepo) 435 + if err != nil { 436 + l.Error("failed to update repository", "err", err) 437 + rp.pages.Notice(w, noticeId, "Failed to save repository information.") 438 + return 439 + } 440 + 441 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 442 + if err != nil { 443 + // failed to get record 444 + l.Error("failed to get repo record", "err", err) 445 + rp.pages.Notice(w, noticeId, "Failed to save repository information, no record found on PDS.") 446 + return 447 + } 448 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 449 + Collection: tangled.RepoNSID, 450 + Repo: newRepo.Did, 451 + Rkey: newRepo.Rkey, 452 + SwapRecord: ex.Cid, 453 + Record: &lexutil.LexiconTypeDecoder{ 454 + Val: &record, 455 + }, 456 + }) 457 + 458 + if err != nil { 459 + l.Error("failed to perferom update-repo query", "err", err) 460 + // failed to get record 461 + rp.pages.Notice(w, noticeId, "Failed to save repository information, unable to save to PDS.") 462 + return 463 + } 464 + 465 + err = tx.Commit() 466 + if err != nil { 467 + l.Error("failed to commit", "err", err) 468 + } 469 + 470 + rp.pages.HxRefresh(w) 471 + }
+80
appview/repo/tags.go
··· 1 + package repo 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + 8 + "tangled.org/core/api/tangled" 9 + "tangled.org/core/appview/db" 10 + "tangled.org/core/appview/models" 11 + "tangled.org/core/appview/pages" 12 + xrpcclient "tangled.org/core/appview/xrpcclient" 13 + "tangled.org/core/orm" 14 + "tangled.org/core/types" 15 + 16 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 17 + "github.com/go-git/go-git/v5/plumbing" 18 + ) 19 + 20 + func (rp *Repo) Tags(w http.ResponseWriter, r *http.Request) { 21 + l := rp.logger.With("handler", "RepoTags") 22 + f, err := rp.repoResolver.Resolve(r) 23 + if err != nil { 24 + l.Error("failed to get repo and knot", "err", err) 25 + return 26 + } 27 + scheme := "http" 28 + if !rp.config.Core.Dev { 29 + scheme = "https" 30 + } 31 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 32 + xrpcc := &indigoxrpc.Client{ 33 + Host: host, 34 + } 35 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 36 + xrpcBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 37 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 38 + l.Error("failed to call XRPC repo.tags", "err", xrpcerr) 39 + rp.pages.Error503(w) 40 + return 41 + } 42 + var result types.RepoTagsResponse 43 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 44 + l.Error("failed to decode XRPC response", "err", err) 45 + rp.pages.Error503(w) 46 + return 47 + } 48 + artifacts, err := db.GetArtifact(rp.db, orm.FilterEq("repo_at", f.RepoAt())) 49 + if err != nil { 50 + l.Error("failed grab artifacts", "err", err) 51 + return 52 + } 53 + // convert artifacts to map for easy UI building 54 + artifactMap := make(map[plumbing.Hash][]models.Artifact) 55 + for _, a := range artifacts { 56 + artifactMap[a.Tag] = append(artifactMap[a.Tag], a) 57 + } 58 + var danglingArtifacts []models.Artifact 59 + for _, a := range artifacts { 60 + found := false 61 + for _, t := range result.Tags { 62 + if t.Tag != nil { 63 + if t.Tag.Hash == a.Tag { 64 + found = true 65 + } 66 + } 67 + } 68 + if !found { 69 + danglingArtifacts = append(danglingArtifacts, a) 70 + } 71 + } 72 + user := rp.oauth.GetUser(r) 73 + rp.pages.RepoTags(w, pages.RepoTagsParams{ 74 + LoggedInUser: user, 75 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 76 + RepoTagsResponse: result, 77 + ArtifactMap: artifactMap, 78 + DanglingArtifacts: danglingArtifacts, 79 + }) 80 + }
+108
appview/repo/tree.go
··· 1 + package repo 2 + 3 + import ( 4 + "fmt" 5 + "net/http" 6 + "net/url" 7 + "strings" 8 + "time" 9 + 10 + "tangled.org/core/api/tangled" 11 + "tangled.org/core/appview/pages" 12 + "tangled.org/core/appview/reporesolver" 13 + xrpcclient "tangled.org/core/appview/xrpcclient" 14 + "tangled.org/core/types" 15 + 16 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 17 + "github.com/go-chi/chi/v5" 18 + "github.com/go-git/go-git/v5/plumbing" 19 + ) 20 + 21 + func (rp *Repo) Tree(w http.ResponseWriter, r *http.Request) { 22 + l := rp.logger.With("handler", "RepoTree") 23 + f, err := rp.repoResolver.Resolve(r) 24 + if err != nil { 25 + l.Error("failed to fully resolve repo", "err", err) 26 + return 27 + } 28 + ref := chi.URLParam(r, "ref") 29 + ref, _ = url.PathUnescape(ref) 30 + // if the tree path has a trailing slash, let's strip it 31 + // so we don't 404 32 + treePath := chi.URLParam(r, "*") 33 + treePath, _ = url.PathUnescape(treePath) 34 + treePath = strings.TrimSuffix(treePath, "/") 35 + scheme := "http" 36 + if !rp.config.Core.Dev { 37 + scheme = "https" 38 + } 39 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 40 + xrpcc := &indigoxrpc.Client{ 41 + Host: host, 42 + } 43 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 44 + xrpcResp, err := tangled.RepoTree(r.Context(), xrpcc, treePath, ref, repo) 45 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 46 + l.Error("failed to call XRPC repo.tree", "err", xrpcerr) 47 + rp.pages.Error503(w) 48 + return 49 + } 50 + // Convert XRPC response to internal types.RepoTreeResponse 51 + files := make([]types.NiceTree, len(xrpcResp.Files)) 52 + for i, xrpcFile := range xrpcResp.Files { 53 + file := types.NiceTree{ 54 + Name: xrpcFile.Name, 55 + Mode: xrpcFile.Mode, 56 + Size: int64(xrpcFile.Size), 57 + } 58 + // Convert last commit info if present 59 + if xrpcFile.Last_commit != nil { 60 + commitWhen, _ := time.Parse(time.RFC3339, xrpcFile.Last_commit.When) 61 + file.LastCommit = &types.LastCommitInfo{ 62 + Hash: plumbing.NewHash(xrpcFile.Last_commit.Hash), 63 + Message: xrpcFile.Last_commit.Message, 64 + When: commitWhen, 65 + } 66 + } 67 + files[i] = file 68 + } 69 + result := types.RepoTreeResponse{ 70 + Ref: xrpcResp.Ref, 71 + Files: files, 72 + } 73 + if xrpcResp.Parent != nil { 74 + result.Parent = *xrpcResp.Parent 75 + } 76 + if xrpcResp.Dotdot != nil { 77 + result.DotDot = *xrpcResp.Dotdot 78 + } 79 + if xrpcResp.Readme != nil { 80 + result.ReadmeFileName = xrpcResp.Readme.Filename 81 + result.Readme = xrpcResp.Readme.Contents 82 + } 83 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 84 + // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated, 85 + // so we can safely redirect to the "parent" (which is the same file). 86 + if len(result.Files) == 0 && result.Parent == treePath { 87 + redirectTo := fmt.Sprintf("/%s/blob/%s/%s", ownerSlashRepo, url.PathEscape(ref), result.Parent) 88 + http.Redirect(w, r, redirectTo, http.StatusFound) 89 + return 90 + } 91 + user := rp.oauth.GetUser(r) 92 + var breadcrumbs [][]string 93 + breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", ownerSlashRepo, url.PathEscape(ref))}) 94 + if treePath != "" { 95 + for idx, elem := range strings.Split(treePath, "/") { 96 + breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))}) 97 + } 98 + } 99 + sortFiles(result.Files) 100 + 101 + rp.pages.RepoTree(w, pages.RepoTreeParams{ 102 + LoggedInUser: user, 103 + BreadCrumbs: breadcrumbs, 104 + TreePath: treePath, 105 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 106 + RepoTreeResponse: result, 107 + }) 108 + }
+76 -162
appview/reporesolver/resolver.go
··· 1 1 package reporesolver 2 2 3 3 import ( 4 - "context" 5 - "database/sql" 6 - "errors" 7 4 "fmt" 8 5 "log" 9 6 "net/http" ··· 12 9 "strings" 13 10 14 11 "github.com/bluesky-social/indigo/atproto/identity" 15 - securejoin "github.com/cyphar/filepath-securejoin" 16 12 "github.com/go-chi/chi/v5" 17 13 "tangled.org/core/appview/config" 18 14 "tangled.org/core/appview/db" 19 15 "tangled.org/core/appview/models" 20 16 "tangled.org/core/appview/oauth" 21 - "tangled.org/core/appview/pages" 22 17 "tangled.org/core/appview/pages/repoinfo" 23 - "tangled.org/core/idresolver" 24 18 "tangled.org/core/rbac" 25 19 ) 26 20 27 - type ResolvedRepo struct { 28 - models.Repo 29 - OwnerId identity.Identity 30 - CurrentDir string 31 - Ref string 32 - 33 - rr *RepoResolver 21 + type RepoResolver struct { 22 + config *config.Config 23 + enforcer *rbac.Enforcer 24 + execer db.Execer 34 25 } 35 26 36 - type RepoResolver struct { 37 - config *config.Config 38 - enforcer *rbac.Enforcer 39 - idResolver *idresolver.Resolver 40 - execer db.Execer 27 + func New(config *config.Config, enforcer *rbac.Enforcer, execer db.Execer) *RepoResolver { 28 + return &RepoResolver{config: config, enforcer: enforcer, execer: execer} 41 29 } 42 30 43 - func New(config *config.Config, enforcer *rbac.Enforcer, resolver *idresolver.Resolver, execer db.Execer) *RepoResolver { 44 - return &RepoResolver{config: config, enforcer: enforcer, idResolver: resolver, execer: execer} 31 + // NOTE: this... should not even be here. the entire package will be removed in future refactor 32 + func GetBaseRepoPath(r *http.Request, repo *models.Repo) string { 33 + var ( 34 + user = chi.URLParam(r, "user") 35 + name = chi.URLParam(r, "repo") 36 + ) 37 + if user == "" || name == "" { 38 + return repo.DidSlashRepo() 39 + } 40 + return path.Join(user, name) 45 41 } 46 42 47 - func (rr *RepoResolver) Resolve(r *http.Request) (*ResolvedRepo, error) { 43 + // TODO: move this out of `RepoResolver` struct 44 + func (rr *RepoResolver) Resolve(r *http.Request) (*models.Repo, error) { 48 45 repo, ok := r.Context().Value("repo").(*models.Repo) 49 46 if !ok { 50 47 log.Println("malformed middleware: `repo` not exist in context") 51 48 return nil, fmt.Errorf("malformed middleware") 52 49 } 53 - id, ok := r.Context().Value("resolvedId").(identity.Identity) 54 - if !ok { 55 - log.Println("malformed middleware") 56 - return nil, fmt.Errorf("malformed middleware") 57 - } 58 50 59 - currentDir := path.Dir(extractPathAfterRef(r.URL.EscapedPath())) 60 - ref := chi.URLParam(r, "ref") 61 - 62 - return &ResolvedRepo{ 63 - Repo: *repo, 64 - OwnerId: id, 65 - CurrentDir: currentDir, 66 - Ref: ref, 67 - 68 - rr: rr, 69 - }, nil 51 + return repo, nil 70 52 } 71 53 72 - func (f *ResolvedRepo) OwnerDid() string { 73 - return f.OwnerId.DID.String() 74 - } 75 - 76 - func (f *ResolvedRepo) OwnerHandle() string { 77 - return f.OwnerId.Handle.String() 78 - } 79 - 80 - func (f *ResolvedRepo) OwnerSlashRepo() string { 81 - handle := f.OwnerId.Handle 82 - 83 - var p string 84 - if handle != "" && !handle.IsInvalidHandle() { 85 - p, _ = securejoin.SecureJoin(fmt.Sprintf("@%s", handle), f.Name) 86 - } else { 87 - p, _ = securejoin.SecureJoin(f.OwnerDid(), f.Name) 54 + // 1. [x] replace `RepoInfo` to `reporesolver.GetRepoInfo(r *http.Request, repo, user)` 55 + // 2. [x] remove `rr`, `CurrentDir`, `Ref` fields from `ResolvedRepo` 56 + // 3. [x] remove `ResolvedRepo` 57 + // 4. [ ] replace reporesolver to reposervice 58 + func (rr *RepoResolver) GetRepoInfo(r *http.Request, user *oauth.User) repoinfo.RepoInfo { 59 + ownerId, ook := r.Context().Value("resolvedId").(identity.Identity) 60 + repo, rok := r.Context().Value("repo").(*models.Repo) 61 + if !ook || !rok { 62 + log.Println("malformed request, failed to get repo from context") 88 63 } 89 64 90 - return p 91 - } 65 + // get dir/ref 66 + currentDir := path.Dir(extractPathAfterRef(r.URL.EscapedPath())) 67 + ref := chi.URLParam(r, "ref") 92 68 93 - func (f *ResolvedRepo) Collaborators(ctx context.Context) ([]pages.Collaborator, error) { 94 - repoCollaborators, err := f.rr.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot) 95 - if err != nil { 96 - return nil, err 69 + repoAt := repo.RepoAt() 70 + isStarred := false 71 + roles := repoinfo.RolesInRepo{} 72 + if user != nil { 73 + isStarred = db.GetStarStatus(rr.execer, user.Did, repoAt) 74 + roles.Roles = rr.enforcer.GetPermissionsInRepo(user.Did, repo.Knot, repo.DidSlashRepo()) 97 75 } 98 76 99 - var collaborators []pages.Collaborator 100 - for _, item := range repoCollaborators { 101 - // currently only two roles: owner and member 102 - var role string 103 - switch item[3] { 104 - case "repo:owner": 105 - role = "owner" 106 - case "repo:collaborator": 107 - role = "collaborator" 108 - default: 109 - continue 77 + stats := repo.RepoStats 78 + if stats == nil { 79 + starCount, err := db.GetStarCount(rr.execer, repoAt) 80 + if err != nil { 81 + log.Println("failed to get star count for ", repoAt) 110 82 } 111 - 112 - did := item[0] 113 - 114 - c := pages.Collaborator{ 115 - Did: did, 116 - Handle: "", 117 - Role: role, 83 + issueCount, err := db.GetIssueCount(rr.execer, repoAt) 84 + if err != nil { 85 + log.Println("failed to get issue count for ", repoAt) 118 86 } 119 - collaborators = append(collaborators, c) 120 - } 121 - 122 - // populate all collborators with handles 123 - identsToResolve := make([]string, len(collaborators)) 124 - for i, collab := range collaborators { 125 - identsToResolve[i] = collab.Did 126 - } 127 - 128 - resolvedIdents := f.rr.idResolver.ResolveIdents(ctx, identsToResolve) 129 - for i, resolved := range resolvedIdents { 130 - if resolved != nil { 131 - collaborators[i].Handle = resolved.Handle.String() 87 + pullCount, err := db.GetPullCount(rr.execer, repoAt) 88 + if err != nil { 89 + log.Println("failed to get pull count for ", repoAt) 132 90 } 133 - } 134 - 135 - return collaborators, nil 136 - } 137 - 138 - // this function is a bit weird since it now returns RepoInfo from an entirely different 139 - // package. we should refactor this or get rid of RepoInfo entirely. 140 - func (f *ResolvedRepo) RepoInfo(user *oauth.User) repoinfo.RepoInfo { 141 - repoAt := f.RepoAt() 142 - isStarred := false 143 - if user != nil { 144 - isStarred = db.GetStarStatus(f.rr.execer, user.Did, repoAt) 145 - } 146 - 147 - starCount, err := db.GetStarCount(f.rr.execer, repoAt) 148 - if err != nil { 149 - log.Println("failed to get star count for ", repoAt) 150 - } 151 - issueCount, err := db.GetIssueCount(f.rr.execer, repoAt) 152 - if err != nil { 153 - log.Println("failed to get issue count for ", repoAt) 154 - } 155 - pullCount, err := db.GetPullCount(f.rr.execer, repoAt) 156 - if err != nil { 157 - log.Println("failed to get issue count for ", repoAt) 158 - } 159 - source, err := db.GetRepoSource(f.rr.execer, repoAt) 160 - if errors.Is(err, sql.ErrNoRows) { 161 - source = "" 162 - } else if err != nil { 163 - log.Println("failed to get repo source for ", repoAt, err) 91 + stats = &models.RepoStats{ 92 + StarCount: starCount, 93 + IssueCount: issueCount, 94 + PullCount: pullCount, 95 + } 164 96 } 165 97 166 98 var sourceRepo *models.Repo 167 - if source != "" { 168 - sourceRepo, err = db.GetRepoByAtUri(f.rr.execer, source) 99 + var err error 100 + if repo.Source != "" { 101 + sourceRepo, err = db.GetRepoByAtUri(rr.execer, repo.Source) 169 102 if err != nil { 170 103 log.Println("failed to get repo by at uri", err) 171 104 } 172 105 } 173 106 174 - var sourceHandle *identity.Identity 175 - if sourceRepo != nil { 176 - sourceHandle, err = f.rr.idResolver.ResolveIdent(context.Background(), sourceRepo.Did) 177 - if err != nil { 178 - log.Println("failed to resolve source repo", err) 179 - } 180 - } 107 + repoInfo := repoinfo.RepoInfo{ 108 + // this is basically a models.Repo 109 + OwnerDid: ownerId.DID.String(), 110 + OwnerHandle: ownerId.Handle.String(), 111 + Name: repo.Name, 112 + Rkey: repo.Rkey, 113 + Description: repo.Description, 114 + Website: repo.Website, 115 + Topics: repo.Topics, 116 + Knot: repo.Knot, 117 + Spindle: repo.Spindle, 118 + Stats: *stats, 181 119 182 - knot := f.Knot 120 + // fork repo upstream 121 + Source: sourceRepo, 183 122 184 - repoInfo := repoinfo.RepoInfo{ 185 - OwnerDid: f.OwnerDid(), 186 - OwnerHandle: f.OwnerHandle(), 187 - Name: f.Name, 188 - Rkey: f.Repo.Rkey, 189 - RepoAt: repoAt, 190 - Description: f.Description, 191 - IsStarred: isStarred, 192 - Knot: knot, 193 - Spindle: f.Spindle, 194 - Roles: f.RolesInRepo(user), 195 - Stats: models.RepoStats{ 196 - StarCount: starCount, 197 - IssueCount: issueCount, 198 - PullCount: pullCount, 199 - }, 200 - CurrentDir: f.CurrentDir, 201 - Ref: f.Ref, 202 - } 123 + // page context 124 + CurrentDir: currentDir, 125 + Ref: ref, 203 126 204 - if sourceRepo != nil { 205 - repoInfo.Source = sourceRepo 206 - repoInfo.SourceHandle = sourceHandle.Handle.String() 127 + // info related to the session 128 + IsStarred: isStarred, 129 + Roles: roles, 207 130 } 208 131 209 132 return repoInfo 210 - } 211 - 212 - func (f *ResolvedRepo) RolesInRepo(u *oauth.User) repoinfo.RolesInRepo { 213 - if u != nil { 214 - r := f.rr.enforcer.GetPermissionsInRepo(u.Did, f.Knot, f.DidSlashRepo()) 215 - return repoinfo.RolesInRepo{Roles: r} 216 - } else { 217 - return repoinfo.RolesInRepo{} 218 - } 219 133 } 220 134 221 135 // extractPathAfterRef gets the actual repository path
+5 -4
appview/serververify/verify.go
··· 9 9 "tangled.org/core/api/tangled" 10 10 "tangled.org/core/appview/db" 11 11 "tangled.org/core/appview/xrpcclient" 12 + "tangled.org/core/orm" 12 13 "tangled.org/core/rbac" 13 14 ) 14 15 ··· 76 77 // mark this spindle as verified in the db 77 78 rowId, err := db.VerifySpindle( 78 79 tx, 79 - db.FilterEq("owner", owner), 80 - db.FilterEq("instance", instance), 80 + orm.FilterEq("owner", owner), 81 + orm.FilterEq("instance", instance), 81 82 ) 82 83 if err != nil { 83 84 return 0, fmt.Errorf("failed to write to DB: %w", err) ··· 115 116 // mark as registered 116 117 err = db.MarkRegistered( 117 118 tx, 118 - db.FilterEq("did", owner), 119 - db.FilterEq("domain", domain), 119 + orm.FilterEq("did", owner), 120 + orm.FilterEq("domain", domain), 120 121 ) 121 122 if err != nil { 122 123 return fmt.Errorf("failed to register domain: %w", err)
+6 -2
appview/settings/settings.go
··· 22 22 "tangled.org/core/tid" 23 23 24 24 comatproto "github.com/bluesky-social/indigo/api/atproto" 25 + "github.com/bluesky-social/indigo/atproto/syntax" 25 26 lexutil "github.com/bluesky-social/indigo/lex/util" 26 27 "github.com/gliderlabs/ssh" 27 28 "github.com/google/uuid" ··· 42 43 {"Name": "keys", "Icon": "key"}, 43 44 {"Name": "emails", "Icon": "mail"}, 44 45 {"Name": "notifications", "Icon": "bell"}, 46 + {"Name": "knots", "Icon": "volleyball"}, 47 + {"Name": "spindles", "Icon": "spool"}, 45 48 } 46 49 ) 47 50 ··· 91 94 user := s.OAuth.GetUser(r) 92 95 did := s.OAuth.GetDid(r) 93 96 94 - prefs, err := s.Db.GetNotificationPreferences(r.Context(), did) 97 + prefs, err := db.GetNotificationPreference(s.Db, did) 95 98 if err != nil { 96 99 log.Printf("failed to get notification preferences: %s", err) 97 100 s.Pages.Notice(w, "settings-notifications-error", "Unable to load notification preferences.") ··· 110 113 did := s.OAuth.GetDid(r) 111 114 112 115 prefs := &models.NotificationPreferences{ 113 - UserDid: did, 116 + UserDid: syntax.DID(did), 114 117 RepoStarred: r.FormValue("repo_starred") == "on", 115 118 IssueCreated: r.FormValue("issue_created") == "on", 116 119 IssueCommented: r.FormValue("issue_commented") == "on", ··· 119 122 PullCommented: r.FormValue("pull_commented") == "on", 120 123 PullMerged: r.FormValue("pull_merged") == "on", 121 124 Followed: r.FormValue("followed") == "on", 125 + UserMentioned: r.FormValue("user_mentioned") == "on", 122 126 EmailNotifications: r.FormValue("email_notifications") == "on", 123 127 } 124 128
+18
appview/signup/requests.go
··· 102 102 103 103 return result.DID, nil 104 104 } 105 + 106 + func (s *Signup) deleteAccountRequest(did string) error { 107 + body := map[string]string{ 108 + "did": did, 109 + } 110 + 111 + resp, err := s.makePdsRequest("POST", "com.atproto.admin.deleteAccount", body, true) 112 + if err != nil { 113 + return err 114 + } 115 + defer resp.Body.Close() 116 + 117 + if resp.StatusCode != http.StatusOK { 118 + return s.handlePdsError(resp, "delete account") 119 + } 120 + 121 + return nil 122 + }
+94 -37
appview/signup/signup.go
··· 2 2 3 3 import ( 4 4 "bufio" 5 + "context" 5 6 "encoding/json" 6 7 "errors" 7 8 "fmt" ··· 62 63 disallowed := make(map[string]bool) 63 64 64 65 if filepath == "" { 65 - logger.Debug("no disallowed nicknames file configured") 66 + logger.Warn("no disallowed nicknames file configured") 66 67 return disallowed 67 68 } 68 69 ··· 216 217 return 217 218 } 218 219 219 - did, err := s.createAccountRequest(username, password, email, code) 220 - if err != nil { 221 - s.l.Error("failed to create account", "error", err) 222 - s.pages.Notice(w, "signup-error", err.Error()) 223 - return 224 - } 225 - 226 220 if s.cf == nil { 227 221 s.l.Error("cloudflare client is nil", "error", "Cloudflare integration is not enabled in configuration") 228 222 s.pages.Notice(w, "signup-error", "Account signup is currently disabled. DNS record creation is not available. Please contact support.") 229 223 return 230 224 } 231 225 232 - err = s.cf.CreateDNSRecord(r.Context(), dns.Record{ 233 - Type: "TXT", 234 - Name: "_atproto." + username, 235 - Content: fmt.Sprintf(`"did=%s"`, did), 236 - TTL: 6400, 237 - Proxied: false, 238 - }) 226 + // Execute signup transactionally with rollback capability 227 + err = s.executeSignupTransaction(r.Context(), username, password, email, code, w) 239 228 if err != nil { 240 - s.l.Error("failed to create DNS record", "error", err) 241 - s.pages.Notice(w, "signup-error", "Failed to create DNS record for your handle. Please contact support.") 229 + // Error already logged and notice already sent 242 230 return 243 231 } 232 + } 233 + } 234 + 235 + // executeSignupTransaction performs the signup process transactionally with rollback 236 + func (s *Signup) executeSignupTransaction(ctx context.Context, username, password, email, code string, w http.ResponseWriter) error { 237 + var recordID string 238 + var did string 239 + var emailAdded bool 240 + 241 + success := false 242 + defer func() { 243 + if !success { 244 + s.l.Info("rolling back signup transaction", "username", username, "did", did) 244 245 245 - err = db.AddEmail(s.db, models.Email{ 246 - Did: did, 247 - Address: email, 248 - Verified: true, 249 - Primary: true, 250 - }) 251 - if err != nil { 252 - s.l.Error("failed to add email", "error", err) 253 - s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.") 254 - return 246 + // Rollback DNS record 247 + if recordID != "" { 248 + if err := s.cf.DeleteDNSRecord(ctx, recordID); err != nil { 249 + s.l.Error("failed to rollback DNS record", "error", err, "recordID", recordID) 250 + } else { 251 + s.l.Info("successfully rolled back DNS record", "recordID", recordID) 252 + } 253 + } 254 + 255 + // Rollback PDS account 256 + if did != "" { 257 + if err := s.deleteAccountRequest(did); err != nil { 258 + s.l.Error("failed to rollback PDS account", "error", err, "did", did) 259 + } else { 260 + s.l.Info("successfully rolled back PDS account", "did", did) 261 + } 262 + } 263 + 264 + // Rollback email from database 265 + if emailAdded { 266 + if err := db.DeleteEmail(s.db, did, email); err != nil { 267 + s.l.Error("failed to rollback email from database", "error", err, "email", email) 268 + } else { 269 + s.l.Info("successfully rolled back email from database", "email", email) 270 + } 271 + } 255 272 } 273 + }() 256 274 257 - s.pages.Notice(w, "signup-msg", fmt.Sprintf(`Account created successfully. You can now 258 - <a class="underline text-black dark:text-white" href="/login">login</a> 259 - with <code>%s.tngl.sh</code>.`, username)) 275 + // step 1: create account in PDS 276 + did, err := s.createAccountRequest(username, password, email, code) 277 + if err != nil { 278 + s.l.Error("failed to create account", "error", err) 279 + s.pages.Notice(w, "signup-error", err.Error()) 280 + return err 281 + } 282 + 283 + // step 2: create DNS record with actual DID 284 + recordID, err = s.cf.CreateDNSRecord(ctx, dns.Record{ 285 + Type: "TXT", 286 + Name: "_atproto." + username, 287 + Content: fmt.Sprintf(`"did=%s"`, did), 288 + TTL: 6400, 289 + Proxied: false, 290 + }) 291 + if err != nil { 292 + s.l.Error("failed to create DNS record", "error", err) 293 + s.pages.Notice(w, "signup-error", "Failed to create DNS record for your handle. Please contact support.") 294 + return err 295 + } 260 296 261 - go func() { 262 - err := db.DeleteInflightSignup(s.db, email) 263 - if err != nil { 264 - s.l.Error("failed to delete inflight signup", "error", err) 265 - } 266 - }() 267 - return 297 + // step 3: add email to database 298 + err = db.AddEmail(s.db, models.Email{ 299 + Did: did, 300 + Address: email, 301 + Verified: true, 302 + Primary: true, 303 + }) 304 + if err != nil { 305 + s.l.Error("failed to add email", "error", err) 306 + s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.") 307 + return err 268 308 } 309 + emailAdded = true 310 + 311 + // if we get here, we've successfully created the account and added the email 312 + success = true 313 + 314 + s.pages.Notice(w, "signup-msg", fmt.Sprintf(`Account created successfully. You can now 315 + <a class="underline text-black dark:text-white" href="/login">login</a> 316 + with <code>%s.tngl.sh</code>.`, username)) 317 + 318 + // clean up inflight signup asynchronously 319 + go func() { 320 + if err := db.DeleteInflightSignup(s.db, email); err != nil { 321 + s.l.Error("failed to delete inflight signup", "error", err) 322 + } 323 + }() 324 + 325 + return nil 269 326 } 270 327 271 328 type turnstileResponse struct {
+53 -26
appview/spindles/spindles.go
··· 6 6 "log/slog" 7 7 "net/http" 8 8 "slices" 9 + "strings" 9 10 "time" 10 11 11 12 "github.com/go-chi/chi/v5" ··· 19 20 "tangled.org/core/appview/serververify" 20 21 "tangled.org/core/appview/xrpcclient" 21 22 "tangled.org/core/idresolver" 23 + "tangled.org/core/orm" 22 24 "tangled.org/core/rbac" 23 25 "tangled.org/core/tid" 24 26 ··· 37 39 Logger *slog.Logger 38 40 } 39 41 42 + type tab = map[string]any 43 + 44 + var ( 45 + spindlesTabs []tab = []tab{ 46 + {"Name": "profile", "Icon": "user"}, 47 + {"Name": "keys", "Icon": "key"}, 48 + {"Name": "emails", "Icon": "mail"}, 49 + {"Name": "notifications", "Icon": "bell"}, 50 + {"Name": "knots", "Icon": "volleyball"}, 51 + {"Name": "spindles", "Icon": "spool"}, 52 + } 53 + ) 54 + 40 55 func (s *Spindles) Router() http.Handler { 41 56 r := chi.NewRouter() 42 57 ··· 57 72 user := s.OAuth.GetUser(r) 58 73 all, err := db.GetSpindles( 59 74 s.Db, 60 - db.FilterEq("owner", user.Did), 75 + orm.FilterEq("owner", user.Did), 61 76 ) 62 77 if err != nil { 63 78 s.Logger.Error("failed to fetch spindles", "err", err) ··· 68 83 s.Pages.Spindles(w, pages.SpindlesParams{ 69 84 LoggedInUser: user, 70 85 Spindles: all, 86 + Tabs: spindlesTabs, 87 + Tab: "spindles", 71 88 }) 72 89 } 73 90 ··· 85 102 86 103 spindles, err := db.GetSpindles( 87 104 s.Db, 88 - db.FilterEq("instance", instance), 89 - db.FilterEq("owner", user.Did), 90 - db.FilterIsNot("verified", "null"), 105 + orm.FilterEq("instance", instance), 106 + orm.FilterEq("owner", user.Did), 107 + orm.FilterIsNot("verified", "null"), 91 108 ) 92 109 if err != nil || len(spindles) != 1 { 93 110 l.Error("failed to get spindle", "err", err, "len(spindles)", len(spindles)) ··· 107 124 repos, err := db.GetRepos( 108 125 s.Db, 109 126 0, 110 - db.FilterEq("spindle", instance), 127 + orm.FilterEq("spindle", instance), 111 128 ) 112 129 if err != nil { 113 130 l.Error("failed to get spindle repos", "err", err) ··· 126 143 Spindle: spindle, 127 144 Members: members, 128 145 Repos: repoMap, 146 + Tabs: spindlesTabs, 147 + Tab: "spindles", 129 148 }) 130 149 } 131 150 ··· 146 165 } 147 166 148 167 instance := r.FormValue("instance") 168 + // Strip protocol, trailing slashes, and whitespace 169 + // Rkey cannot contain slashes 170 + instance = strings.TrimSpace(instance) 171 + instance = strings.TrimPrefix(instance, "https://") 172 + instance = strings.TrimPrefix(instance, "http://") 173 + instance = strings.TrimSuffix(instance, "/") 149 174 if instance == "" { 150 175 s.Pages.Notice(w, noticeId, "Incomplete form.") 151 176 return ··· 266 291 267 292 spindles, err := db.GetSpindles( 268 293 s.Db, 269 - db.FilterEq("owner", user.Did), 270 - db.FilterEq("instance", instance), 294 + orm.FilterEq("owner", user.Did), 295 + orm.FilterEq("instance", instance), 271 296 ) 272 297 if err != nil || len(spindles) != 1 { 273 298 l.Error("failed to retrieve instance", "err", err, "len(spindles)", len(spindles)) ··· 295 320 // remove spindle members first 296 321 err = db.RemoveSpindleMember( 297 322 tx, 298 - db.FilterEq("did", user.Did), 299 - db.FilterEq("instance", instance), 323 + orm.FilterEq("did", user.Did), 324 + orm.FilterEq("instance", instance), 300 325 ) 301 326 if err != nil { 302 327 l.Error("failed to remove spindle members", "err", err) ··· 306 331 307 332 err = db.DeleteSpindle( 308 333 tx, 309 - db.FilterEq("owner", user.Did), 310 - db.FilterEq("instance", instance), 334 + orm.FilterEq("owner", user.Did), 335 + orm.FilterEq("instance", instance), 311 336 ) 312 337 if err != nil { 313 338 l.Error("failed to delete spindle", "err", err) ··· 358 383 359 384 shouldRedirect := r.Header.Get("shouldRedirect") 360 385 if shouldRedirect == "true" { 361 - s.Pages.HxRedirect(w, "/spindles") 386 + s.Pages.HxRedirect(w, "/settings/spindles") 362 387 return 363 388 } 364 389 ··· 386 411 387 412 spindles, err := db.GetSpindles( 388 413 s.Db, 389 - db.FilterEq("owner", user.Did), 390 - db.FilterEq("instance", instance), 414 + orm.FilterEq("owner", user.Did), 415 + orm.FilterEq("instance", instance), 391 416 ) 392 417 if err != nil || len(spindles) != 1 { 393 418 l.Error("failed to retrieve instance", "err", err, "len(spindles)", len(spindles)) ··· 429 454 430 455 verifiedSpindle, err := db.GetSpindles( 431 456 s.Db, 432 - db.FilterEq("id", rowId), 457 + orm.FilterEq("id", rowId), 433 458 ) 434 459 if err != nil || len(verifiedSpindle) != 1 { 435 460 l.Error("failed get new spindle", "err", err) ··· 462 487 463 488 spindles, err := db.GetSpindles( 464 489 s.Db, 465 - db.FilterEq("owner", user.Did), 466 - db.FilterEq("instance", instance), 490 + orm.FilterEq("owner", user.Did), 491 + orm.FilterEq("instance", instance), 467 492 ) 468 493 if err != nil || len(spindles) != 1 { 469 494 l.Error("failed to retrieve instance", "err", err, "len(spindles)", len(spindles)) ··· 484 509 } 485 510 486 511 member := r.FormValue("member") 512 + member = strings.TrimPrefix(member, "@") 487 513 if member == "" { 488 514 l.Error("empty member") 489 515 s.Pages.Notice(w, noticeId, "Failed to add member, empty form.") ··· 573 599 } 574 600 575 601 // success 576 - s.Pages.HxRedirect(w, fmt.Sprintf("/spindles/%s", instance)) 602 + s.Pages.HxRedirect(w, fmt.Sprintf("/settings/spindles/%s", instance)) 577 603 } 578 604 579 605 func (s *Spindles) removeMember(w http.ResponseWriter, r *http.Request) { ··· 597 623 598 624 spindles, err := db.GetSpindles( 599 625 s.Db, 600 - db.FilterEq("owner", user.Did), 601 - db.FilterEq("instance", instance), 626 + orm.FilterEq("owner", user.Did), 627 + orm.FilterEq("instance", instance), 602 628 ) 603 629 if err != nil || len(spindles) != 1 { 604 630 l.Error("failed to retrieve instance", "err", err, "len(spindles)", len(spindles)) ··· 613 639 } 614 640 615 641 member := r.FormValue("member") 642 + member = strings.TrimPrefix(member, "@") 616 643 if member == "" { 617 644 l.Error("empty member") 618 645 s.Pages.Notice(w, noticeId, "Failed to remove member, empty form.") ··· 646 673 // get the record from the DB first: 647 674 members, err := db.GetSpindleMembers( 648 675 s.Db, 649 - db.FilterEq("did", user.Did), 650 - db.FilterEq("instance", instance), 651 - db.FilterEq("subject", memberId.DID), 676 + orm.FilterEq("did", user.Did), 677 + orm.FilterEq("instance", instance), 678 + orm.FilterEq("subject", memberId.DID), 652 679 ) 653 680 if err != nil || len(members) != 1 { 654 681 l.Error("failed to get member", "err", err) ··· 659 686 // remove from db 660 687 if err = db.RemoveSpindleMember( 661 688 tx, 662 - db.FilterEq("did", user.Did), 663 - db.FilterEq("instance", instance), 664 - db.FilterEq("subject", memberId.DID), 689 + orm.FilterEq("did", user.Did), 690 + orm.FilterEq("instance", instance), 691 + orm.FilterEq("subject", memberId.DID), 665 692 ); err != nil { 666 693 l.Error("failed to remove spindle member", "err", err) 667 694 fail()
+1
appview/state/follow.go
··· 26 26 subjectIdent, err := s.idResolver.ResolveIdent(r.Context(), subject) 27 27 if err != nil { 28 28 log.Println("failed to follow, invalid did") 29 + return 29 30 } 30 31 31 32 if currentUser.Did == subjectIdent.DID.String() {
+16 -12
appview/state/gfi.go
··· 1 1 package state 2 2 3 3 import ( 4 - "fmt" 5 4 "log" 6 5 "net/http" 7 6 "sort" 8 7 9 8 "github.com/bluesky-social/indigo/atproto/syntax" 10 - "tangled.org/core/api/tangled" 11 9 "tangled.org/core/appview/db" 12 10 "tangled.org/core/appview/models" 13 11 "tangled.org/core/appview/pages" 14 12 "tangled.org/core/appview/pagination" 15 13 "tangled.org/core/consts" 14 + "tangled.org/core/orm" 16 15 ) 17 16 18 17 func (s *State) GoodFirstIssues(w http.ResponseWriter, r *http.Request) { 19 18 user := s.oauth.GetUser(r) 20 19 21 - page, ok := r.Context().Value("page").(pagination.Page) 22 - if !ok { 23 - page = pagination.FirstPage() 24 - } 20 + page := pagination.FromContext(r.Context()) 25 21 26 - goodFirstIssueLabel := fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "good-first-issue") 22 + goodFirstIssueLabel := s.config.Label.GoodFirstIssue 27 23 28 - repoLabels, err := db.GetRepoLabels(s.db, db.FilterEq("label_at", goodFirstIssueLabel)) 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)) 29 32 if err != nil { 30 33 log.Println("failed to get repo labels", err) 31 34 s.pages.Error503(w) ··· 38 41 RepoGroups: []*models.RepoGroup{}, 39 42 LabelDefs: make(map[string]*models.LabelDefinition), 40 43 Page: page, 44 + GfiLabel: gfiLabelDef, 41 45 }) 42 46 return 43 47 } ··· 52 56 pagination.Page{ 53 57 Limit: 500, 54 58 }, 55 - db.FilterIn("repo_at", repoUris), 56 - db.FilterEq("open", 1), 59 + orm.FilterIn("repo_at", repoUris), 60 + orm.FilterEq("open", 1), 57 61 ) 58 62 if err != nil { 59 63 log.Println("failed to get issues", err) ··· 129 133 } 130 134 131 135 if len(uriList) > 0 { 132 - allLabelDefs, err = db.GetLabelDefinitions(s.db, db.FilterIn("at_uri", uriList)) 136 + allLabelDefs, err = db.GetLabelDefinitions(s.db, orm.FilterIn("at_uri", uriList)) 133 137 if err != nil { 134 138 log.Println("failed to fetch labels", err) 135 139 } ··· 146 150 RepoGroups: paginatedGroups, 147 151 LabelDefs: labelDefsMap, 148 152 Page: page, 149 - GfiLabel: labelDefsMap[goodFirstIssueLabel], 153 + GfiLabel: gfiLabelDef, 150 154 }) 151 155 }
+17
appview/state/git_http.go
··· 25 25 26 26 } 27 27 28 + func (s *State) UploadArchive(w http.ResponseWriter, r *http.Request) { 29 + user, ok := r.Context().Value("resolvedId").(identity.Identity) 30 + if !ok { 31 + http.Error(w, "failed to resolve user", http.StatusInternalServerError) 32 + return 33 + } 34 + repo := r.Context().Value("repo").(*models.Repo) 35 + 36 + scheme := "https" 37 + if s.config.Core.Dev { 38 + scheme = "http" 39 + } 40 + 41 + targetURL := fmt.Sprintf("%s://%s/%s/%s/git-upload-archive?%s", scheme, repo.Knot, user.DID, repo.Name, r.URL.RawQuery) 42 + s.proxyRequest(w, r, targetURL) 43 + } 44 + 28 45 func (s *State) UploadPack(w http.ResponseWriter, r *http.Request) { 29 46 user, ok := r.Context().Value("resolvedId").(identity.Identity) 30 47 if !ok {
+9 -6
appview/state/knotstream.go
··· 16 16 ec "tangled.org/core/eventconsumer" 17 17 "tangled.org/core/eventconsumer/cursor" 18 18 "tangled.org/core/log" 19 + "tangled.org/core/orm" 19 20 "tangled.org/core/rbac" 20 21 "tangled.org/core/workflow" 21 22 ··· 25 26 ) 26 27 27 28 func Knotstream(ctx context.Context, c *config.Config, d *db.DB, enforcer *rbac.Enforcer, posthog posthog.Client) (*ec.Consumer, error) { 29 + logger := log.FromContext(ctx) 30 + logger = log.SubLogger(logger, "knotstream") 31 + 28 32 knots, err := db.GetRegistrations( 29 33 d, 30 - db.FilterIsNot("registered", "null"), 34 + orm.FilterIsNot("registered", "null"), 31 35 ) 32 36 if err != nil { 33 37 return nil, err ··· 39 43 srcs[s] = struct{}{} 40 44 } 41 45 42 - logger := log.New("knotstream") 43 46 cache := cache.New(c.Redis.Addr) 44 47 cursorStore := cursor.NewRedisCursorStore(cache) 45 48 ··· 141 144 repos, err := db.GetRepos( 142 145 d, 143 146 0, 144 - db.FilterEq("did", record.RepoDid), 145 - db.FilterEq("name", record.RepoName), 147 + orm.FilterEq("did", record.RepoDid), 148 + orm.FilterEq("name", record.RepoName), 146 149 ) 147 150 if err != nil { 148 151 return fmt.Errorf("failed to look for repo in DB (%s/%s): %w", record.RepoDid, record.RepoName, err) ··· 207 210 repos, err := db.GetRepos( 208 211 d, 209 212 0, 210 - db.FilterEq("did", record.TriggerMetadata.Repo.Did), 211 - db.FilterEq("name", record.TriggerMetadata.Repo.Repo), 213 + orm.FilterEq("did", record.TriggerMetadata.Repo.Did), 214 + orm.FilterEq("name", record.TriggerMetadata.Repo.Repo), 212 215 ) 213 216 if err != nil { 214 217 return fmt.Errorf("failed to look for repo in DB: nsid %s, rkey %s, %w", msg.Nsid, msg.Rkey, err)
+10 -4
appview/state/login.go
··· 2 2 3 3 import ( 4 4 "fmt" 5 - "log" 6 5 "net/http" 7 6 "strings" 8 7 ··· 10 9 ) 11 10 12 11 func (s *State) Login(w http.ResponseWriter, r *http.Request) { 12 + l := s.logger.With("handler", "Login") 13 + 13 14 switch r.Method { 14 15 case http.MethodGet: 15 16 returnURL := r.URL.Query().Get("return_url") 17 + errorCode := r.URL.Query().Get("error") 16 18 s.pages.Login(w, pages.LoginParams{ 17 19 ReturnUrl: returnURL, 20 + ErrorCode: errorCode, 18 21 }) 19 22 case http.MethodPost: 20 23 handle := r.FormValue("handle") ··· 32 35 33 36 // basic handle validation 34 37 if !strings.Contains(handle, ".") { 35 - log.Println("invalid handle format", "raw", handle) 38 + l.Error("invalid handle format", "raw", handle) 36 39 s.pages.Notice( 37 40 w, 38 41 "login-msg", ··· 43 46 44 47 redirectURL, err := s.oauth.ClientApp.StartAuthFlow(r.Context(), handle) 45 48 if err != nil { 49 + l.Error("failed to start auth", "err", err) 46 50 http.Error(w, err.Error(), http.StatusInternalServerError) 47 51 return 48 52 } ··· 52 56 } 53 57 54 58 func (s *State) Logout(w http.ResponseWriter, r *http.Request) { 59 + l := s.logger.With("handler", "Logout") 60 + 55 61 err := s.oauth.DeleteSession(w, r) 56 62 if err != nil { 57 - log.Println("failed to logout", "err", err) 63 + l.Error("failed to logout", "err", err) 58 64 } else { 59 - log.Println("logged out successfully") 65 + l.Info("logged out successfully") 60 66 } 61 67 62 68 s.pages.HxRedirect(w, "/login")
+30 -21
appview/state/profile.go
··· 19 19 "tangled.org/core/appview/db" 20 20 "tangled.org/core/appview/models" 21 21 "tangled.org/core/appview/pages" 22 + "tangled.org/core/orm" 22 23 ) 23 24 24 25 func (s *State) Profile(w http.ResponseWriter, r *http.Request) { ··· 56 57 return nil, fmt.Errorf("failed to get profile: %w", err) 57 58 } 58 59 59 - repoCount, err := db.CountRepos(s.db, db.FilterEq("did", did)) 60 + repoCount, err := db.CountRepos(s.db, orm.FilterEq("did", did)) 60 61 if err != nil { 61 62 return nil, fmt.Errorf("failed to get repo count: %w", err) 62 63 } 63 64 64 - stringCount, err := db.CountStrings(s.db, db.FilterEq("did", did)) 65 + stringCount, err := db.CountStrings(s.db, orm.FilterEq("did", did)) 65 66 if err != nil { 66 67 return nil, fmt.Errorf("failed to get string count: %w", err) 67 68 } 68 69 69 - starredCount, err := db.CountStars(s.db, db.FilterEq("starred_by_did", did)) 70 + starredCount, err := db.CountStars(s.db, orm.FilterEq("did", did)) 70 71 if err != nil { 71 72 return nil, fmt.Errorf("failed to get starred repo count: %w", err) 72 73 } ··· 86 87 startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC) 87 88 punchcard, err := db.MakePunchcard( 88 89 s.db, 89 - db.FilterEq("did", did), 90 - db.FilterGte("date", startOfYear.Format(time.DateOnly)), 91 - db.FilterLte("date", now.Format(time.DateOnly)), 90 + orm.FilterEq("did", did), 91 + orm.FilterGte("date", startOfYear.Format(time.DateOnly)), 92 + orm.FilterLte("date", now.Format(time.DateOnly)), 92 93 ) 93 94 if err != nil { 94 95 return nil, fmt.Errorf("failed to get punchcard for %s: %w", did, err) ··· 96 97 97 98 return &pages.ProfileCard{ 98 99 UserDid: did, 99 - UserHandle: ident.Handle.String(), 100 100 Profile: profile, 101 101 FollowStatus: followStatus, 102 102 Stats: pages.ProfileStats{ ··· 119 119 s.pages.Error500(w) 120 120 return 121 121 } 122 - l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 122 + l = l.With("profileDid", profile.UserDid) 123 123 124 124 repos, err := db.GetRepos( 125 125 s.db, 126 126 0, 127 - db.FilterEq("did", profile.UserDid), 127 + orm.FilterEq("did", profile.UserDid), 128 128 ) 129 129 if err != nil { 130 130 l.Error("failed to fetch repos", "err", err) ··· 162 162 l.Error("failed to create timeline", "err", err) 163 163 } 164 164 165 + // populate commit counts in the timeline, using the punchcard 166 + currentMonth := time.Now().Month() 167 + for _, p := range profile.Punchcard.Punches { 168 + idx := currentMonth - p.Date.Month() 169 + if int(idx) < len(timeline.ByMonth) { 170 + timeline.ByMonth[idx].Commits += p.Count 171 + } 172 + } 173 + 165 174 s.pages.ProfileOverview(w, pages.ProfileOverviewParams{ 166 175 LoggedInUser: s.oauth.GetUser(r), 167 176 Card: profile, ··· 180 189 s.pages.Error500(w) 181 190 return 182 191 } 183 - l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 192 + l = l.With("profileDid", profile.UserDid) 184 193 185 194 repos, err := db.GetRepos( 186 195 s.db, 187 196 0, 188 - db.FilterEq("did", profile.UserDid), 197 + orm.FilterEq("did", profile.UserDid), 189 198 ) 190 199 if err != nil { 191 200 l.Error("failed to get repos", "err", err) ··· 209 218 s.pages.Error500(w) 210 219 return 211 220 } 212 - l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 221 + l = l.With("profileDid", profile.UserDid) 213 222 214 - stars, err := db.GetStars(s.db, 0, db.FilterEq("starred_by_did", profile.UserDid)) 223 + stars, err := db.GetRepoStars(s.db, 0, orm.FilterEq("did", profile.UserDid)) 215 224 if err != nil { 216 225 l.Error("failed to get stars", "err", err) 217 226 s.pages.Error500(w) ··· 219 228 } 220 229 var repos []models.Repo 221 230 for _, s := range stars { 222 - if s.Repo != nil { 223 - repos = append(repos, *s.Repo) 224 - } 231 + repos = append(repos, *s.Repo) 225 232 } 226 233 227 234 err = s.pages.ProfileStarred(w, pages.ProfileStarredParams{ ··· 240 247 s.pages.Error500(w) 241 248 return 242 249 } 243 - l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 250 + l = l.With("profileDid", profile.UserDid) 244 251 245 - strings, err := db.GetStrings(s.db, 0, db.FilterEq("did", profile.UserDid)) 252 + strings, err := db.GetStrings(s.db, 0, orm.FilterEq("did", profile.UserDid)) 246 253 if err != nil { 247 254 l.Error("failed to get strings", "err", err) 248 255 s.pages.Error500(w) ··· 272 279 if err != nil { 273 280 return nil, err 274 281 } 275 - l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 282 + l = l.With("profileDid", profile.UserDid) 276 283 277 284 loggedInUser := s.oauth.GetUser(r) 278 285 params := FollowsPageParams{ ··· 294 301 followDids = append(followDids, extractDid(follow)) 295 302 } 296 303 297 - profiles, err := db.GetProfiles(s.db, db.FilterIn("did", followDids)) 304 + profiles, err := db.GetProfiles(s.db, orm.FilterIn("did", followDids)) 298 305 if err != nil { 299 306 l.Error("failed to get profiles", "followDids", followDids, "err", err) 300 307 return &params, err ··· 538 545 profile.Description = r.FormValue("description") 539 546 profile.IncludeBluesky = r.FormValue("includeBluesky") == "on" 540 547 profile.Location = r.FormValue("location") 548 + profile.Pronouns = r.FormValue("pronouns") 541 549 542 550 var links [5]string 543 551 for i := range 5 { ··· 652 660 Location: &profile.Location, 653 661 PinnedRepositories: pinnedRepoStrings, 654 662 Stats: vanityStats[:], 663 + Pronouns: &profile.Pronouns, 655 664 }}, 656 665 SwapRecord: cid, 657 666 }) ··· 695 704 log.Printf("getting profile data for %s: %s", user.Did, err) 696 705 } 697 706 698 - repos, err := db.GetRepos(s.db, 0, db.FilterEq("did", user.Did)) 707 + repos, err := db.GetRepos(s.db, 0, orm.FilterEq("did", user.Did)) 699 708 if err != nil { 700 709 log.Printf("getting repos for %s: %s", user.Did, err) 701 710 }
+112 -52
appview/state/router.go
··· 42 42 43 43 router.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) { 44 44 pat := chi.URLParam(r, "*") 45 - if strings.HasPrefix(pat, "did:") || strings.HasPrefix(pat, "@") { 46 - userRouter.ServeHTTP(w, r) 47 - } else { 48 - // Check if the first path element is a valid handle without '@' or a flattened DID 49 - pathParts := strings.SplitN(pat, "/", 2) 50 - if len(pathParts) > 0 { 51 - if userutil.IsHandleNoAt(pathParts[0]) { 52 - // Redirect to the same path but with '@' prefixed to the handle 53 - redirectPath := "@" + pat 54 - http.Redirect(w, r, "/"+redirectPath, http.StatusFound) 55 - return 56 - } else if userutil.IsFlattenedDid(pathParts[0]) { 57 - // Redirect to the unflattened DID version 58 - unflattenedDid := userutil.UnflattenDid(pathParts[0]) 59 - var redirectPath string 60 - if len(pathParts) > 1 { 61 - redirectPath = unflattenedDid + "/" + pathParts[1] 62 - } else { 63 - redirectPath = unflattenedDid 64 - } 65 - http.Redirect(w, r, "/"+redirectPath, http.StatusFound) 66 - return 67 - } 45 + pathParts := strings.SplitN(pat, "/", 2) 46 + 47 + if len(pathParts) > 0 { 48 + firstPart := pathParts[0] 49 + 50 + // if using a DID or handle, just continue as per usual 51 + if userutil.IsDid(firstPart) || userutil.IsHandle(firstPart) { 52 + userRouter.ServeHTTP(w, r) 53 + return 68 54 } 69 - standardRouter.ServeHTTP(w, r) 55 + 56 + // if using a flattened DID (like you would in go modules), unflatten 57 + if userutil.IsFlattenedDid(firstPart) { 58 + unflattenedDid := userutil.UnflattenDid(firstPart) 59 + redirectPath := strings.Join(append([]string{unflattenedDid}, pathParts[1:]...), "/") 60 + 61 + redirectURL := *r.URL 62 + redirectURL.Path = "/" + redirectPath 63 + 64 + http.Redirect(w, r, redirectURL.String(), http.StatusFound) 65 + return 66 + } 67 + 68 + // if using a handle with @, rewrite to work without @ 69 + if normalized := strings.TrimPrefix(firstPart, "@"); userutil.IsHandle(normalized) { 70 + redirectPath := strings.Join(append([]string{normalized}, pathParts[1:]...), "/") 71 + 72 + redirectURL := *r.URL 73 + redirectURL.Path = "/" + redirectPath 74 + 75 + http.Redirect(w, r, redirectURL.String(), http.StatusFound) 76 + return 77 + } 78 + 70 79 } 80 + 81 + standardRouter.ServeHTTP(w, r) 71 82 }) 72 83 73 84 return router ··· 80 91 r.Get("/", s.Profile) 81 92 r.Get("/feed.atom", s.AtomFeedPage) 82 93 83 - // redirect /@handle/repo.git -> /@handle/repo 84 - r.Get("/{repo}.git", func(w http.ResponseWriter, r *http.Request) { 85 - nonDotGitPath := strings.TrimSuffix(r.URL.Path, ".git") 86 - http.Redirect(w, r, nonDotGitPath, http.StatusMovedPermanently) 87 - }) 88 - 89 94 r.With(mw.ResolveRepo()).Route("/{repo}", func(r chi.Router) { 90 95 r.Use(mw.GoImport()) 91 96 r.Mount("/", s.RepoRouter(mw)) 92 97 r.Mount("/issues", s.IssuesRouter(mw)) 93 98 r.Mount("/pulls", s.PullsRouter(mw)) 94 - r.Mount("/pipelines", s.PipelinesRouter(mw)) 95 - r.Mount("/labels", s.LabelsRouter(mw)) 99 + r.Mount("/pipelines", s.PipelinesRouter()) 100 + r.Mount("/labels", s.LabelsRouter()) 96 101 97 102 // These routes get proxied to the knot 98 103 r.Get("/info/refs", s.InfoRefs) 104 + r.Post("/git-upload-archive", s.UploadArchive) 99 105 r.Post("/git-upload-pack", s.UploadPack) 100 106 r.Post("/git-receive-pack", s.ReceivePack) 101 107 ··· 134 140 // r.Post("/import", s.ImportRepo) 135 141 }) 136 142 137 - r.Get("/goodfirstissues", s.GoodFirstIssues) 143 + r.With(middleware.Paginate).Get("/goodfirstissues", s.GoodFirstIssues) 138 144 139 145 r.With(middleware.AuthMiddleware(s.oauth)).Route("/follow", func(r chi.Router) { 140 146 r.Post("/", s.Follow) ··· 161 167 162 168 r.Mount("/settings", s.SettingsRouter()) 163 169 r.Mount("/strings", s.StringsRouter(mw)) 164 - r.Mount("/knots", s.KnotsRouter()) 165 - r.Mount("/spindles", s.SpindlesRouter()) 170 + 171 + r.Mount("/settings/knots", s.KnotsRouter()) 172 + r.Mount("/settings/spindles", s.SpindlesRouter()) 173 + 166 174 r.Mount("/notifications", s.NotificationsRouter(mw)) 167 175 168 176 r.Mount("/signup", s.SignupRouter()) ··· 205 213 } 206 214 207 215 func (s *State) SpindlesRouter() http.Handler { 208 - logger := log.New("spindles") 216 + logger := log.SubLogger(s.logger, "spindles") 209 217 210 218 spindles := &spindles.Spindles{ 211 219 Db: s.db, ··· 221 229 } 222 230 223 231 func (s *State) KnotsRouter() http.Handler { 224 - logger := log.New("knots") 232 + logger := log.SubLogger(s.logger, "knots") 225 233 226 234 knots := &knots.Knots{ 227 235 Db: s.db, ··· 238 246 } 239 247 240 248 func (s *State) StringsRouter(mw *middleware.Middleware) http.Handler { 241 - logger := log.New("strings") 249 + logger := log.SubLogger(s.logger, "strings") 242 250 243 251 strs := &avstrings.Strings{ 244 252 Db: s.db, ··· 253 261 } 254 262 255 263 func (s *State) IssuesRouter(mw *middleware.Middleware) http.Handler { 256 - issues := issues.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.notifier, s.validator) 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, 274 + s.validator, 275 + s.indexer.Issues, 276 + log.SubLogger(s.logger, "issues"), 277 + ) 257 278 return issues.Router(mw) 258 279 } 259 280 260 281 func (s *State) PullsRouter(mw *middleware.Middleware) http.Handler { 261 - pulls := pulls.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.notifier) 282 + pulls := pulls.New( 283 + s.oauth, 284 + s.repoResolver, 285 + s.pages, 286 + s.idResolver, 287 + s.mentionsResolver, 288 + s.db, 289 + s.config, 290 + s.notifier, 291 + s.enforcer, 292 + s.validator, 293 + s.indexer.Pulls, 294 + log.SubLogger(s.logger, "pulls"), 295 + ) 262 296 return pulls.Router(mw) 263 297 } 264 298 265 299 func (s *State) RepoRouter(mw *middleware.Middleware) http.Handler { 266 - logger := log.New("repo") 267 - repo := repo.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.notifier, s.enforcer, logger, s.validator) 300 + repo := repo.New( 301 + s.oauth, 302 + s.repoResolver, 303 + s.pages, 304 + s.spindlestream, 305 + s.idResolver, 306 + s.db, 307 + s.config, 308 + s.notifier, 309 + s.enforcer, 310 + log.SubLogger(s.logger, "repo"), 311 + s.validator, 312 + ) 268 313 return repo.Router(mw) 269 314 } 270 315 271 - func (s *State) PipelinesRouter(mw *middleware.Middleware) http.Handler { 272 - pipes := pipelines.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.enforcer) 273 - return pipes.Router(mw) 316 + func (s *State) PipelinesRouter() http.Handler { 317 + pipes := pipelines.New( 318 + s.oauth, 319 + s.repoResolver, 320 + s.pages, 321 + s.spindlestream, 322 + s.idResolver, 323 + s.db, 324 + s.config, 325 + s.enforcer, 326 + log.SubLogger(s.logger, "pipelines"), 327 + ) 328 + return pipes.Router() 274 329 } 275 330 276 - func (s *State) LabelsRouter(mw *middleware.Middleware) http.Handler { 277 - ls := labels.New(s.oauth, s.pages, s.db, s.validator, s.enforcer) 278 - return ls.Router(mw) 331 + func (s *State) LabelsRouter() http.Handler { 332 + ls := labels.New( 333 + s.oauth, 334 + s.pages, 335 + s.db, 336 + s.validator, 337 + s.enforcer, 338 + log.SubLogger(s.logger, "labels"), 339 + ) 340 + return ls.Router() 279 341 } 280 342 281 343 func (s *State) NotificationsRouter(mw *middleware.Middleware) http.Handler { 282 - notifs := notifications.New(s.db, s.oauth, s.pages) 344 + notifs := notifications.New(s.db, s.oauth, s.pages, log.SubLogger(s.logger, "notifications")) 283 345 return notifs.Router(mw) 284 346 } 285 347 286 348 func (s *State) SignupRouter() http.Handler { 287 - logger := log.New("signup") 288 - 289 - sig := signup.New(s.config, s.db, s.posthog, s.idResolver, s.pages, logger) 349 + sig := signup.New(s.config, s.db, s.posthog, s.idResolver, s.pages, log.SubLogger(s.logger, "signup")) 290 350 return sig.Router() 291 351 }
+5 -2
appview/state/spindlestream.go
··· 17 17 ec "tangled.org/core/eventconsumer" 18 18 "tangled.org/core/eventconsumer/cursor" 19 19 "tangled.org/core/log" 20 + "tangled.org/core/orm" 20 21 "tangled.org/core/rbac" 21 22 spindle "tangled.org/core/spindle/models" 22 23 ) 23 24 24 25 func Spindlestream(ctx context.Context, c *config.Config, d *db.DB, enforcer *rbac.Enforcer) (*ec.Consumer, error) { 26 + logger := log.FromContext(ctx) 27 + logger = log.SubLogger(logger, "spindlestream") 28 + 25 29 spindles, err := db.GetSpindles( 26 30 d, 27 - db.FilterIsNot("verified", "null"), 31 + orm.FilterIsNot("verified", "null"), 28 32 ) 29 33 if err != nil { 30 34 return nil, err ··· 36 40 srcs[src] = struct{}{} 37 41 } 38 42 39 - logger := log.New("spindlestream") 40 43 cache := cache.New(c.Redis.Addr) 41 44 cursorStore := cursor.NewRedisCursorStore(cache) 42 45
+9 -13
appview/state/star.go
··· 57 57 log.Println("created atproto record: ", resp.Uri) 58 58 59 59 star := &models.Star{ 60 - StarredByDid: currentUser.Did, 61 - RepoAt: subjectUri, 62 - Rkey: rkey, 60 + Did: currentUser.Did, 61 + RepoAt: subjectUri, 62 + Rkey: rkey, 63 63 } 64 64 65 65 err = db.AddStar(s.db, star) ··· 75 75 76 76 s.notifier.NewStar(r.Context(), star) 77 77 78 - s.pages.RepoStarFragment(w, pages.RepoStarFragmentParams{ 78 + s.pages.StarBtnFragment(w, pages.StarBtnFragmentParams{ 79 79 IsStarred: true, 80 - RepoAt: subjectUri, 81 - Stats: models.RepoStats{ 82 - StarCount: starCount, 83 - }, 80 + SubjectAt: subjectUri, 81 + StarCount: starCount, 84 82 }) 85 83 86 84 return ··· 117 115 118 116 s.notifier.DeleteStar(r.Context(), star) 119 117 120 - s.pages.RepoStarFragment(w, pages.RepoStarFragmentParams{ 118 + s.pages.StarBtnFragment(w, pages.StarBtnFragmentParams{ 121 119 IsStarred: false, 122 - RepoAt: subjectUri, 123 - Stats: models.RepoStats{ 124 - StarCount: starCount, 125 - }, 120 + SubjectAt: subjectUri, 121 + StarCount: starCount, 126 122 }) 127 123 128 124 return
+77 -59
appview/state/state.go
··· 5 5 "database/sql" 6 6 "errors" 7 7 "fmt" 8 - "log" 9 8 "log/slog" 10 9 "net/http" 11 10 "strings" ··· 13 12 14 13 "tangled.org/core/api/tangled" 15 14 "tangled.org/core/appview" 16 - "tangled.org/core/appview/cache" 17 - "tangled.org/core/appview/cache/session" 18 15 "tangled.org/core/appview/config" 19 16 "tangled.org/core/appview/db" 17 + "tangled.org/core/appview/indexer" 18 + "tangled.org/core/appview/mentions" 20 19 "tangled.org/core/appview/models" 21 20 "tangled.org/core/appview/notify" 22 21 dbnotify "tangled.org/core/appview/notify/db" ··· 29 28 "tangled.org/core/eventconsumer" 30 29 "tangled.org/core/idresolver" 31 30 "tangled.org/core/jetstream" 31 + "tangled.org/core/log" 32 32 tlog "tangled.org/core/log" 33 + "tangled.org/core/orm" 33 34 "tangled.org/core/rbac" 34 35 "tangled.org/core/tid" 35 36 ··· 43 44 ) 44 45 45 46 type State struct { 46 - db *db.DB 47 - notifier notify.Notifier 48 - oauth *oauth.OAuth 49 - enforcer *rbac.Enforcer 50 - pages *pages.Pages 51 - sess *session.SessionStore 52 - idResolver *idresolver.Resolver 53 - posthog posthog.Client 54 - jc *jetstream.JetstreamClient 55 - config *config.Config 56 - repoResolver *reporesolver.RepoResolver 57 - knotstream *eventconsumer.Consumer 58 - spindlestream *eventconsumer.Consumer 59 - logger *slog.Logger 60 - validator *validator.Validator 47 + db *db.DB 48 + notifier notify.Notifier 49 + indexer *indexer.Indexer 50 + oauth *oauth.OAuth 51 + enforcer *rbac.Enforcer 52 + pages *pages.Pages 53 + idResolver *idresolver.Resolver 54 + mentionsResolver *mentions.Resolver 55 + posthog posthog.Client 56 + jc *jetstream.JetstreamClient 57 + config *config.Config 58 + repoResolver *reporesolver.RepoResolver 59 + knotstream *eventconsumer.Consumer 60 + spindlestream *eventconsumer.Consumer 61 + logger *slog.Logger 62 + validator *validator.Validator 61 63 } 62 64 63 65 func Make(ctx context.Context, config *config.Config) (*State, error) { 64 - d, err := db.Make(config.Core.DbPath) 66 + logger := tlog.FromContext(ctx) 67 + 68 + d, err := db.Make(ctx, config.Core.DbPath) 65 69 if err != nil { 66 70 return nil, fmt.Errorf("failed to create db: %w", err) 67 71 } 68 72 69 - enforcer, err := rbac.NewEnforcer(config.Core.DbPath) 73 + indexer := indexer.New(log.SubLogger(logger, "indexer")) 74 + err = indexer.Init(ctx, d) 70 75 if err != nil { 71 - return nil, fmt.Errorf("failed to create enforcer: %w", err) 76 + return nil, fmt.Errorf("failed to create indexer: %w", err) 72 77 } 73 78 74 - res, err := idresolver.RedisResolver(config.Redis.ToURL()) 79 + enforcer, err := rbac.NewEnforcer(config.Core.DbPath) 75 80 if err != nil { 76 - log.Printf("failed to create redis resolver: %v", err) 77 - res = idresolver.DefaultResolver() 81 + return nil, fmt.Errorf("failed to create enforcer: %w", err) 78 82 } 79 83 80 - pages := pages.NewPages(config, res) 81 - cache := cache.New(config.Redis.Addr) 82 - sess := session.New(cache) 83 - oauth2, err := oauth.New(config) 84 + res, err := idresolver.RedisResolver(config.Redis.ToURL(), config.Plc.PLCURL) 84 85 if err != nil { 85 - return nil, fmt.Errorf("failed to start oauth handler: %w", err) 86 + logger.Error("failed to create redis resolver", "err", err) 87 + res = idresolver.DefaultResolver(config.Plc.PLCURL) 86 88 } 87 - validator := validator.New(d, res, enforcer) 88 89 89 90 posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint}) 90 91 if err != nil { 91 92 return nil, fmt.Errorf("failed to create posthog client: %w", err) 92 93 } 93 94 94 - repoResolver := reporesolver.New(config, enforcer, res, d) 95 + pages := pages.NewPages(config, res, log.SubLogger(logger, "pages")) 96 + oauth, err := oauth.New(config, posthog, d, enforcer, res, log.SubLogger(logger, "oauth")) 97 + if err != nil { 98 + return nil, fmt.Errorf("failed to start oauth handler: %w", err) 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")) 95 105 96 106 wrapper := db.DbWrapper{Execer: d} 97 107 jc, err := jetstream.NewJetstreamClient( ··· 112 122 tangled.LabelOpNSID, 113 123 }, 114 124 nil, 115 - slog.Default(), 125 + tlog.SubLogger(logger, "jetstream"), 116 126 wrapper, 117 127 false, 118 128 ··· 124 134 return nil, fmt.Errorf("failed to create jetstream client: %w", err) 125 135 } 126 136 127 - if err := BackfillDefaultDefs(d, res); err != nil { 137 + if err := BackfillDefaultDefs(d, res, config.Label.DefaultLabelDefs); err != nil { 128 138 return nil, fmt.Errorf("failed to backfill default label defs: %w", err) 129 139 } 130 140 ··· 133 143 Enforcer: enforcer, 134 144 IdResolver: res, 135 145 Config: config, 136 - Logger: tlog.New("ingester"), 146 + Logger: log.SubLogger(logger, "ingester"), 137 147 Validator: validator, 138 148 } 139 149 err = jc.StartJetstream(ctx, ingester.Ingest()) ··· 162 172 if !config.Core.Dev { 163 173 notifiers = append(notifiers, phnotify.NewPosthogNotifier(posthog)) 164 174 } 165 - notifier := notify.NewMergedNotifier(notifiers...) 175 + notifiers = append(notifiers, indexer) 176 + notifier := notify.NewMergedNotifier(notifiers, tlog.SubLogger(logger, "notify")) 166 177 167 178 state := &State{ 168 179 d, 169 180 notifier, 170 - oauth2, 181 + indexer, 182 + oauth, 171 183 enforcer, 172 184 pages, 173 - sess, 174 185 res, 186 + mentionsResolver, 175 187 posthog, 176 188 jc, 177 189 config, 178 190 repoResolver, 179 191 knotstream, 180 192 spindlestream, 181 - slog.Default(), 193 + logger, 182 194 validator, 183 195 } 184 196 ··· 268 280 func (s *State) Timeline(w http.ResponseWriter, r *http.Request) { 269 281 user := s.oauth.GetUser(r) 270 282 283 + // TODO: set this flag based on the UI 284 + filtered := false 285 + 271 286 var userDid string 272 287 if user != nil { 273 288 userDid = user.Did 274 289 } 275 - timeline, err := db.MakeTimeline(s.db, 50, userDid) 290 + timeline, err := db.MakeTimeline(s.db, 50, userDid, filtered) 276 291 if err != nil { 277 - log.Println(err) 292 + s.logger.Error("failed to make timeline", "err", err) 278 293 s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") 279 294 } 280 295 281 296 repos, err := db.GetTopStarredReposLastWeek(s.db) 282 297 if err != nil { 283 - log.Println(err) 298 + s.logger.Error("failed to get top starred repos", "err", err) 284 299 s.pages.Notice(w, "topstarredrepos", "Unable to load.") 285 300 return 286 301 } 287 302 288 - gfiLabel, err := db.GetLabelDefinition(s.db, db.FilterEq("at_uri", models.LabelGoodFirstIssue)) 303 + gfiLabel, err := db.GetLabelDefinition(s.db, orm.FilterEq("at_uri", s.config.Label.GoodFirstIssue)) 289 304 if err != nil { 290 305 // non-fatal 291 306 } ··· 309 324 310 325 regs, err := db.GetRegistrations( 311 326 s.db, 312 - db.FilterEq("did", user.Did), 313 - db.FilterEq("needs_upgrade", 1), 327 + orm.FilterEq("did", user.Did), 328 + orm.FilterEq("needs_upgrade", 1), 314 329 ) 315 330 if err != nil { 316 331 l.Error("non-fatal: failed to get registrations", "err", err) ··· 318 333 319 334 spindles, err := db.GetSpindles( 320 335 s.db, 321 - db.FilterEq("owner", user.Did), 322 - db.FilterEq("needs_upgrade", 1), 336 + orm.FilterEq("owner", user.Did), 337 + orm.FilterEq("needs_upgrade", 1), 323 338 ) 324 339 if err != nil { 325 340 l.Error("non-fatal: failed to get spindles", "err", err) ··· 336 351 } 337 352 338 353 func (s *State) Home(w http.ResponseWriter, r *http.Request) { 339 - timeline, err := db.MakeTimeline(s.db, 5, "") 354 + // TODO: set this flag based on the UI 355 + filtered := false 356 + 357 + timeline, err := db.MakeTimeline(s.db, 5, "", filtered) 340 358 if err != nil { 341 - log.Println(err) 359 + s.logger.Error("failed to make timeline", "err", err) 342 360 s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") 343 361 return 344 362 } 345 363 346 364 repos, err := db.GetTopStarredReposLastWeek(s.db) 347 365 if err != nil { 348 - log.Println(err) 366 + s.logger.Error("failed to get top starred repos", "err", err) 349 367 s.pages.Notice(w, "topstarredrepos", "Unable to load.") 350 368 return 351 369 } ··· 374 392 375 393 pubKeys, err := db.GetPublicKeysForDid(s.db, id.DID.String()) 376 394 if err != nil { 377 - w.WriteHeader(http.StatusNotFound) 395 + s.logger.Error("failed to get public keys", "err", err) 396 + http.Error(w, "failed to get public keys", http.StatusInternalServerError) 378 397 return 379 398 } 380 399 381 400 if len(pubKeys) == 0 { 382 - w.WriteHeader(http.StatusNotFound) 401 + w.WriteHeader(http.StatusNoContent) 383 402 return 384 403 } 385 404 ··· 486 505 // Check for existing repos 487 506 existingRepo, err := db.GetRepo( 488 507 s.db, 489 - db.FilterEq("did", user.Did), 490 - db.FilterEq("name", repoName), 508 + orm.FilterEq("did", user.Did), 509 + orm.FilterEq("name", repoName), 491 510 ) 492 511 if err == nil && existingRepo != nil { 493 512 l.Info("repo exists") ··· 504 523 Rkey: rkey, 505 524 Description: description, 506 525 Created: time.Now(), 507 - Labels: models.DefaultLabelDefs(), 526 + Labels: s.config.Label.DefaultLabelDefs, 508 527 } 509 528 record := repo.AsRecord() 510 529 ··· 620 639 aturi = "" 621 640 622 641 s.notifier.NewRepo(r.Context(), repo) 623 - s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Did, repoName)) 642 + s.pages.HxLocation(w, fmt.Sprintf("/%s/%s", user.Did, repoName)) 624 643 } 625 644 } 626 645 ··· 646 665 return err 647 666 } 648 667 649 - func BackfillDefaultDefs(e db.Execer, r *idresolver.Resolver) error { 650 - defaults := models.DefaultLabelDefs() 651 - defaultLabels, err := db.GetLabelDefinitions(e, db.FilterIn("at_uri", defaults)) 668 + func BackfillDefaultDefs(e db.Execer, r *idresolver.Resolver, defaults []string) error { 669 + defaultLabels, err := db.GetLabelDefinitions(e, orm.FilterIn("at_uri", defaults)) 652 670 if err != nil { 653 671 return err 654 672 } ··· 657 675 return nil 658 676 } 659 677 660 - labelDefs, err := models.FetchDefaultDefs(r) 678 + labelDefs, err := models.FetchLabelDefs(r, defaults) 661 679 if err != nil { 662 680 return err 663 681 }
+6 -6
appview/state/userutil/userutil.go
··· 10 10 didRegex = regexp.MustCompile(`^did:[a-z]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]$`) 11 11 ) 12 12 13 - func IsHandleNoAt(s string) bool { 13 + func IsHandle(s string) bool { 14 14 // ref: https://atproto.com/specs/handle 15 15 return handleRegex.MatchString(s) 16 + } 17 + 18 + // IsDid checks if the given string is a standard DID. 19 + func IsDid(s string) bool { 20 + return didRegex.MatchString(s) 16 21 } 17 22 18 23 func UnflattenDid(s string) string { ··· 45 50 return strings.Replace(s, ":", "-", 2) 46 51 } 47 52 return s 48 - } 49 - 50 - // IsDid checks if the given string is a standard DID. 51 - func IsDid(s string) bool { 52 - return didRegex.MatchString(s) 53 53 } 54 54 55 55 var subdomainRegex = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{2,61}[a-z0-9])?$`)
+21 -8
appview/strings/strings.go
··· 17 17 "tangled.org/core/appview/pages" 18 18 "tangled.org/core/appview/pages/markup" 19 19 "tangled.org/core/idresolver" 20 + "tangled.org/core/orm" 20 21 "tangled.org/core/tid" 21 22 22 23 "github.com/bluesky-social/indigo/api/atproto" ··· 108 109 strings, err := db.GetStrings( 109 110 s.Db, 110 111 0, 111 - db.FilterEq("did", id.DID), 112 - db.FilterEq("rkey", rkey), 112 + orm.FilterEq("did", id.DID), 113 + orm.FilterEq("rkey", rkey), 113 114 ) 114 115 if err != nil { 115 116 l.Error("failed to fetch string", "err", err) ··· 148 149 showRendered = r.URL.Query().Get("code") != "true" 149 150 } 150 151 152 + starCount, err := db.GetStarCount(s.Db, string.AtUri()) 153 + if err != nil { 154 + l.Error("failed to get star count", "err", err) 155 + } 156 + user := s.OAuth.GetUser(r) 157 + isStarred := false 158 + if user != nil { 159 + isStarred = db.GetStarStatus(s.Db, user.Did, string.AtUri()) 160 + } 161 + 151 162 s.Pages.SingleString(w, pages.SingleStringParams{ 152 - LoggedInUser: s.OAuth.GetUser(r), 163 + LoggedInUser: user, 153 164 RenderToggle: renderToggle, 154 165 ShowRendered: showRendered, 155 - String: string, 166 + String: &string, 156 167 Stats: string.Stats(), 168 + IsStarred: isStarred, 169 + StarCount: starCount, 157 170 Owner: id, 158 171 }) 159 172 } ··· 187 200 all, err := db.GetStrings( 188 201 s.Db, 189 202 0, 190 - db.FilterEq("did", id.DID), 191 - db.FilterEq("rkey", rkey), 203 + orm.FilterEq("did", id.DID), 204 + orm.FilterEq("rkey", rkey), 192 205 ) 193 206 if err != nil { 194 207 l.Error("failed to fetch string", "err", err) ··· 396 409 397 410 if err := db.DeleteString( 398 411 s.Db, 399 - db.FilterEq("did", user.Did), 400 - db.FilterEq("rkey", rkey), 412 + orm.FilterEq("did", user.Did), 413 + orm.FilterEq("rkey", rkey), 401 414 ); err != nil { 402 415 fail("Failed to delete string.", err) 403 416 return
+2 -1
appview/validator/issue.go
··· 6 6 7 7 "tangled.org/core/appview/db" 8 8 "tangled.org/core/appview/models" 9 + "tangled.org/core/orm" 9 10 ) 10 11 11 12 func (v *Validator) ValidateIssueComment(comment *models.IssueComment) error { 12 13 // if comments have parents, only ingest ones that are 1 level deep 13 14 if comment.ReplyTo != nil { 14 - parents, err := db.GetIssueComments(v.db, db.FilterEq("at_uri", *comment.ReplyTo)) 15 + parents, err := db.GetIssueComments(v.db, orm.FilterEq("at_uri", *comment.ReplyTo)) 15 16 if err != nil { 16 17 return fmt.Errorf("failed to fetch parent comment: %w", err) 17 18 }
+25
appview/validator/patch.go
··· 1 + package validator 2 + 3 + import ( 4 + "fmt" 5 + "strings" 6 + 7 + "tangled.org/core/patchutil" 8 + ) 9 + 10 + func (v *Validator) ValidatePatch(patch *string) error { 11 + if patch == nil || *patch == "" { 12 + return fmt.Errorf("patch is empty") 13 + } 14 + 15 + // add newline if not present to diff style patches 16 + if !patchutil.IsFormatPatch(*patch) && !strings.HasSuffix(*patch, "\n") { 17 + *patch = *patch + "\n" 18 + } 19 + 20 + if err := patchutil.IsPatchValid(*patch); err != nil { 21 + return err 22 + } 23 + 24 + return nil 25 + }
+53
appview/validator/repo_topics.go
··· 1 + package validator 2 + 3 + import ( 4 + "fmt" 5 + "maps" 6 + "regexp" 7 + "slices" 8 + "strings" 9 + ) 10 + 11 + const ( 12 + maxTopicLen = 50 13 + maxTopics = 20 14 + ) 15 + 16 + var ( 17 + topicRE = regexp.MustCompile(`\A[a-z0-9-]+\z`) 18 + ) 19 + 20 + // ValidateRepoTopicStr parses and validates whitespace-separated topic string. 21 + // 22 + // Rules: 23 + // - topics are separated by whitespace 24 + // - each topic may contain lowercase letters, digits, and hyphens only 25 + // - each topic must be <= 50 characters long 26 + // - no more than 20 topics allowed 27 + // - duplicates are removed 28 + func (v *Validator) ValidateRepoTopicStr(topicsStr string) ([]string, error) { 29 + topicsStr = strings.TrimSpace(topicsStr) 30 + if topicsStr == "" { 31 + return nil, nil 32 + } 33 + parts := strings.Fields(topicsStr) 34 + if len(parts) > maxTopics { 35 + return nil, fmt.Errorf("too many topics: %d (maximum %d)", len(parts), maxTopics) 36 + } 37 + 38 + topicSet := make(map[string]struct{}) 39 + 40 + for _, t := range parts { 41 + if _, exists := topicSet[t]; exists { 42 + continue 43 + } 44 + if len(t) > maxTopicLen { 45 + return nil, fmt.Errorf("topic '%s' is too long (maximum %d characters)", t, maxTopics) 46 + } 47 + if !topicRE.MatchString(t) { 48 + return nil, fmt.Errorf("topic '%s' contains invalid characters (allowed: lowercase letters, digits, hyphens)", t) 49 + } 50 + topicSet[t] = struct{}{} 51 + } 52 + return slices.Collect(maps.Keys(topicSet)), nil 53 + }
+17
appview/validator/uri.go
··· 1 + package validator 2 + 3 + import ( 4 + "fmt" 5 + "net/url" 6 + ) 7 + 8 + func (v *Validator) ValidateURI(uri string) error { 9 + parsed, err := url.Parse(uri) 10 + if err != nil { 11 + return fmt.Errorf("invalid uri format") 12 + } 13 + if parsed.Scheme == "" { 14 + return fmt.Errorf("uri scheme missing") 15 + } 16 + return nil 17 + }
+14 -9
cmd/appview/main.go
··· 2 2 3 3 import ( 4 4 "context" 5 - "log" 6 - "log/slog" 7 5 "net/http" 8 6 "os" 9 7 10 8 "tangled.org/core/appview/config" 11 9 "tangled.org/core/appview/state" 10 + tlog "tangled.org/core/log" 12 11 ) 13 12 14 13 func main() { 15 - slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, nil))) 16 - 17 14 ctx := context.Background() 15 + logger := tlog.New("appview") 16 + ctx = tlog.IntoContext(ctx, logger) 18 17 19 18 c, err := config.LoadConfig(ctx) 20 19 if err != nil { 21 - log.Println("failed to load config", "error", err) 20 + logger.Error("failed to load config", "error", err) 22 21 return 23 22 } 24 23 25 24 state, err := state.Make(ctx, c) 26 25 defer func() { 27 - log.Println(state.Close()) 26 + if err := state.Close(); err != nil { 27 + logger.Error("failed to close state", "err", err) 28 + } 28 29 }() 29 30 30 31 if err != nil { 31 - log.Fatal(err) 32 + logger.Error("failed to start appview", "err", err) 33 + os.Exit(-1) 32 34 } 33 35 34 - log.Println("starting server on", c.Core.ListenAddr) 35 - log.Println(http.ListenAndServe(c.Core.ListenAddr, state.Router())) 36 + logger.Info("starting server", "address", c.Core.ListenAddr) 37 + 38 + if err := http.ListenAndServe(c.Core.ListenAddr, state.Router()); err != nil { 39 + logger.Error("failed to start appview", "err", err) 40 + } 36 41 }
+62
cmd/cborgen/cborgen.go
··· 1 + package main 2 + 3 + import ( 4 + cbg "github.com/whyrusleeping/cbor-gen" 5 + "tangled.org/core/api/tangled" 6 + ) 7 + 8 + func main() { 9 + 10 + genCfg := cbg.Gen{ 11 + MaxStringLength: 1_000_000, 12 + } 13 + 14 + if err := genCfg.WriteMapEncodersToFile( 15 + "api/tangled/cbor_gen.go", 16 + "tangled", 17 + tangled.ActorProfile{}, 18 + tangled.FeedReaction{}, 19 + tangled.FeedStar{}, 20 + tangled.GitRefUpdate{}, 21 + tangled.GitRefUpdate_CommitCountBreakdown{}, 22 + tangled.GitRefUpdate_IndividualEmailCommitCount{}, 23 + tangled.GitRefUpdate_IndividualLanguageSize{}, 24 + tangled.GitRefUpdate_LangBreakdown{}, 25 + tangled.GitRefUpdate_Meta{}, 26 + tangled.GraphFollow{}, 27 + tangled.Knot{}, 28 + tangled.KnotMember{}, 29 + tangled.LabelDefinition{}, 30 + tangled.LabelDefinition_ValueType{}, 31 + tangled.LabelOp{}, 32 + tangled.LabelOp_Operand{}, 33 + tangled.Pipeline{}, 34 + tangled.Pipeline_CloneOpts{}, 35 + tangled.Pipeline_ManualTriggerData{}, 36 + tangled.Pipeline_Pair{}, 37 + tangled.Pipeline_PullRequestTriggerData{}, 38 + tangled.Pipeline_PushTriggerData{}, 39 + tangled.PipelineStatus{}, 40 + tangled.Pipeline_TriggerMetadata{}, 41 + tangled.Pipeline_TriggerRepo{}, 42 + tangled.Pipeline_Workflow{}, 43 + tangled.PublicKey{}, 44 + tangled.Repo{}, 45 + tangled.RepoArtifact{}, 46 + tangled.RepoCollaborator{}, 47 + tangled.RepoIssue{}, 48 + tangled.RepoIssueComment{}, 49 + tangled.RepoIssueState{}, 50 + tangled.RepoPull{}, 51 + tangled.RepoPullComment{}, 52 + tangled.RepoPull_Source{}, 53 + tangled.RepoPullStatus{}, 54 + tangled.RepoPull_Target{}, 55 + tangled.Spindle{}, 56 + tangled.SpindleMember{}, 57 + tangled.String{}, 58 + ); err != nil { 59 + panic(err) 60 + } 61 + 62 + }
-62
cmd/gen.go
··· 1 - package main 2 - 3 - import ( 4 - cbg "github.com/whyrusleeping/cbor-gen" 5 - "tangled.org/core/api/tangled" 6 - ) 7 - 8 - func main() { 9 - 10 - genCfg := cbg.Gen{ 11 - MaxStringLength: 1_000_000, 12 - } 13 - 14 - if err := genCfg.WriteMapEncodersToFile( 15 - "api/tangled/cbor_gen.go", 16 - "tangled", 17 - tangled.ActorProfile{}, 18 - tangled.FeedReaction{}, 19 - tangled.FeedStar{}, 20 - tangled.GitRefUpdate{}, 21 - tangled.GitRefUpdate_CommitCountBreakdown{}, 22 - tangled.GitRefUpdate_IndividualEmailCommitCount{}, 23 - tangled.GitRefUpdate_IndividualLanguageSize{}, 24 - tangled.GitRefUpdate_LangBreakdown{}, 25 - tangled.GitRefUpdate_Meta{}, 26 - tangled.GraphFollow{}, 27 - tangled.Knot{}, 28 - tangled.KnotMember{}, 29 - tangled.LabelDefinition{}, 30 - tangled.LabelDefinition_ValueType{}, 31 - tangled.LabelOp{}, 32 - tangled.LabelOp_Operand{}, 33 - tangled.Pipeline{}, 34 - tangled.Pipeline_CloneOpts{}, 35 - tangled.Pipeline_ManualTriggerData{}, 36 - tangled.Pipeline_Pair{}, 37 - tangled.Pipeline_PullRequestTriggerData{}, 38 - tangled.Pipeline_PushTriggerData{}, 39 - tangled.PipelineStatus{}, 40 - tangled.Pipeline_TriggerMetadata{}, 41 - tangled.Pipeline_TriggerRepo{}, 42 - tangled.Pipeline_Workflow{}, 43 - tangled.PublicKey{}, 44 - tangled.Repo{}, 45 - tangled.RepoArtifact{}, 46 - tangled.RepoCollaborator{}, 47 - tangled.RepoIssue{}, 48 - tangled.RepoIssueComment{}, 49 - tangled.RepoIssueState{}, 50 - tangled.RepoPull{}, 51 - tangled.RepoPullComment{}, 52 - tangled.RepoPull_Source{}, 53 - tangled.RepoPullStatus{}, 54 - tangled.RepoPull_Target{}, 55 - tangled.Spindle{}, 56 - tangled.SpindleMember{}, 57 - tangled.String{}, 58 - ); err != nil { 59 - panic(err) 60 - } 61 - 62 - }
-43
cmd/genjwks/main.go
··· 1 - // adapted from https://tangled.org/anirudh.fi/atproto-oauth 2 - 3 - package main 4 - 5 - import ( 6 - "crypto/ecdsa" 7 - "crypto/elliptic" 8 - "crypto/rand" 9 - "encoding/json" 10 - "fmt" 11 - "time" 12 - 13 - "github.com/lestrrat-go/jwx/v2/jwk" 14 - ) 15 - 16 - func main() { 17 - privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 18 - if err != nil { 19 - panic(err) 20 - } 21 - 22 - key, err := jwk.FromRaw(privKey) 23 - if err != nil { 24 - panic(err) 25 - } 26 - 27 - kid := fmt.Sprintf("%d", time.Now().Unix()) 28 - 29 - if err := key.Set(jwk.KeyIDKey, kid); err != nil { 30 - panic(err) 31 - } 32 - 33 - if err := key.Set("use", "sig"); err != nil { 34 - panic(err) 35 - } 36 - 37 - b, err := json.Marshal(key) 38 - if err != nil { 39 - panic(err) 40 - } 41 - 42 - fmt.Println(string(b)) 43 - }
+6 -3
cmd/knot/main.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "log/slog" 5 6 "os" 6 7 7 8 "github.com/urfave/cli/v3" ··· 9 10 "tangled.org/core/hook" 10 11 "tangled.org/core/keyfetch" 11 12 "tangled.org/core/knotserver" 12 - "tangled.org/core/log" 13 + tlog "tangled.org/core/log" 13 14 ) 14 15 15 16 func main() { ··· 24 25 }, 25 26 } 26 27 28 + logger := tlog.New("knot") 29 + slog.SetDefault(logger) 30 + 27 31 ctx := context.Background() 28 - logger := log.New("knot") 29 - ctx = log.IntoContext(ctx, logger.With("command", cmd.Name)) 32 + ctx = tlog.IntoContext(ctx, logger) 30 33 31 34 if err := cmd.Run(ctx, os.Args); err != nil { 32 35 logger.Error(err.Error())
-49
cmd/punchcardPopulate/main.go
··· 1 - package main 2 - 3 - import ( 4 - "database/sql" 5 - "fmt" 6 - "log" 7 - "math/rand" 8 - "time" 9 - 10 - _ "github.com/mattn/go-sqlite3" 11 - ) 12 - 13 - func main() { 14 - db, err := sql.Open("sqlite3", "./appview.db?_foreign_keys=1") 15 - if err != nil { 16 - log.Fatal("Failed to open database:", err) 17 - } 18 - defer db.Close() 19 - 20 - const did = "did:plc:qfpnj4og54vl56wngdriaxug" 21 - 22 - now := time.Now() 23 - start := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC) 24 - 25 - tx, err := db.Begin() 26 - if err != nil { 27 - log.Fatal(err) 28 - } 29 - stmt, err := tx.Prepare("INSERT INTO punchcard (did, date, count) VALUES (?, ?, ?)") 30 - if err != nil { 31 - log.Fatal(err) 32 - } 33 - defer stmt.Close() 34 - 35 - for day := start; !day.After(now); day = day.AddDate(0, 0, 1) { 36 - count := rand.Intn(16) // 0โ€“5 37 - dateStr := day.Format("2006-01-02") 38 - _, err := stmt.Exec(did, dateStr, count) 39 - if err != nil { 40 - log.Printf("Failed to insert for date %s: %v", dateStr, err) 41 - } 42 - } 43 - 44 - if err := tx.Commit(); err != nil { 45 - log.Fatal("Failed to commit:", err) 46 - } 47 - 48 - fmt.Println("Done populating punchcard.") 49 - }
+9 -4
cmd/spindle/main.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "log/slog" 5 6 "os" 6 7 7 - "tangled.org/core/log" 8 + tlog "tangled.org/core/log" 8 9 "tangled.org/core/spindle" 9 - _ "tangled.org/core/tid" 10 10 ) 11 11 12 12 func main() { 13 - ctx := log.NewContext(context.Background(), "spindle") 13 + logger := tlog.New("spindle") 14 + slog.SetDefault(logger) 15 + 16 + ctx := context.Background() 17 + ctx = tlog.IntoContext(ctx, logger) 18 + 14 19 err := spindle.Run(ctx) 15 20 if err != nil { 16 - log.FromContext(ctx).Error("error running spindle", "error", err) 21 + logger.Error("error running spindle", "error", err) 17 22 os.Exit(-1) 18 23 } 19 24 }
+1 -34
crypto/verify.go
··· 5 5 "crypto/sha256" 6 6 "encoding/base64" 7 7 "fmt" 8 - "strings" 9 8 10 9 "github.com/hiddeco/sshsig" 11 10 "golang.org/x/crypto/ssh" 12 - "tangled.org/core/types" 13 11 ) 14 12 15 13 func VerifySignature(pubKey, signature, payload []byte) (error, bool) { ··· 28 26 // multiple algorithms but sha-512 is most secure, and git's ssh signing defaults 29 27 // to sha-512 for all key types anyway. 30 28 err = sshsig.Verify(buf, sig, pub, sshsig.HashSHA512, "git") 31 - return err, err == nil 32 - } 33 29 34 - // VerifyCommitSignature reconstructs the payload used to sign a commit. This is 35 - // essentially the git cat-file output but without the gpgsig header. 36 - // 37 - // Caveats: signature verification will fail on commits with more than one parent, 38 - // i.e. merge commits, because types.NiceDiff doesn't carry more than one Parent field 39 - // and we are unable to reconstruct the payload correctly. 40 - // 41 - // Ideally this should directly operate on an *object.Commit. 42 - func VerifyCommitSignature(pubKey string, commit types.NiceDiff) (error, bool) { 43 - signature := commit.Commit.PGPSignature 44 - 45 - author := bytes.NewBuffer([]byte{}) 46 - committer := bytes.NewBuffer([]byte{}) 47 - commit.Commit.Author.Encode(author) 48 - commit.Commit.Committer.Encode(committer) 49 - 50 - payload := strings.Builder{} 51 - 52 - fmt.Fprintf(&payload, "tree %s\n", commit.Commit.Tree) 53 - if commit.Commit.Parent != "" { 54 - fmt.Fprintf(&payload, "parent %s\n", commit.Commit.Parent) 55 - } 56 - fmt.Fprintf(&payload, "author %s\n", author.String()) 57 - fmt.Fprintf(&payload, "committer %s\n", committer.String()) 58 - if commit.Commit.ChangedId != "" { 59 - fmt.Fprintf(&payload, "change-id %s\n", commit.Commit.ChangedId) 60 - } 61 - fmt.Fprintf(&payload, "\n%s", commit.Commit.Message) 62 - 63 - return VerifySignature([]byte(pubKey), []byte(signature), []byte(payload.String())) 30 + return err, err == nil 64 31 } 65 32 66 33 // SSHFingerprint computes the fingerprint of the supplied ssh pubkey.
+19 -9
docs/hacking.md
··· 37 37 38 38 ``` 39 39 # oauth jwks should already be setup by the nix devshell: 40 - echo $TANGLED_OAUTH_JWKS 41 - {"crv":"P-256","d":"tELKHYH-Dko6qo4ozYcVPE1ah6LvXHFV2wpcWpi8ab4","kid":"1753352226","kty":"EC","x":"mRzYpLzAGq74kJez9UbgGfV040DxgsXpMbaVsdy8RZs","y":"azqqXzUYywMlLb2Uc5AVG18nuLXyPnXr4kI4T39eeIc"} 40 + echo $TANGLED_OAUTH_CLIENT_SECRET 41 + z42ty4RT1ovnTopY8B8ekz9NuziF2CuMkZ7rbRFpAR9jBqMc 42 + 43 + echo $TANGLED_OAUTH_CLIENT_KID 44 + 1761667908 42 45 43 46 # if not, you can set it up yourself: 44 - go build -o genjwks.out ./cmd/genjwks 45 - export TANGLED_OAUTH_JWKS="$(./genjwks.out)" 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..." 46 56 47 57 # run redis in at a new shell to store oauth sessions 48 58 redis-server ··· 107 117 # type `poweroff` at the shell to exit the VM 108 118 ``` 109 119 110 - This starts a knot on port 6000, a spindle on port 6555 120 + This starts a knot on port 6444, a spindle on port 6555 111 121 with `ssh` exposed on port 2222. 112 122 113 123 Once the services are running, head to 114 - http://localhost:3000/knots and hit verify. It should 124 + http://localhost:3000/settings/knots and hit verify. It should 115 125 verify the ownership of the services instantly if everything 116 126 went smoothly. 117 127 ··· 136 146 ### running a spindle 137 147 138 148 The above VM should already be running a spindle on 139 - `localhost:6555`. Head to http://localhost:3000/spindles and 149 + `localhost:6555`. Head to http://localhost:3000/settings/spindles and 140 150 hit verify. You can then configure each repository to use 141 151 this spindle and run CI jobs. 142 152 ··· 158 168 159 169 If for any reason you wish to disable either one of the 160 170 services in the VM, modify [nix/vm.nix](/nix/vm.nix) and set 161 - `services.tangled-spindle.enable` (or 162 - `services.tangled-knot.enable`) to `false`. 171 + `services.tangled.spindle.enable` (or 172 + `services.tangled.knot.enable`) to `false`.
+3 -2
docs/knot-hosting.md
··· 39 39 ``` 40 40 41 41 Next, move the `knot` binary to a location owned by `root` -- 42 - `/usr/local/bin/knot` is a good choice: 42 + `/usr/local/bin/` is a good choice. Make sure the binary itself is also owned by `root`: 43 43 44 44 ``` 45 45 sudo mv knot /usr/local/bin/knot 46 + sudo chown root:root /usr/local/bin/knot 46 47 ``` 47 48 48 49 This is necessary because SSH `AuthorizedKeysCommand` requires [really ··· 130 131 131 132 You should now have a running knot server! You can finalize 132 133 your registration by hitting the `verify` button on the 133 - [/knots](https://tangled.org/knots) page. This simply creates 134 + [/settings/knots](https://tangled.org/settings/knots) page. This simply creates 134 135 a record on your PDS to announce the existence of the knot. 135 136 136 137 ### custom paths
+4 -4
docs/migrations.md
··· 14 14 For knots: 15 15 16 16 - Upgrade to latest tag (v1.9.0 or above) 17 - - Head to the [knot dashboard](https://tangled.org/knots) and 17 + - Head to the [knot dashboard](https://tangled.org/settings/knots) and 18 18 hit the "retry" button to verify your knot 19 19 20 20 For spindles: 21 21 22 22 - Upgrade to latest tag (v1.9.0 or above) 23 23 - Head to the [spindle 24 - dashboard](https://tangled.org/spindles) and hit the 24 + dashboard](https://tangled.org/settings/spindles) and hit the 25 25 "retry" button to verify your spindle 26 26 27 27 ## Upgrading from v1.7.x ··· 41 41 [settings](https://tangled.org/settings) page. 42 42 - Restart your knot once you have replaced the environment 43 43 variable 44 - - Head to the [knot dashboard](https://tangled.org/knots) and 44 + - Head to the [knot dashboard](https://tangled.org/settings/knots) and 45 45 hit the "retry" button to verify your knot. This simply 46 46 writes a `sh.tangled.knot` record to your PDS. 47 47 ··· 49 49 latest revision, and change your config block like so: 50 50 51 51 ```diff 52 - services.tangled-knot = { 52 + services.tangled.knot = { 53 53 enable = true; 54 54 server = { 55 55 - secretFile = /path/to/secret;
+19 -1
docs/spindle/pipeline.md
··· 19 19 - `push`: The workflow should run every time a commit is pushed to the repository. 20 20 - `pull_request`: The workflow should run every time a pull request is made or updated. 21 21 - `manual`: The workflow can be triggered manually. 22 - - `branch`: This is a **required** field that defines which branches the workflow should run for. If used with the `push` event, commits to the branch(es) listed here will trigger the workflow. If used with the `pull_request` event, updates to pull requests targeting the branch(es) listed here will trigger the workflow. This field has no effect with the `manual` event. 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. 23 24 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: 25 26 ··· 29 30 branch: ["main", "develop"] 30 31 - event: ["pull_request"] 31 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"] 32 50 ``` 33 51 34 52 ## Engine
+20 -3
flake.lock
··· 1 1 { 2 2 "nodes": { 3 + "actor-typeahead-src": { 4 + "flake": false, 5 + "locked": { 6 + "lastModified": 1762835797, 7 + "narHash": "sha256-heizoWUKDdar6ymfZTnj3ytcEv/L4d4fzSmtr0HlXsQ=", 8 + "ref": "refs/heads/main", 9 + "rev": "677fe7f743050a4e7f09d4a6f87bbf1325a06f6b", 10 + "revCount": 6, 11 + "type": "git", 12 + "url": "https://tangled.org/@jakelazaroff.com/actor-typeahead" 13 + }, 14 + "original": { 15 + "type": "git", 16 + "url": "https://tangled.org/@jakelazaroff.com/actor-typeahead" 17 + } 18 + }, 3 19 "flake-compat": { 4 20 "flake": false, 5 21 "locked": { ··· 134 150 }, 135 151 "nixpkgs": { 136 152 "locked": { 137 - "lastModified": 1751984180, 138 - "narHash": "sha256-LwWRsENAZJKUdD3SpLluwDmdXY9F45ZEgCb0X+xgOL0=", 153 + "lastModified": 1765186076, 154 + "narHash": "sha256-hM20uyap1a0M9d344I692r+ik4gTMyj60cQWO+hAYP8=", 139 155 "owner": "nixos", 140 156 "repo": "nixpkgs", 141 - "rev": "9807714d6944a957c2e036f84b0ff8caf9930bc0", 157 + "rev": "addf7cf5f383a3101ecfba091b98d0a1263dc9b8", 142 158 "type": "github" 143 159 }, 144 160 "original": { ··· 150 166 }, 151 167 "root": { 152 168 "inputs": { 169 + "actor-typeahead-src": "actor-typeahead-src", 153 170 "flake-compat": "flake-compat", 154 171 "gomod2nix": "gomod2nix", 155 172 "htmx-src": "htmx-src",
+22 -17
flake.nix
··· 33 33 url = "https://github.com/rsms/inter/releases/download/v4.1/Inter-4.1.zip"; 34 34 flake = false; 35 35 }; 36 + actor-typeahead-src = { 37 + url = "git+https://tangled.org/@jakelazaroff.com/actor-typeahead"; 38 + flake = false; 39 + }; 36 40 ibm-plex-mono-src = { 37 41 url = "https://github.com/IBM/plex/releases/download/%40ibm%2Fplex-mono%401.1.0/ibm-plex-mono.zip"; 38 42 flake = false; ··· 54 58 inter-fonts-src, 55 59 sqlite-lib-src, 56 60 ibm-plex-mono-src, 61 + actor-typeahead-src, 57 62 ... 58 63 }: let 59 64 supportedSystems = ["x86_64-linux" "x86_64-darwin" "aarch64-linux" "aarch64-darwin"]; ··· 75 80 }).buildGoApplication; 76 81 modules = ./nix/gomod2nix.toml; 77 82 sqlite-lib = self.callPackage ./nix/pkgs/sqlite-lib.nix { 78 - inherit (pkgs) gcc; 79 83 inherit sqlite-lib-src; 80 84 }; 81 - genjwks = self.callPackage ./nix/pkgs/genjwks.nix {}; 82 85 lexgen = self.callPackage ./nix/pkgs/lexgen.nix {inherit indigo;}; 86 + goat = self.callPackage ./nix/pkgs/goat.nix {inherit indigo;}; 83 87 appview-static-files = self.callPackage ./nix/pkgs/appview-static-files.nix { 84 - inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src; 88 + inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src actor-typeahead-src; 85 89 }; 86 90 appview = self.callPackage ./nix/pkgs/appview.nix {}; 87 91 spindle = self.callPackage ./nix/pkgs/spindle.nix {}; ··· 90 94 }); 91 95 in { 92 96 overlays.default = final: prev: { 93 - inherit (mkPackageSet final) lexgen sqlite-lib genjwks spindle knot-unwrapped knot appview; 97 + inherit (mkPackageSet final) lexgen goat sqlite-lib spindle knot-unwrapped knot appview; 94 98 }; 95 99 96 100 packages = forAllSystems (system: let ··· 99 103 staticPackages = mkPackageSet pkgs.pkgsStatic; 100 104 crossPackages = mkPackageSet pkgs.pkgsCross.gnu64.pkgsStatic; 101 105 in { 102 - inherit (packages) appview appview-static-files lexgen genjwks spindle knot knot-unwrapped sqlite-lib; 106 + inherit (packages) appview appview-static-files lexgen goat spindle knot knot-unwrapped sqlite-lib; 103 107 104 108 pkgsStatic-appview = staticPackages.appview; 105 109 pkgsStatic-knot = staticPackages.knot; ··· 151 155 nativeBuildInputs = [ 152 156 pkgs.go 153 157 pkgs.air 154 - pkgs.tilt 155 158 pkgs.gopls 156 159 pkgs.httpie 157 160 pkgs.litecli ··· 167 170 mkdir -p appview/pages/static 168 171 # no preserve is needed because watch-tailwind will want to be able to overwrite 169 172 cp -fr --no-preserve=ownership ${packages'.appview-static-files}/* appview/pages/static 170 - export TANGLED_OAUTH_JWKS="$(${packages'.genjwks}/bin/genjwks)" 173 + export TANGLED_OAUTH_CLIENT_KID="$(date +%s)" 174 + export TANGLED_OAUTH_CLIENT_SECRET="$(${packages'.goat}/bin/goat key generate -t P-256 | grep -A1 "Secret Key" | tail -n1 | awk '{print $1}')" 171 175 ''; 172 176 env.CGO_ENABLED = 1; 173 177 }; ··· 178 182 air-watcher = name: arg: 179 183 pkgs.writeShellScriptBin "run" 180 184 '' 181 - ${pkgs.air}/bin/air -c /dev/null \ 182 - -build.cmd "${pkgs.go}/bin/go build -o ./out/${name}.out ./cmd/${name}/main.go" \ 183 - -build.bin "./out/${name}.out" \ 184 - -build.args_bin "${arg}" \ 185 - -build.stop_on_error "true" \ 186 - -build.include_ext "go" 185 + export PATH=${pkgs.go}/bin:$PATH 186 + ${pkgs.air}/bin/air -c ./.air/${name}.toml \ 187 + -build.args_bin "${arg}" 187 188 ''; 188 189 tailwind-watcher = 189 190 pkgs.writeShellScriptBin "run" ··· 207 208 type = "app"; 208 209 program = ''${air-watcher "knot" "server"}/bin/run''; 209 210 }; 211 + watch-spindle = { 212 + type = "app"; 213 + program = ''${air-watcher "spindle" ""}/bin/run''; 214 + }; 210 215 watch-tailwind = { 211 216 type = "app"; 212 217 program = ''${tailwind-watcher}/bin/run''; ··· 262 267 lexgen --build-file lexicon-build-config.json lexicons 263 268 sed -i.bak 's/\tutil/\/\/\tutil/' api/tangled/* 264 269 ${pkgs.gotools}/bin/goimports -w api/tangled/* 265 - go run cmd/gen.go 270 + go run ./cmd/cborgen/ 266 271 lexgen --build-file lexicon-build-config.json lexicons 267 272 rm api/tangled/*.bak 268 273 ''; ··· 278 283 }: { 279 284 imports = [./nix/modules/appview.nix]; 280 285 281 - services.tangled-appview.package = lib.mkDefault self.packages.${pkgs.system}.appview; 286 + services.tangled.appview.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.appview; 282 287 }; 283 288 nixosModules.knot = { 284 289 lib, ··· 287 292 }: { 288 293 imports = [./nix/modules/knot.nix]; 289 294 290 - services.tangled-knot.package = lib.mkDefault self.packages.${pkgs.system}.knot; 295 + services.tangled.knot.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.knot; 291 296 }; 292 297 nixosModules.spindle = { 293 298 lib, ··· 296 301 }: { 297 302 imports = [./nix/modules/spindle.nix]; 298 303 299 - services.tangled-spindle.package = lib.mkDefault self.packages.${pkgs.system}.spindle; 304 + services.tangled.spindle.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.spindle; 300 305 }; 301 306 }; 302 307 }
+43 -12
go.mod
··· 1 1 module tangled.org/core 2 2 3 - go 1.24.4 3 + go 1.25.0 4 4 5 5 require ( 6 6 github.com/Blank-Xu/sql-adapter v1.1.1 7 7 github.com/alecthomas/assert/v2 v2.11.0 8 8 github.com/alecthomas/chroma/v2 v2.15.0 9 9 github.com/avast/retry-go/v4 v4.6.1 10 + github.com/blevesearch/bleve/v2 v2.5.3 10 11 github.com/bluekeyes/go-gitdiff v0.8.1 11 12 github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e 12 13 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 14 + github.com/bmatcuk/doublestar/v4 v4.9.1 13 15 github.com/carlmjohnson/versioninfo v0.22.5 14 16 github.com/casbin/casbin/v2 v2.103.0 17 + github.com/charmbracelet/log v0.4.2 15 18 github.com/cloudflare/cloudflare-go v0.115.0 16 19 github.com/cyphar/filepath-securejoin v0.4.1 17 20 github.com/dgraph-io/ristretto v0.2.0 ··· 29 32 github.com/hiddeco/sshsig v0.2.0 30 33 github.com/hpcloud/tail v1.0.0 31 34 github.com/ipfs/go-cid v0.5.0 32 - github.com/lestrrat-go/jwx/v2 v2.1.6 33 35 github.com/mattn/go-sqlite3 v1.14.24 34 36 github.com/microcosm-cc/bluemonday v1.0.27 35 37 github.com/openbao/openbao/api/v2 v2.3.0 ··· 42 44 github.com/stretchr/testify v1.10.0 43 45 github.com/urfave/cli/v3 v3.3.3 44 46 github.com/whyrusleeping/cbor-gen v0.3.1 45 - github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57 46 47 github.com/yuin/goldmark v1.7.13 47 48 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc 49 + gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab 48 50 golang.org/x/crypto v0.40.0 49 51 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b 50 52 golang.org/x/image v0.31.0 ··· 58 60 dario.cat/mergo v1.0.1 // indirect 59 61 github.com/Microsoft/go-winio v0.6.2 // indirect 60 62 github.com/ProtonMail/go-crypto v1.3.0 // indirect 63 + github.com/RoaringBitmap/roaring/v2 v2.4.5 // indirect 61 64 github.com/alecthomas/repr v0.4.0 // indirect 62 65 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect 66 + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 63 67 github.com/aymerick/douceur v0.2.0 // indirect 64 68 github.com/beorn7/perks v1.0.1 // indirect 65 - github.com/bmatcuk/doublestar/v4 v4.7.1 // indirect 69 + github.com/bits-and-blooms/bitset v1.22.0 // indirect 70 + github.com/blevesearch/bleve_index_api v1.2.8 // indirect 71 + github.com/blevesearch/geo v0.2.4 // indirect 72 + github.com/blevesearch/go-faiss v1.0.25 // indirect 73 + github.com/blevesearch/go-porterstemmer v1.0.3 // indirect 74 + github.com/blevesearch/gtreap v0.1.1 // indirect 75 + github.com/blevesearch/mmap-go v1.0.4 // indirect 76 + github.com/blevesearch/scorch_segment_api/v2 v2.3.10 // indirect 77 + github.com/blevesearch/segment v0.9.1 // indirect 78 + github.com/blevesearch/snowballstem v0.9.0 // indirect 79 + github.com/blevesearch/upsidedown_store_api v1.0.2 // indirect 80 + github.com/blevesearch/vellum v1.1.0 // indirect 81 + github.com/blevesearch/zapx/v11 v11.4.2 // indirect 82 + github.com/blevesearch/zapx/v12 v12.4.2 // indirect 83 + github.com/blevesearch/zapx/v13 v13.4.2 // indirect 84 + github.com/blevesearch/zapx/v14 v14.4.2 // indirect 85 + github.com/blevesearch/zapx/v15 v15.4.2 // indirect 86 + github.com/blevesearch/zapx/v16 v16.2.4 // indirect 66 87 github.com/casbin/govaluate v1.3.0 // indirect 67 88 github.com/cenkalti/backoff/v4 v4.3.0 // indirect 68 89 github.com/cespare/xxhash/v2 v2.3.0 // indirect 90 + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect 91 + github.com/charmbracelet/lipgloss v1.1.0 // indirect 92 + github.com/charmbracelet/x/ansi v0.8.0 // indirect 93 + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect 94 + github.com/charmbracelet/x/term v0.2.1 // indirect 69 95 github.com/cloudflare/circl v1.6.2-0.20250618153321-aa837fd1539d // indirect 70 96 github.com/containerd/errdefs v1.0.0 // indirect 71 97 github.com/containerd/errdefs/pkg v0.3.0 // indirect 72 98 github.com/containerd/log v0.1.0 // indirect 73 99 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 74 - github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect 75 100 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 76 101 github.com/distribution/reference v0.6.0 // indirect 77 102 github.com/dlclark/regexp2 v1.11.5 // indirect ··· 84 109 github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect 85 110 github.com/go-git/go-billy/v5 v5.6.2 // indirect 86 111 github.com/go-jose/go-jose/v3 v3.0.4 // indirect 112 + github.com/go-logfmt/logfmt v0.6.0 // indirect 87 113 github.com/go-logr/logr v1.4.3 // indirect 88 114 github.com/go-logr/stdr v1.2.2 // indirect 89 115 github.com/go-redis/cache/v9 v9.0.0 // indirect ··· 93 119 github.com/golang-jwt/jwt/v5 v5.2.3 // indirect 94 120 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect 95 121 github.com/golang/mock v1.6.0 // indirect 122 + github.com/golang/protobuf v1.5.4 // indirect 123 + github.com/golang/snappy v0.0.4 // indirect 96 124 github.com/google/go-querystring v1.1.0 // indirect 97 125 github.com/gorilla/css v1.0.1 // indirect 98 126 github.com/gorilla/securecookie v1.1.2 // indirect ··· 118 146 github.com/ipfs/go-log v1.0.5 // indirect 119 147 github.com/ipfs/go-log/v2 v2.6.0 // indirect 120 148 github.com/ipfs/go-metrics-interface v0.3.0 // indirect 149 + github.com/json-iterator/go v1.1.12 // indirect 121 150 github.com/kevinburke/ssh_config v1.2.0 // indirect 122 151 github.com/klauspost/compress v1.18.0 // indirect 123 152 github.com/klauspost/cpuid/v2 v2.3.0 // indirect 124 - github.com/lestrrat-go/blackmagic v1.0.4 // indirect 125 - github.com/lestrrat-go/httpcc v1.0.1 // indirect 126 - github.com/lestrrat-go/httprc v1.0.6 // indirect 127 - github.com/lestrrat-go/iter v1.0.2 // indirect 128 - github.com/lestrrat-go/option v1.0.1 // indirect 153 + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 129 154 github.com/mattn/go-isatty v0.0.20 // indirect 155 + github.com/mattn/go-runewidth v0.0.16 // indirect 130 156 github.com/minio/sha256-simd v1.0.1 // indirect 131 157 github.com/mitchellh/mapstructure v1.5.0 // indirect 132 158 github.com/moby/docker-image-spec v1.3.1 // indirect 133 159 github.com/moby/sys/atomicwriter v0.1.0 // indirect 134 160 github.com/moby/term v0.5.2 // indirect 161 + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 162 + github.com/modern-go/reflect2 v1.0.2 // indirect 135 163 github.com/morikuni/aec v1.0.0 // indirect 136 164 github.com/mr-tron/base58 v1.2.0 // indirect 165 + github.com/mschoch/smat v0.2.0 // indirect 166 + github.com/muesli/termenv v0.16.0 // indirect 137 167 github.com/multiformats/go-base32 v0.1.0 // indirect 138 168 github.com/multiformats/go-base36 v0.2.0 // indirect 139 169 github.com/multiformats/go-multibase v0.2.0 // indirect ··· 152 182 github.com/prometheus/client_model v0.6.2 // indirect 153 183 github.com/prometheus/common v0.64.0 // indirect 154 184 github.com/prometheus/procfs v0.16.1 // indirect 185 + github.com/rivo/uniseg v0.4.7 // indirect 155 186 github.com/ryanuber/go-glob v1.0.0 // indirect 156 - github.com/segmentio/asm v1.2.0 // indirect 157 187 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect 158 188 github.com/spaolacci/murmur3 v1.1.0 // indirect 159 189 github.com/vmihailenco/go-tinylfu v0.2.2 // indirect 160 190 github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect 161 191 github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect 162 - github.com/wyatt915/treeblood v0.1.15 // indirect 192 + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 163 193 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 164 194 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 195 + go.etcd.io/bbolt v1.4.0 // indirect 165 196 go.opentelemetry.io/auto/sdk v1.1.0 // indirect 166 197 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect 167 198 go.opentelemetry.io/otel v1.37.0 // indirect
+89 -21
go.sum
··· 9 9 github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= 10 10 github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= 11 11 github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= 12 + github.com/RoaringBitmap/roaring/v2 v2.4.5 h1:uGrrMreGjvAtTBobc0g5IrW1D5ldxDQYe2JW2gggRdg= 13 + github.com/RoaringBitmap/roaring/v2 v2.4.5/go.mod h1:FiJcsfkGje/nZBZgCu0ZxCPOKD/hVXDS2dXi7/eUFE0= 12 14 github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= 13 15 github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= 14 16 github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= ··· 19 21 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= 20 22 github.com/avast/retry-go/v4 v4.6.1 h1:VkOLRubHdisGrHnTu89g08aQEWEgRU7LVEop3GbIcMk= 21 23 github.com/avast/retry-go/v4 v4.6.1/go.mod h1:V6oF8njAwxJ5gRo1Q7Cxab24xs5NCWZBeaHHBklR8mA= 24 + github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 25 + github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 22 26 github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= 23 27 github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= 24 28 github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 25 29 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 30 + github.com/bits-and-blooms/bitset v1.12.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= 31 + github.com/bits-and-blooms/bitset v1.22.0 h1:Tquv9S8+SGaS3EhyA+up3FXzmkhxPGjQQCkcs2uw7w4= 32 + github.com/bits-and-blooms/bitset v1.22.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= 33 + github.com/blevesearch/bleve/v2 v2.5.3 h1:9l1xtKaETv64SZc1jc4Sy0N804laSa/LeMbYddq1YEM= 34 + github.com/blevesearch/bleve/v2 v2.5.3/go.mod h1:Z/e8aWjiq8HeX+nW8qROSxiE0830yQA071dwR3yoMzw= 35 + github.com/blevesearch/bleve_index_api v1.2.8 h1:Y98Pu5/MdlkRyLM0qDHostYo7i+Vv1cDNhqTeR4Sy6Y= 36 + github.com/blevesearch/bleve_index_api v1.2.8/go.mod h1:rKQDl4u51uwafZxFrPD1R7xFOwKnzZW7s/LSeK4lgo0= 37 + github.com/blevesearch/geo v0.2.4 h1:ECIGQhw+QALCZaDcogRTNSJYQXRtC8/m8IKiA706cqk= 38 + github.com/blevesearch/geo v0.2.4/go.mod h1:K56Q33AzXt2YExVHGObtmRSFYZKYGv0JEN5mdacJJR8= 39 + github.com/blevesearch/go-faiss v1.0.25 h1:lel1rkOUGbT1CJ0YgzKwC7k+XH0XVBHnCVWahdCXk4U= 40 + github.com/blevesearch/go-faiss v1.0.25/go.mod h1:OMGQwOaRRYxrmeNdMrXJPvVx8gBnvE5RYrr0BahNnkk= 41 + github.com/blevesearch/go-porterstemmer v1.0.3 h1:GtmsqID0aZdCSNiY8SkuPJ12pD4jI+DdXTAn4YRcHCo= 42 + github.com/blevesearch/go-porterstemmer v1.0.3/go.mod h1:angGc5Ht+k2xhJdZi511LtmxuEf0OVpvUUNrwmM1P7M= 43 + github.com/blevesearch/gtreap v0.1.1 h1:2JWigFrzDMR+42WGIN/V2p0cUvn4UP3C4Q5nmaZGW8Y= 44 + github.com/blevesearch/gtreap v0.1.1/go.mod h1:QaQyDRAT51sotthUWAH4Sj08awFSSWzgYICSZ3w0tYk= 45 + github.com/blevesearch/mmap-go v1.0.4 h1:OVhDhT5B/M1HNPpYPBKIEJaD0F3Si+CrEKULGCDPWmc= 46 + github.com/blevesearch/mmap-go v1.0.4/go.mod h1:EWmEAOmdAS9z/pi/+Toxu99DnsbhG1TIxUoRmJw/pSs= 47 + github.com/blevesearch/scorch_segment_api/v2 v2.3.10 h1:Yqk0XD1mE0fDZAJXTjawJ8If/85JxnLd8v5vG/jWE/s= 48 + github.com/blevesearch/scorch_segment_api/v2 v2.3.10/go.mod h1:Z3e6ChN3qyN35yaQpl00MfI5s8AxUJbpTR/DL8QOQ+8= 49 + github.com/blevesearch/segment v0.9.1 h1:+dThDy+Lvgj5JMxhmOVlgFfkUtZV2kw49xax4+jTfSU= 50 + github.com/blevesearch/segment v0.9.1/go.mod h1:zN21iLm7+GnBHWTao9I+Au/7MBiL8pPFtJBJTsk6kQw= 51 + github.com/blevesearch/snowballstem v0.9.0 h1:lMQ189YspGP6sXvZQ4WZ+MLawfV8wOmPoD/iWeNXm8s= 52 + github.com/blevesearch/snowballstem v0.9.0/go.mod h1:PivSj3JMc8WuaFkTSRDW2SlrulNWPl4ABg1tC/hlgLs= 53 + github.com/blevesearch/upsidedown_store_api v1.0.2 h1:U53Q6YoWEARVLd1OYNc9kvhBMGZzVrdmaozG2MfoB+A= 54 + github.com/blevesearch/upsidedown_store_api v1.0.2/go.mod h1:M01mh3Gpfy56Ps/UXHjEO/knbqyQ1Oamg8If49gRwrQ= 55 + github.com/blevesearch/vellum v1.1.0 h1:CinkGyIsgVlYf8Y2LUQHvdelgXr6PYuvoDIajq6yR9w= 56 + github.com/blevesearch/vellum v1.1.0/go.mod h1:QgwWryE8ThtNPxtgWJof5ndPfx0/YMBh+W2weHKPw8Y= 57 + github.com/blevesearch/zapx/v11 v11.4.2 h1:l46SV+b0gFN+Rw3wUI1YdMWdSAVhskYuvxlcgpQFljs= 58 + github.com/blevesearch/zapx/v11 v11.4.2/go.mod h1:4gdeyy9oGa/lLa6D34R9daXNUvfMPZqUYjPwiLmekwc= 59 + github.com/blevesearch/zapx/v12 v12.4.2 h1:fzRbhllQmEMUuAQ7zBuMvKRlcPA5ESTgWlDEoB9uQNE= 60 + github.com/blevesearch/zapx/v12 v12.4.2/go.mod h1:TdFmr7afSz1hFh/SIBCCZvcLfzYvievIH6aEISCte58= 61 + github.com/blevesearch/zapx/v13 v13.4.2 h1:46PIZCO/ZuKZYgxI8Y7lOJqX3Irkc3N8W82QTK3MVks= 62 + github.com/blevesearch/zapx/v13 v13.4.2/go.mod h1:knK8z2NdQHlb5ot/uj8wuvOq5PhDGjNYQQy0QDnopZk= 63 + github.com/blevesearch/zapx/v14 v14.4.2 h1:2SGHakVKd+TrtEqpfeq8X+So5PShQ5nW6GNxT7fWYz0= 64 + github.com/blevesearch/zapx/v14 v14.4.2/go.mod h1:rz0XNb/OZSMjNorufDGSpFpjoFKhXmppH9Hi7a877D8= 65 + github.com/blevesearch/zapx/v15 v15.4.2 h1:sWxpDE0QQOTjyxYbAVjt3+0ieu8NCE0fDRaFxEsp31k= 66 + github.com/blevesearch/zapx/v15 v15.4.2/go.mod h1:1pssev/59FsuWcgSnTa0OeEpOzmhtmr/0/11H0Z8+Nw= 67 + github.com/blevesearch/zapx/v16 v16.2.4 h1:tGgfvleXTAkwsD5mEzgM3zCS/7pgocTCnO1oyAUjlww= 68 + github.com/blevesearch/zapx/v16 v16.2.4/go.mod h1:Rti/REtuuMmzwsI8/C/qIzRaEoSK/wiFYw5e5ctUKKs= 26 69 github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e h1:IutKPwmbU0LrYqw03EuwJtMdAe67rDTrL1U8S8dicRU= 27 70 github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e/go.mod h1:n6QE1NDPFoi7PRbMUZmc2y7FibCqiVU4ePpsvhHUBR8= 28 71 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA= 29 72 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4= 30 73 github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= 31 - github.com/bmatcuk/doublestar/v4 v4.7.1 h1:fdDeAqgT47acgwd9bd9HxJRDmc9UAmPpc+2m0CXv75Q= 32 74 github.com/bmatcuk/doublestar/v4 v4.7.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= 75 + github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE= 76 + github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= 33 77 github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= 34 78 github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= 35 79 github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= ··· 48 92 github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 49 93 github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 50 94 github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 95 + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= 96 + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= 97 + github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= 98 + github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= 99 + github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig= 100 + github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw= 101 + github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= 102 + github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= 103 + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= 104 + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 105 + github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 106 + github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 51 107 github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= 52 108 github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= 53 109 github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= ··· 69 125 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 70 126 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 71 127 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 72 - github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= 73 - github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= 74 128 github.com/dgraph-io/ristretto v0.2.0 h1:XAfl+7cmoUDWW/2Lx8TGZQjjxIQ2Ley9DSf52dru4WE= 75 129 github.com/dgraph-io/ristretto v0.2.0/go.mod h1:8uBHCU/PBV4Ag0CJrP47b9Ofby5dqWNh4FicAdoqFNU= 76 130 github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WAFKLNi6ZS0675eEUC9y3AlwSbQu1Y= ··· 120 174 github.com/go-git/go-git-fixtures/v5 v5.0.0-20241203230421-0753e18f8f03/go.mod h1:hMKrMnUE4W0SJ7bFyM00dyz/HoknZoptGWzrj6M+dEM= 121 175 github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY= 122 176 github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= 177 + github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= 178 + github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= 123 179 github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 124 180 github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 125 181 github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= ··· 154 210 github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= 155 211 github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 156 212 github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 213 + github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= 214 + github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 215 + github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= 216 + github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= 157 217 github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 158 218 github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 159 219 github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= ··· 165 225 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 166 226 github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 167 227 github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 228 + github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 168 229 github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 169 230 github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 170 231 github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= ··· 245 306 github.com/ipfs/go-log/v2 v2.6.0/go.mod h1:p+Efr3qaY5YXpx9TX7MoLCSEZX5boSWj9wh86P5HJa8= 246 307 github.com/ipfs/go-metrics-interface v0.3.0 h1:YwG7/Cy4R94mYDUuwsBfeziJCVm9pBMJ6q/JR9V40TU= 247 308 github.com/ipfs/go-metrics-interface v0.3.0/go.mod h1:OxxQjZDGocXVdyTPocns6cOLwHieqej/jos7H4POwoY= 309 + github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 310 + github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 248 311 github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= 249 312 github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 250 313 github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= ··· 264 327 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 265 328 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 266 329 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 267 - github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA= 268 - github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= 269 - github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= 270 - github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= 271 - github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k= 272 - github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo= 273 - github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= 274 - github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= 275 - github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVfecA= 276 - github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU= 277 - github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= 278 - github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= 330 + github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 331 + github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 279 332 github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= 280 333 github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 281 334 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 282 335 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 336 + github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 337 + github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 283 338 github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= 284 339 github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 285 340 github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= ··· 296 351 github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= 297 352 github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= 298 353 github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= 354 + github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 355 + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 356 + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 357 + github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 358 + github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 299 359 github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= 300 360 github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= 301 361 github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= 302 362 github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= 363 + github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM= 364 + github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw= 365 + github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 366 + github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 303 367 github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE= 304 368 github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI= 305 369 github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0= ··· 377 441 github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= 378 442 github.com/resend/resend-go/v2 v2.15.0 h1:B6oMEPf8IEQwn2Ovx/9yymkESLDSeNfLFaNMw+mzHhE= 379 443 github.com/resend/resend-go/v2 v2.15.0/go.mod h1:3YCb8c8+pLiqhtRFXTyFwlLvfjQtluxOr9HEh2BwCkQ= 444 + github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 445 + github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 446 + github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 380 447 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 381 448 github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= 382 449 github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= ··· 384 451 github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 385 452 github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= 386 453 github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= 387 - github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= 388 - github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= 389 454 github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= 390 455 github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= 391 456 github.com/sethvargo/go-envconfig v1.1.0 h1:cWZiJxeTm7AlCvzGXrEXaSTCNgip5oJepekh/BOQuog= ··· 430 495 github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw= 431 496 github.com/whyrusleeping/cbor-gen v0.3.1 h1:82ioxmhEYut7LBVGhGq8xoRkXPLElVuh5mV67AFfdv0= 432 497 github.com/whyrusleeping/cbor-gen v0.3.1/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= 433 - github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57 h1:UqtQdzLXnvdBdqn/go53qGyncw1wJ7Mq5SQdieM1/Ew= 434 - github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57/go.mod h1:BxSCWByWSRSuembL3cDG1IBUbkBoO/oW/6tF19aA4hs= 435 - github.com/wyatt915/treeblood v0.1.15 h1:3KZ3o2LpcKZAzOLqMoW9qeUzKEaKArKpbcPpTkNfQC8= 436 - github.com/wyatt915/treeblood v0.1.15/go.mod h1:i7+yhhmzdDP17/97pIsOSffw74EK/xk+qJ0029cSXUY= 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= 437 500 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 438 501 github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 439 502 github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= ··· 444 507 github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 445 508 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ= 446 509 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I= 510 + gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab h1:gK9tS6QJw5F0SIhYJnGG2P83kuabOdmWBbSmZhJkz2A= 511 + gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab/go.mod h1:SPu13/NPe1kMrbGoJldQwqtpNhXsmIuHCfm/aaGjU0c= 447 512 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA= 448 513 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8= 449 514 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q= 450 515 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I= 516 + go.etcd.io/bbolt v1.4.0 h1:TU77id3TnN/zKr7CO/uk+fBCwF2jGcMuw2B/FMAzYIk= 517 + go.etcd.io/bbolt v1.4.0/go.mod h1:AsD+OCi/qPN1giOX1aiLAha3o1U8rAz65bvN4j0sRuk= 451 518 go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= 452 519 go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= 453 520 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU= ··· 651 718 gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 652 719 gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 653 720 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 721 + gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 654 722 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 655 723 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 656 724 gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
+36 -61
guard/guard.go
··· 12 12 "os/exec" 13 13 "strings" 14 14 15 - "github.com/bluesky-social/indigo/atproto/identity" 16 15 securejoin "github.com/cyphar/filepath-securejoin" 17 16 "github.com/urfave/cli/v3" 18 - "tangled.org/core/idresolver" 19 17 "tangled.org/core/log" 20 18 ) 21 19 ··· 93 91 "command", sshCommand, 94 92 "client", clientIP) 95 93 94 + // TODO: greet user with their resolved handle instead of did 96 95 if sshCommand == "" { 97 96 l.Info("access denied: no interactive shells", "user", incomingUser) 98 97 fmt.Fprintf(os.Stderr, "Hi @%s! You've successfully authenticated.\n", incomingUser) ··· 107 106 } 108 107 109 108 gitCommand := cmdParts[0] 110 - 111 - // did:foo/repo-name or 112 - // handle/repo-name or 113 - // any of the above with a leading slash (/) 114 - 115 - components := strings.Split(strings.TrimPrefix(strings.Trim(cmdParts[1], "'"), "/"), "/") 116 - l.Info("command components", "components", components) 117 - 118 - if len(components) != 2 { 119 - l.Error("invalid repo format", "components", components) 120 - fmt.Fprintln(os.Stderr, "invalid repo format, needs <user>/<repo> or /<user>/<repo>") 121 - os.Exit(-1) 122 - } 123 - 124 - didOrHandle := components[0] 125 - identity := resolveIdentity(ctx, l, didOrHandle) 126 - did := identity.DID.String() 127 - repoName := components[1] 128 - qualifiedRepoName, _ := securejoin.SecureJoin(did, repoName) 109 + repoPath := cmdParts[1] 129 110 130 111 validCommands := map[string]bool{ 131 112 "git-receive-pack": true, ··· 138 119 return fmt.Errorf("access denied: invalid git command") 139 120 } 140 121 141 - if gitCommand != "git-upload-pack" { 142 - if !isPushPermitted(l, incomingUser, qualifiedRepoName, endpoint) { 143 - l.Error("access denied: user not allowed", 144 - "did", incomingUser, 145 - "reponame", qualifiedRepoName) 146 - fmt.Fprintln(os.Stderr, "access denied: user not allowed") 147 - os.Exit(-1) 148 - } 122 + // qualify repo path from internal server which holds the knot config 123 + qualifiedRepoPath, err := guardAndQualifyRepo(l, endpoint, incomingUser, repoPath, gitCommand) 124 + if err != nil { 125 + l.Error("failed to run guard", "err", err) 126 + fmt.Fprintln(os.Stderr, err) 127 + os.Exit(1) 149 128 } 150 129 151 - fullPath, _ := securejoin.SecureJoin(gitDir, qualifiedRepoName) 130 + fullPath, _ := securejoin.SecureJoin(gitDir, qualifiedRepoPath) 152 131 153 132 l.Info("processing command", 154 133 "user", incomingUser, 155 134 "command", gitCommand, 156 - "repo", repoName, 135 + "repo", repoPath, 157 136 "fullPath", fullPath, 158 137 "client", clientIP) 159 138 ··· 177 156 gitCmd.Stdin = os.Stdin 178 157 gitCmd.Env = append(os.Environ(), 179 158 fmt.Sprintf("GIT_USER_DID=%s", incomingUser), 180 - fmt.Sprintf("GIT_USER_PDS_ENDPOINT=%s", identity.PDSEndpoint()), 181 159 ) 182 160 183 161 if err := gitCmd.Run(); err != nil { ··· 189 167 l.Info("command completed", 190 168 "user", incomingUser, 191 169 "command", gitCommand, 192 - "repo", repoName, 170 + "repo", repoPath, 193 171 "success", true) 194 172 195 173 return nil 196 174 } 197 175 198 - func resolveIdentity(ctx context.Context, l *slog.Logger, didOrHandle string) *identity.Identity { 199 - resolver := idresolver.DefaultResolver() 200 - ident, err := resolver.ResolveIdent(ctx, didOrHandle) 176 + // runs guardAndQualifyRepo logic 177 + func guardAndQualifyRepo(l *slog.Logger, endpoint, incomingUser, repo, gitCommand string) (string, error) { 178 + u, _ := url.Parse(endpoint + "/guard") 179 + q := u.Query() 180 + q.Add("user", incomingUser) 181 + q.Add("repo", repo) 182 + q.Add("gitCmd", gitCommand) 183 + u.RawQuery = q.Encode() 184 + 185 + resp, err := http.Get(u.String()) 201 186 if err != nil { 202 - l.Error("Error resolving handle", "error", err, "handle", didOrHandle) 203 - fmt.Fprintf(os.Stderr, "error resolving handle: %v\n", err) 204 - os.Exit(1) 205 - } 206 - if ident.Handle.IsInvalidHandle() { 207 - l.Error("Error resolving handle", "invalid handle", didOrHandle) 208 - fmt.Fprintf(os.Stderr, "error resolving handle: invalid handle\n") 209 - os.Exit(1) 187 + return "", err 210 188 } 211 - return ident 212 - } 189 + defer resp.Body.Close() 213 190 214 - func isPushPermitted(l *slog.Logger, user, qualifiedRepoName, endpoint string) bool { 215 - u, _ := url.Parse(endpoint + "/push-allowed") 216 - q := u.Query() 217 - q.Add("user", user) 218 - q.Add("repo", qualifiedRepoName) 219 - u.RawQuery = q.Encode() 191 + l.Info("Running guard", "url", u.String(), "status", resp.Status) 220 192 221 - req, err := http.Get(u.String()) 193 + body, err := io.ReadAll(resp.Body) 222 194 if err != nil { 223 - l.Error("Error verifying permissions", "error", err) 224 - fmt.Fprintf(os.Stderr, "error verifying permissions: %v\n", err) 225 - os.Exit(1) 195 + return "", err 226 196 } 227 - 228 - l.Info("Checking push permission", 229 - "url", u.String(), 230 - "status", req.Status) 197 + text := string(body) 231 198 232 - return req.StatusCode == http.StatusNoContent 199 + switch resp.StatusCode { 200 + case http.StatusOK: 201 + return text, nil 202 + case http.StatusForbidden: 203 + l.Error("access denied: user not allowed", "did", incomingUser, "reponame", text) 204 + return text, errors.New("access denied: user not allowed") 205 + default: 206 + return "", errors.New(text) 207 + } 233 208 }
+4 -4
hook/hook.go
··· 48 48 }, 49 49 Commands: []*cli.Command{ 50 50 { 51 - Name: "post-recieve", 52 - Usage: "sends a post-recieve hook to the knot (waits for stdin)", 53 - Action: postRecieve, 51 + Name: "post-receive", 52 + Usage: "sends a post-receive hook to the knot (waits for stdin)", 53 + Action: postReceive, 54 54 }, 55 55 }, 56 56 } 57 57 } 58 58 59 - func postRecieve(ctx context.Context, cmd *cli.Command) error { 59 + func postReceive(ctx context.Context, cmd *cli.Command) error { 60 60 gitDir := cmd.String("git-dir") 61 61 userDid := cmd.String("user-did") 62 62 userHandle := cmd.String("user-handle")
+1 -1
hook/setup.go
··· 138 138 option_var="GIT_PUSH_OPTION_$i" 139 139 push_options+=(-push-option "${!option_var}") 140 140 done 141 - %s hook -git-dir "$GIT_DIR" -user-did "$GIT_USER_DID" -user-handle "$GIT_USER_HANDLE" -internal-api "%s" "${push_options[@]}" post-recieve 141 + %s hook -git-dir "$GIT_DIR" -user-did "$GIT_USER_DID" -user-handle "$GIT_USER_HANDLE" -internal-api "%s" "${push_options[@]}" post-receive 142 142 `, executablePath, config.internalApi) 143 143 144 144 return os.WriteFile(hookPath, []byte(hookContent), 0755)
+17 -8
idresolver/resolver.go
··· 17 17 directory identity.Directory 18 18 } 19 19 20 - func BaseDirectory() identity.Directory { 20 + func BaseDirectory(plcUrl string) identity.Directory { 21 21 base := identity.BaseDirectory{ 22 - PLCURL: identity.DefaultPLCURL, 22 + PLCURL: plcUrl, 23 23 HTTPClient: http.Client{ 24 24 Timeout: time.Second * 10, 25 25 Transport: &http.Transport{ ··· 42 42 return &base 43 43 } 44 44 45 - func RedisDirectory(url string) (identity.Directory, error) { 45 + func RedisDirectory(url, plcUrl string) (identity.Directory, error) { 46 46 hitTTL := time.Hour * 24 47 47 errTTL := time.Second * 30 48 48 invalidHandleTTL := time.Minute * 5 49 - return redisdir.NewRedisDirectory(BaseDirectory(), url, hitTTL, errTTL, invalidHandleTTL, 10000) 49 + return redisdir.NewRedisDirectory( 50 + BaseDirectory(plcUrl), 51 + url, 52 + hitTTL, 53 + errTTL, 54 + invalidHandleTTL, 55 + 10000, 56 + ) 50 57 } 51 58 52 - func DefaultResolver() *Resolver { 59 + func DefaultResolver(plcUrl string) *Resolver { 60 + base := BaseDirectory(plcUrl) 61 + cached := identity.NewCacheDirectory(base, 250_000, time.Hour*24, time.Minute*2, time.Minute*5) 53 62 return &Resolver{ 54 - directory: identity.DefaultDirectory(), 63 + directory: &cached, 55 64 } 56 65 } 57 66 58 - func RedisResolver(redisUrl string) (*Resolver, error) { 59 - directory, err := RedisDirectory(redisUrl) 67 + func RedisResolver(redisUrl, plcUrl string) (*Resolver, error) { 68 + directory, err := RedisDirectory(redisUrl, plcUrl) 60 69 if err != nil { 61 70 return nil, err 62 71 }
+109 -12
input.css
··· 134 134 } 135 135 136 136 .prose hr { 137 - @apply my-2; 137 + @apply my-2; 138 138 } 139 139 140 140 .prose li:has(input) { 141 - @apply list-none; 141 + @apply list-none; 142 142 } 143 143 144 144 .prose ul:has(input) { 145 - @apply pl-2; 145 + @apply pl-2; 146 146 } 147 147 148 148 .prose .heading .anchor { 149 - @apply no-underline mx-2 opacity-0; 149 + @apply no-underline mx-2 opacity-0; 150 150 } 151 151 152 152 .prose .heading:hover .anchor { 153 - @apply opacity-70; 153 + @apply opacity-70; 154 154 } 155 155 156 156 .prose .heading .anchor:hover { 157 - @apply opacity-70; 157 + @apply opacity-70; 158 158 } 159 159 160 160 .prose a.footnote-backref { 161 - @apply no-underline; 161 + @apply no-underline; 162 + } 163 + 164 + .prose a.mention { 165 + @apply no-underline hover:underline; 162 166 } 163 167 164 168 .prose li { 165 - @apply my-0 py-0; 169 + @apply my-0 py-0; 166 170 } 167 171 168 - .prose ul, .prose ol { 169 - @apply my-1 py-0; 172 + .prose ul, 173 + .prose ol { 174 + @apply my-1 py-0; 170 175 } 171 176 172 177 .prose img { ··· 176 181 } 177 182 178 183 .prose input { 179 - @apply inline-block my-0 mb-1 mx-1; 184 + @apply inline-block my-0 mb-1 mx-1; 180 185 } 181 186 182 187 .prose input[type="checkbox"] { 183 188 @apply disabled:accent-blue-500 checked:accent-blue-500 disabled:checked:accent-blue-500; 184 189 } 190 + 191 + /* Base callout */ 192 + details[data-callout] { 193 + @apply border-l-4 pl-3 py-2 text-gray-800 dark:text-gray-200 my-4; 194 + } 195 + 196 + details[data-callout] > summary { 197 + @apply font-bold cursor-pointer mb-1; 198 + } 199 + 200 + details[data-callout] > .callout-content { 201 + @apply text-sm leading-snug; 202 + } 203 + 204 + /* Note (blue) */ 205 + details[data-callout="note" i] { 206 + @apply border-blue-400 dark:border-blue-500; 207 + } 208 + details[data-callout="note" i] > summary { 209 + @apply text-blue-700 dark:text-blue-400; 210 + } 211 + 212 + /* Important (purple) */ 213 + details[data-callout="important" i] { 214 + @apply border-purple-400 dark:border-purple-500; 215 + } 216 + details[data-callout="important" i] > summary { 217 + @apply text-purple-700 dark:text-purple-400; 218 + } 219 + 220 + /* Warning (yellow) */ 221 + details[data-callout="warning" i] { 222 + @apply border-yellow-400 dark:border-yellow-500; 223 + } 224 + details[data-callout="warning" i] > summary { 225 + @apply text-yellow-700 dark:text-yellow-400; 226 + } 227 + 228 + /* Caution (red) */ 229 + details[data-callout="caution" i] { 230 + @apply border-red-400 dark:border-red-500; 231 + } 232 + details[data-callout="caution" i] > summary { 233 + @apply text-red-700 dark:text-red-400; 234 + } 235 + 236 + /* Tip (green) */ 237 + details[data-callout="tip" i] { 238 + @apply border-green-400 dark:border-green-500; 239 + } 240 + details[data-callout="tip" i] > summary { 241 + @apply text-green-700 dark:text-green-400; 242 + } 243 + 244 + /* Optional: hide the disclosure arrow like GitHub */ 245 + details[data-callout] > summary::-webkit-details-marker { 246 + display: none; 247 + } 248 + 185 249 } 186 250 @layer utilities { 187 251 .error { ··· 228 292 } 229 293 /* LineHighlight */ 230 294 .chroma .hl { 231 - @apply bg-amber-400/30 dark:bg-amber-500/20; 295 + @apply bg-amber-400/30 dark:bg-amber-500/20; 232 296 } 233 297 234 298 /* LineNumbersTable */ ··· 865 929 text-decoration: underline; 866 930 } 867 931 } 932 + 933 + actor-typeahead { 934 + --color-background: #ffffff; 935 + --color-border: #d1d5db; 936 + --color-shadow: #000000; 937 + --color-hover: #f9fafb; 938 + --color-avatar-fallback: #e5e7eb; 939 + --radius: 0.0; 940 + --padding-menu: 0.0rem; 941 + z-index: 1000; 942 + } 943 + 944 + actor-typeahead::part(handle) { 945 + color: #111827; 946 + } 947 + 948 + actor-typeahead::part(menu) { 949 + box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); 950 + } 951 + 952 + @media (prefers-color-scheme: dark) { 953 + actor-typeahead { 954 + --color-background: #1f2937; 955 + --color-border: #4b5563; 956 + --color-shadow: #000000; 957 + --color-hover: #374151; 958 + --color-avatar-fallback: #4b5563; 959 + } 960 + 961 + actor-typeahead::part(handle) { 962 + color: #f9fafb; 963 + } 964 + }
+16 -5
jetstream/jetstream.go
··· 72 72 // existing instances of the closure when j.WantedDids is mutated 73 73 return func(ctx context.Context, evt *models.Event) error { 74 74 75 + j.mu.RLock() 75 76 // empty filter => all dids allowed 76 - if len(j.wantedDids) == 0 { 77 - return processFunc(ctx, evt) 77 + matches := len(j.wantedDids) == 0 78 + if !matches { 79 + if _, ok := j.wantedDids[evt.Did]; ok { 80 + matches = true 81 + } 78 82 } 83 + j.mu.RUnlock() 79 84 80 - if _, ok := j.wantedDids[evt.Did]; ok { 85 + if matches { 81 86 return processFunc(ctx, evt) 82 87 } else { 83 88 return nil ··· 114 119 115 120 sched := sequential.NewScheduler(j.ident, logger, j.withDidFilter(processFunc)) 116 121 117 - client, err := client.NewClient(j.cfg, log.New("jetstream"), sched) 122 + client, err := client.NewClient(j.cfg, logger, sched) 118 123 if err != nil { 119 124 return fmt.Errorf("failed to create jetstream client: %w", err) 120 125 } ··· 122 127 123 128 go func() { 124 129 if j.waitForDid { 125 - for len(j.wantedDids) == 0 { 130 + for { 131 + j.mu.RLock() 132 + hasDid := len(j.wantedDids) != 0 133 + j.mu.RUnlock() 134 + if hasDid { 135 + break 136 + } 126 137 time.Sleep(time.Second) 127 138 } 128 139 }
+1
knotserver/config/config.go
··· 19 19 InternalListenAddr string `env:"INTERNAL_LISTEN_ADDR, default=127.0.0.1:5444"` 20 20 DBPath string `env:"DB_PATH, default=knotserver.db"` 21 21 Hostname string `env:"HOSTNAME, required"` 22 + PlcUrl string `env:"PLC_URL, default=https://plc.directory"` 22 23 JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"` 23 24 Owner string `env:"OWNER, required"` 24 25 LogDids bool `env:"LOG_DIDS, default=true"`
+81
knotserver/db/db.go
··· 1 + package db 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + "log/slog" 7 + "strings" 8 + 9 + _ "github.com/mattn/go-sqlite3" 10 + "tangled.org/core/log" 11 + ) 12 + 13 + type DB struct { 14 + db *sql.DB 15 + logger *slog.Logger 16 + } 17 + 18 + func Setup(ctx context.Context, dbPath string) (*DB, error) { 19 + // https://github.com/mattn/go-sqlite3#connection-string 20 + opts := []string{ 21 + "_foreign_keys=1", 22 + "_journal_mode=WAL", 23 + "_synchronous=NORMAL", 24 + "_auto_vacuum=incremental", 25 + } 26 + 27 + logger := log.FromContext(ctx) 28 + logger = log.SubLogger(logger, "db") 29 + 30 + db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&")) 31 + if err != nil { 32 + return nil, err 33 + } 34 + 35 + conn, err := db.Conn(ctx) 36 + if err != nil { 37 + return nil, err 38 + } 39 + defer conn.Close() 40 + 41 + _, err = conn.ExecContext(ctx, ` 42 + create table if not exists known_dids ( 43 + did text primary key 44 + ); 45 + 46 + create table if not exists public_keys ( 47 + id integer primary key autoincrement, 48 + did text not null, 49 + key text not null, 50 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 51 + unique(did, key), 52 + foreign key (did) references known_dids(did) on delete cascade 53 + ); 54 + 55 + create table if not exists _jetstream ( 56 + id integer primary key autoincrement, 57 + last_time_us integer not null 58 + ); 59 + 60 + create table if not exists events ( 61 + rkey text not null, 62 + nsid text not null, 63 + event text not null, -- json 64 + created integer not null default (strftime('%s', 'now')), 65 + primary key (rkey, nsid) 66 + ); 67 + 68 + create table if not exists migrations ( 69 + id integer primary key autoincrement, 70 + name text unique 71 + ); 72 + `) 73 + if err != nil { 74 + return nil, err 75 + } 76 + 77 + return &DB{ 78 + db: db, 79 + logger: logger, 80 + }, nil 81 + }
-64
knotserver/db/init.go
··· 1 - package db 2 - 3 - import ( 4 - "database/sql" 5 - "strings" 6 - 7 - _ "github.com/mattn/go-sqlite3" 8 - ) 9 - 10 - type DB struct { 11 - db *sql.DB 12 - } 13 - 14 - func Setup(dbPath string) (*DB, error) { 15 - // https://github.com/mattn/go-sqlite3#connection-string 16 - opts := []string{ 17 - "_foreign_keys=1", 18 - "_journal_mode=WAL", 19 - "_synchronous=NORMAL", 20 - "_auto_vacuum=incremental", 21 - } 22 - 23 - db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&")) 24 - if err != nil { 25 - return nil, err 26 - } 27 - 28 - // NOTE: If any other migration is added here, you MUST 29 - // copy the pattern in appview: use a single sql.Conn 30 - // for every migration. 31 - 32 - _, err = db.Exec(` 33 - create table if not exists known_dids ( 34 - did text primary key 35 - ); 36 - 37 - create table if not exists public_keys ( 38 - id integer primary key autoincrement, 39 - did text not null, 40 - key text not null, 41 - created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 42 - unique(did, key), 43 - foreign key (did) references known_dids(did) on delete cascade 44 - ); 45 - 46 - create table if not exists _jetstream ( 47 - id integer primary key autoincrement, 48 - last_time_us integer not null 49 - ); 50 - 51 - create table if not exists events ( 52 - rkey text not null, 53 - nsid text not null, 54 - event text not null, -- json 55 - created integer not null default (strftime('%s', 'now')), 56 - primary key (rkey, nsid) 57 - ); 58 - `) 59 - if err != nil { 60 - return nil, err 61 - } 62 - 63 - return &DB{db: db}, nil 64 - }
+2 -3
knotserver/events.go
··· 8 8 "time" 9 9 10 10 "github.com/gorilla/websocket" 11 + "tangled.org/core/log" 11 12 ) 12 13 13 14 var upgrader = websocket.Upgrader{ ··· 16 17 } 17 18 18 19 func (h *Knot) Events(w http.ResponseWriter, r *http.Request) { 19 - l := h.l.With("handler", "OpLog") 20 + l := log.SubLogger(h.l, "eventstream") 20 21 l.Debug("received new connection") 21 22 22 23 conn, err := upgrader.Upgrade(w, r, nil) ··· 75 76 } 76 77 case <-time.After(30 * time.Second): 77 78 // send a keep-alive 78 - l.Debug("sent keepalive") 79 79 if err = conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(time.Second)); err != nil { 80 80 l.Error("failed to write control", "err", err) 81 81 } ··· 89 89 h.l.Error("failed to fetch events from db", "err", err, "cursor", cursor) 90 90 return err 91 91 } 92 - h.l.Debug("ops", "ops", events) 93 92 94 93 for _, event := range events { 95 94 // first extract the inner json into a map
+5
knotserver/git/branch.go
··· 110 110 slices.Reverse(branches) 111 111 return branches, nil 112 112 } 113 + 114 + func (g *GitRepo) DeleteBranch(branch string) error { 115 + ref := plumbing.NewBranchReferenceName(branch) 116 + return g.r.Storer.RemoveReference(ref) 117 + }
+1 -17
knotserver/git/diff.go
··· 77 77 nd.Diff = append(nd.Diff, ndiff) 78 78 } 79 79 80 - nd.Stat.FilesChanged = len(diffs) 81 - nd.Commit.This = c.Hash.String() 82 - nd.Commit.PGPSignature = c.PGPSignature 83 - nd.Commit.Committer = c.Committer 84 - nd.Commit.Tree = c.TreeHash.String() 85 - 86 - if parent.Hash.IsZero() { 87 - nd.Commit.Parent = "" 88 - } else { 89 - nd.Commit.Parent = parent.Hash.String() 90 - } 91 - nd.Commit.Author = c.Author 92 - nd.Commit.Message = c.Message 93 - 94 - if v, ok := c.ExtraHeaders["change-id"]; ok { 95 - nd.Commit.ChangedId = string(v) 96 - } 80 + nd.Commit.FromGoGitCommit(c) 97 81 98 82 return &nd, nil 99 83 }
+38 -2
knotserver/git/fork.go
··· 3 3 import ( 4 4 "errors" 5 5 "fmt" 6 + "log/slog" 7 + "net/url" 6 8 "os/exec" 9 + "path/filepath" 7 10 8 11 "github.com/go-git/go-git/v5" 9 12 "github.com/go-git/go-git/v5/config" 13 + knotconfig "tangled.org/core/knotserver/config" 10 14 ) 11 15 12 - func Fork(repoPath, source string) error { 13 - cloneCmd := exec.Command("git", "clone", "--bare", source, repoPath) 16 + func Fork(repoPath, source string, cfg *knotconfig.Config) error { 17 + u, err := url.Parse(source) 18 + if err != nil { 19 + return fmt.Errorf("failed to parse source URL: %w", err) 20 + } 21 + 22 + if o := optimizeClone(u, cfg); o != nil { 23 + u = o 24 + } 25 + 26 + cloneCmd := exec.Command("git", "clone", "--bare", u.String(), repoPath) 14 27 if err := cloneCmd.Run(); err != nil { 15 28 return fmt.Errorf("failed to bare clone repository: %w", err) 16 29 } ··· 21 34 } 22 35 23 36 return nil 37 + } 38 + 39 + func optimizeClone(u *url.URL, cfg *knotconfig.Config) *url.URL { 40 + // only optimize if it's the same host 41 + if u.Host != cfg.Server.Hostname { 42 + return nil 43 + } 44 + 45 + local := filepath.Join(cfg.Repo.ScanPath, u.Path) 46 + 47 + // sanity check: is there a git repo there? 48 + if _, err := PlainOpen(local); err != nil { 49 + return nil 50 + } 51 + 52 + // create optimized file:// URL 53 + optimized := &url.URL{ 54 + Scheme: "file", 55 + Path: local, 56 + } 57 + 58 + slog.Debug("performing local clone", "url", optimized.String()) 59 + return optimized 24 60 } 25 61 26 62 func (g *GitRepo) Sync() error {
+71 -2
knotserver/git/git.go
··· 3 3 import ( 4 4 "archive/tar" 5 5 "bytes" 6 + "errors" 6 7 "fmt" 7 8 "io" 8 9 "io/fs" ··· 12 13 "time" 13 14 14 15 "github.com/go-git/go-git/v5" 16 + "github.com/go-git/go-git/v5/config" 15 17 "github.com/go-git/go-git/v5/plumbing" 16 18 "github.com/go-git/go-git/v5/plumbing/object" 17 19 ) 18 20 19 21 var ( 20 - ErrBinaryFile = fmt.Errorf("binary file") 21 - ErrNotBinaryFile = fmt.Errorf("not binary file") 22 + ErrBinaryFile = errors.New("binary file") 23 + ErrNotBinaryFile = errors.New("not binary file") 24 + ErrMissingGitModules = errors.New("no .gitmodules file found") 25 + ErrInvalidGitModules = errors.New("invalid .gitmodules file") 26 + ErrNotSubmodule = errors.New("path is not a submodule") 22 27 ) 23 28 24 29 type GitRepo struct { ··· 69 74 return nil, fmt.Errorf("opening %s: %w", path, err) 70 75 } 71 76 return &g, nil 77 + } 78 + 79 + // re-open a repository and update references 80 + func (g *GitRepo) Refresh() error { 81 + refreshed, err := PlainOpen(g.path) 82 + if err != nil { 83 + return err 84 + } 85 + 86 + *g = *refreshed 87 + return nil 72 88 } 73 89 74 90 func (g *GitRepo) Commits(offset, limit int) ([]*object.Commit, error) { ··· 177 193 defer reader.Close() 178 194 179 195 return io.ReadAll(reader) 196 + } 197 + 198 + // read and parse .gitmodules 199 + func (g *GitRepo) Submodules() (*config.Modules, error) { 200 + c, err := g.r.CommitObject(g.h) 201 + if err != nil { 202 + return nil, fmt.Errorf("commit object: %w", err) 203 + } 204 + 205 + tree, err := c.Tree() 206 + if err != nil { 207 + return nil, fmt.Errorf("tree: %w", err) 208 + } 209 + 210 + // read .gitmodules file 211 + modulesEntry, err := tree.FindEntry(".gitmodules") 212 + if err != nil { 213 + return nil, fmt.Errorf("%w: %w", ErrMissingGitModules, err) 214 + } 215 + 216 + modulesFile, err := tree.TreeEntryFile(modulesEntry) 217 + if err != nil { 218 + return nil, fmt.Errorf("%w: failed to read file: %w", ErrInvalidGitModules, err) 219 + } 220 + 221 + content, err := modulesFile.Contents() 222 + if err != nil { 223 + return nil, fmt.Errorf("%w: failed to read contents: %w", ErrInvalidGitModules, err) 224 + } 225 + 226 + // parse .gitmodules 227 + modules := config.NewModules() 228 + if err = modules.Unmarshal([]byte(content)); err != nil { 229 + return nil, fmt.Errorf("%w: failed to parse: %w", ErrInvalidGitModules, err) 230 + } 231 + 232 + return modules, nil 233 + } 234 + 235 + func (g *GitRepo) Submodule(path string) (*config.Submodule, error) { 236 + modules, err := g.Submodules() 237 + if err != nil { 238 + return nil, err 239 + } 240 + 241 + for _, submodule := range modules.Submodules { 242 + if submodule.Path == path { 243 + return submodule, nil 244 + } 245 + } 246 + 247 + // path is not a submodule 248 + return nil, ErrNotSubmodule 180 249 } 181 250 182 251 func (g *GitRepo) Branch(name string) (*plumbing.Reference, error) {
+21 -2
knotserver/git/last_commit.go
··· 30 30 commitCache = cache 31 31 } 32 32 33 - func (g *GitRepo) streamingGitLog(ctx context.Context, extraArgs ...string) (io.Reader, error) { 33 + // processReader wraps a reader and ensures the associated process is cleaned up 34 + type processReader struct { 35 + io.Reader 36 + cmd *exec.Cmd 37 + stdout io.ReadCloser 38 + } 39 + 40 + func (pr *processReader) Close() error { 41 + if err := pr.stdout.Close(); err != nil { 42 + return err 43 + } 44 + return pr.cmd.Wait() 45 + } 46 + 47 + func (g *GitRepo) streamingGitLog(ctx context.Context, extraArgs ...string) (io.ReadCloser, error) { 34 48 args := []string{} 35 49 args = append(args, "log") 36 50 args = append(args, g.h.String()) ··· 48 62 return nil, err 49 63 } 50 64 51 - return stdout, nil 65 + return &processReader{ 66 + Reader: stdout, 67 + cmd: cmd, 68 + stdout: stdout, 69 + }, nil 52 70 } 53 71 54 72 type commit struct { ··· 104 122 if err != nil { 105 123 return nil, err 106 124 } 125 + defer output.Close() // Ensure the git process is properly cleaned up 107 126 108 127 reader := bufio.NewReader(output) 109 128 var current commit
+150 -37
knotserver/git/merge.go
··· 4 4 "bytes" 5 5 "crypto/sha256" 6 6 "fmt" 7 + "log" 7 8 "os" 8 9 "os/exec" 9 10 "regexp" ··· 12 13 "github.com/dgraph-io/ristretto" 13 14 "github.com/go-git/go-git/v5" 14 15 "github.com/go-git/go-git/v5/plumbing" 16 + "tangled.org/core/patchutil" 17 + "tangled.org/core/types" 15 18 ) 16 19 17 20 type MergeCheckCache struct { ··· 32 35 mergeCheckCache = MergeCheckCache{cache} 33 36 } 34 37 35 - func (m *MergeCheckCache) cacheKey(g *GitRepo, patch []byte, targetBranch string) string { 38 + func (m *MergeCheckCache) cacheKey(g *GitRepo, patch string, targetBranch string) string { 36 39 sep := byte(':') 37 40 hash := sha256.Sum256(fmt.Append([]byte{}, g.path, sep, g.h.String(), sep, patch, sep, targetBranch)) 38 41 return fmt.Sprintf("%x", hash) ··· 49 52 } 50 53 } 51 54 52 - func (m *MergeCheckCache) Set(g *GitRepo, patch []byte, targetBranch string, mergeCheck error) { 55 + func (m *MergeCheckCache) Set(g *GitRepo, patch string, targetBranch string, mergeCheck error) { 53 56 key := m.cacheKey(g, patch, targetBranch) 54 57 val := m.cacheVal(mergeCheck) 55 58 m.cache.Set(key, val, 0) 56 59 } 57 60 58 - func (m *MergeCheckCache) Get(g *GitRepo, patch []byte, targetBranch string) (error, bool) { 61 + func (m *MergeCheckCache) Get(g *GitRepo, patch string, targetBranch string) (error, bool) { 59 62 key := m.cacheKey(g, patch, targetBranch) 60 63 if val, ok := m.cache.Get(key); ok { 61 64 if val == struct{}{} { ··· 104 107 return fmt.Sprintf("merge failed: %s", e.Message) 105 108 } 106 109 107 - func (g *GitRepo) createTempFileWithPatch(patchData []byte) (string, error) { 110 + func (g *GitRepo) createTempFileWithPatch(patchData string) (string, error) { 108 111 tmpFile, err := os.CreateTemp("", "git-patch-*.patch") 109 112 if err != nil { 110 113 return "", fmt.Errorf("failed to create temporary patch file: %w", err) 111 114 } 112 115 113 - if _, err := tmpFile.Write(patchData); err != nil { 116 + if _, err := tmpFile.Write([]byte(patchData)); err != nil { 114 117 tmpFile.Close() 115 118 os.Remove(tmpFile.Name()) 116 119 return "", fmt.Errorf("failed to write patch data to temporary file: %w", err) ··· 162 165 return nil 163 166 } 164 167 165 - func (g *GitRepo) applyPatch(tmpDir, patchFile string, opts MergeOptions) error { 168 + func (g *GitRepo) applyPatch(patchData, patchFile string, opts MergeOptions) error { 166 169 var stderr bytes.Buffer 167 170 var cmd *exec.Cmd 168 171 169 172 // configure default git user before merge 170 - exec.Command("git", "-C", tmpDir, "config", "user.name", opts.CommitterName).Run() 171 - exec.Command("git", "-C", tmpDir, "config", "user.email", opts.CommitterEmail).Run() 172 - exec.Command("git", "-C", tmpDir, "config", "advice.mergeConflict", "false").Run() 173 + exec.Command("git", "-C", g.path, "config", "user.name", opts.CommitterName).Run() 174 + exec.Command("git", "-C", g.path, "config", "user.email", opts.CommitterEmail).Run() 175 + exec.Command("git", "-C", g.path, "config", "advice.mergeConflict", "false").Run() 173 176 174 177 // if patch is a format-patch, apply using 'git am' 175 178 if opts.FormatPatch { 176 - cmd = exec.Command("git", "-C", tmpDir, "am", patchFile) 177 - } else { 178 - // else, apply using 'git apply' and commit it manually 179 - applyCmd := exec.Command("git", "-C", tmpDir, "apply", patchFile) 180 - applyCmd.Stderr = &stderr 181 - if err := applyCmd.Run(); err != nil { 182 - return fmt.Errorf("patch application failed: %s", stderr.String()) 183 - } 179 + return g.applyMailbox(patchData) 180 + } 184 181 185 - stageCmd := exec.Command("git", "-C", tmpDir, "add", ".") 186 - if err := stageCmd.Run(); err != nil { 187 - return fmt.Errorf("failed to stage changes: %w", err) 188 - } 182 + // else, apply using 'git apply' and commit it manually 183 + applyCmd := exec.Command("git", "-C", g.path, "apply", patchFile) 184 + applyCmd.Stderr = &stderr 185 + if err := applyCmd.Run(); err != nil { 186 + return fmt.Errorf("patch application failed: %s", stderr.String()) 187 + } 189 188 190 - commitArgs := []string{"-C", tmpDir, "commit"} 189 + stageCmd := exec.Command("git", "-C", g.path, "add", ".") 190 + if err := stageCmd.Run(); err != nil { 191 + return fmt.Errorf("failed to stage changes: %w", err) 192 + } 191 193 192 - // Set author if provided 193 - authorName := opts.AuthorName 194 - authorEmail := opts.AuthorEmail 194 + commitArgs := []string{"-C", g.path, "commit"} 195 195 196 - if authorName != "" && authorEmail != "" { 197 - commitArgs = append(commitArgs, "--author", fmt.Sprintf("%s <%s>", authorName, authorEmail)) 198 - } 199 - // else, will default to knot's global user.name & user.email configured via `KNOT_GIT_USER_*` env variables 196 + // Set author if provided 197 + authorName := opts.AuthorName 198 + authorEmail := opts.AuthorEmail 200 199 201 - commitArgs = append(commitArgs, "-m", opts.CommitMessage) 200 + if authorName != "" && authorEmail != "" { 201 + commitArgs = append(commitArgs, "--author", fmt.Sprintf("%s <%s>", authorName, authorEmail)) 202 + } 203 + // else, will default to knot's global user.name & user.email configured via `KNOT_GIT_USER_*` env variables 202 204 203 - if opts.CommitBody != "" { 204 - commitArgs = append(commitArgs, "-m", opts.CommitBody) 205 - } 205 + commitArgs = append(commitArgs, "-m", opts.CommitMessage) 206 206 207 - cmd = exec.Command("git", commitArgs...) 207 + if opts.CommitBody != "" { 208 + commitArgs = append(commitArgs, "-m", opts.CommitBody) 208 209 } 210 + 211 + cmd = exec.Command("git", commitArgs...) 209 212 210 213 cmd.Stderr = &stderr 211 214 ··· 216 219 return nil 217 220 } 218 221 219 - func (g *GitRepo) MergeCheck(patchData []byte, targetBranch string) error { 222 + func (g *GitRepo) applyMailbox(patchData string) error { 223 + fps, err := patchutil.ExtractPatches(patchData) 224 + if err != nil { 225 + return fmt.Errorf("failed to extract patches: %w", err) 226 + } 227 + 228 + // apply each patch one by one 229 + // update the newly created commit object to add the change-id header 230 + total := len(fps) 231 + for i, p := range fps { 232 + newCommit, err := g.applySingleMailbox(p) 233 + if err != nil { 234 + return err 235 + } 236 + 237 + log.Printf("applying mailbox patch %d/%d: committed %s\n", i+1, total, newCommit.String()) 238 + } 239 + 240 + return nil 241 + } 242 + 243 + func (g *GitRepo) applySingleMailbox(singlePatch types.FormatPatch) (plumbing.Hash, error) { 244 + tmpPatch, err := g.createTempFileWithPatch(singlePatch.Raw) 245 + if err != nil { 246 + return plumbing.ZeroHash, fmt.Errorf("failed to create temporary patch file for singluar mailbox patch: %w", err) 247 + } 248 + 249 + var stderr bytes.Buffer 250 + cmd := exec.Command("git", "-C", g.path, "am", tmpPatch) 251 + cmd.Stderr = &stderr 252 + 253 + head, err := g.r.Head() 254 + if err != nil { 255 + return plumbing.ZeroHash, err 256 + } 257 + log.Println("head before apply", head.Hash().String()) 258 + 259 + if err := cmd.Run(); err != nil { 260 + return plumbing.ZeroHash, fmt.Errorf("patch application failed: %s", stderr.String()) 261 + } 262 + 263 + if err := g.Refresh(); err != nil { 264 + return plumbing.ZeroHash, fmt.Errorf("failed to refresh repository state: %w", err) 265 + } 266 + 267 + head, err = g.r.Head() 268 + if err != nil { 269 + return plumbing.ZeroHash, err 270 + } 271 + log.Println("head after apply", head.Hash().String()) 272 + 273 + newHash := head.Hash() 274 + if changeId, err := singlePatch.ChangeId(); err != nil { 275 + // no change ID 276 + } else if updatedHash, err := g.setChangeId(head.Hash(), changeId); err != nil { 277 + return plumbing.ZeroHash, err 278 + } else { 279 + newHash = updatedHash 280 + } 281 + 282 + return newHash, nil 283 + } 284 + 285 + func (g *GitRepo) setChangeId(hash plumbing.Hash, changeId string) (plumbing.Hash, error) { 286 + log.Printf("updating change ID of %s to %s\n", hash.String(), changeId) 287 + obj, err := g.r.CommitObject(hash) 288 + if err != nil { 289 + return plumbing.ZeroHash, fmt.Errorf("failed to get commit object for hash %s: %w", hash.String(), err) 290 + } 291 + 292 + // write the change-id header 293 + obj.ExtraHeaders["change-id"] = []byte(changeId) 294 + 295 + // create a new object 296 + dest := g.r.Storer.NewEncodedObject() 297 + if err := obj.Encode(dest); err != nil { 298 + return plumbing.ZeroHash, fmt.Errorf("failed to create new object: %w", err) 299 + } 300 + 301 + // store the new object 302 + newHash, err := g.r.Storer.SetEncodedObject(dest) 303 + if err != nil { 304 + return plumbing.ZeroHash, fmt.Errorf("failed to store new object: %w", err) 305 + } 306 + 307 + log.Printf("hash changed from %s to %s\n", obj.Hash.String(), newHash.String()) 308 + 309 + // find the branch that HEAD is pointing to 310 + ref, err := g.r.Head() 311 + if err != nil { 312 + return plumbing.ZeroHash, fmt.Errorf("failed to fetch HEAD: %w", err) 313 + } 314 + 315 + // and update that branch to point to new commit 316 + if ref.Name().IsBranch() { 317 + err = g.r.Storer.SetReference(plumbing.NewHashReference(ref.Name(), newHash)) 318 + if err != nil { 319 + return plumbing.ZeroHash, fmt.Errorf("failed to update HEAD: %w", err) 320 + } 321 + } 322 + 323 + // new hash of commit 324 + return newHash, nil 325 + } 326 + 327 + func (g *GitRepo) MergeCheck(patchData string, targetBranch string) error { 220 328 if val, ok := mergeCheckCache.Get(g, patchData, targetBranch); ok { 221 329 return val 222 330 } ··· 244 352 return result 245 353 } 246 354 247 - func (g *GitRepo) MergeWithOptions(patchData []byte, targetBranch string, opts MergeOptions) error { 355 + func (g *GitRepo) MergeWithOptions(patchData string, targetBranch string, opts MergeOptions) error { 248 356 patchFile, err := g.createTempFileWithPatch(patchData) 249 357 if err != nil { 250 358 return &ErrMerge{ ··· 263 371 } 264 372 defer os.RemoveAll(tmpDir) 265 373 266 - if err := g.applyPatch(tmpDir, patchFile, opts); err != nil { 374 + tmpRepo, err := PlainOpen(tmpDir) 375 + if err != nil { 376 + return err 377 + } 378 + 379 + if err := tmpRepo.applyPatch(patchData, patchFile, opts); err != nil { 267 380 return err 268 381 } 269 382
+13 -1
knotserver/git/service/service.go
··· 95 95 return c.RunService(cmd) 96 96 } 97 97 98 + func (c *ServiceCommand) UploadArchive() error { 99 + cmd := exec.Command("git", []string{ 100 + "upload-archive", 101 + ".", 102 + }...) 103 + 104 + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} 105 + cmd.Env = append(cmd.Env, fmt.Sprintf("GIT_PROTOCOL=%s", c.GitProtocol)) 106 + cmd.Dir = c.Dir 107 + 108 + return c.RunService(cmd) 109 + } 110 + 98 111 func (c *ServiceCommand) UploadPack() error { 99 112 cmd := exec.Command("git", []string{ 100 - "-c", "uploadpack.allowFilter=true", 101 113 "upload-pack", 102 114 "--stateless-rpc", 103 115 ".",
+4 -13
knotserver/git/tree.go
··· 7 7 "path" 8 8 "time" 9 9 10 + "github.com/go-git/go-git/v5/plumbing/filemode" 10 11 "github.com/go-git/go-git/v5/plumbing/object" 11 12 "tangled.org/core/types" 12 13 ) ··· 53 54 } 54 55 55 56 for _, e := range subtree.Entries { 56 - mode, _ := e.Mode.ToOSFileMode() 57 57 sz, _ := subtree.Size(e.Name) 58 - 59 58 fpath := path.Join(parent, e.Name) 60 59 61 60 var lastCommit *types.LastCommitInfo ··· 69 68 70 69 nts = append(nts, types.NiceTree{ 71 70 Name: e.Name, 72 - Mode: mode.String(), 73 - IsFile: e.Mode.IsFile(), 71 + Mode: e.Mode.String(), 74 72 Size: sz, 75 73 LastCommit: lastCommit, 76 74 }) ··· 126 124 default: 127 125 } 128 126 129 - mode, err := e.Mode.ToOSFileMode() 130 - if err != nil { 131 - // TODO: log this 132 - continue 133 - } 134 - 135 127 if e.Mode.IsFile() { 136 - err = cb(e, currentTree, root) 137 - if errors.Is(err, TerminateWalk) { 128 + if err := cb(e, currentTree, root); errors.Is(err, TerminateWalk) { 138 129 return err 139 130 } 140 131 } 141 132 142 133 // e is a directory 143 - if mode.IsDir() { 134 + if e.Mode == filemode.Dir { 144 135 subtree, err := currentTree.Tree(e.Name) 145 136 if err != nil { 146 137 return fmt.Errorf("sub tree %s: %w", e.Name, err)
+65 -18
knotserver/git.go
··· 13 13 "tangled.org/core/knotserver/git/service" 14 14 ) 15 15 16 - func (d *Knot) InfoRefs(w http.ResponseWriter, r *http.Request) { 16 + func (h *Knot) InfoRefs(w http.ResponseWriter, r *http.Request) { 17 17 did := chi.URLParam(r, "did") 18 18 name := chi.URLParam(r, "name") 19 19 repoName, err := securejoin.SecureJoin(did, name) 20 20 if err != nil { 21 21 gitError(w, "repository not found", http.StatusNotFound) 22 - d.l.Error("git: failed to secure join repo path", "handler", "InfoRefs", "error", err) 22 + h.l.Error("git: failed to secure join repo path", "handler", "InfoRefs", "error", err) 23 23 return 24 24 } 25 25 26 - repoPath, err := securejoin.SecureJoin(d.c.Repo.ScanPath, repoName) 26 + repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, repoName) 27 27 if err != nil { 28 28 gitError(w, "repository not found", http.StatusNotFound) 29 - d.l.Error("git: failed to secure join repo path", "handler", "InfoRefs", "error", err) 29 + h.l.Error("git: failed to secure join repo path", "handler", "InfoRefs", "error", err) 30 30 return 31 31 } 32 32 ··· 46 46 47 47 if err := cmd.InfoRefs(); err != nil { 48 48 gitError(w, err.Error(), http.StatusInternalServerError) 49 - d.l.Error("git: process failed", "handler", "InfoRefs", "service", serviceName, "error", err) 49 + h.l.Error("git: process failed", "handler", "InfoRefs", "service", serviceName, "error", err) 50 50 return 51 51 } 52 52 case "git-receive-pack": 53 - d.RejectPush(w, r, name) 53 + h.RejectPush(w, r, name) 54 54 default: 55 55 gitError(w, fmt.Sprintf("service unsupported: '%s'", serviceName), http.StatusForbidden) 56 56 } 57 57 } 58 58 59 - func (d *Knot) UploadPack(w http.ResponseWriter, r *http.Request) { 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) { 60 107 did := chi.URLParam(r, "did") 61 108 name := chi.URLParam(r, "name") 62 - repo, err := securejoin.SecureJoin(d.c.Repo.ScanPath, filepath.Join(did, name)) 109 + repo, err := securejoin.SecureJoin(h.c.Repo.ScanPath, filepath.Join(did, name)) 63 110 if err != nil { 64 111 gitError(w, err.Error(), http.StatusInternalServerError) 65 - d.l.Error("git: failed to secure join repo path", "handler", "UploadPack", "error", err) 112 + h.l.Error("git: failed to secure join repo path", "handler", "UploadPack", "error", err) 66 113 return 67 114 } 68 115 ··· 77 124 gzipReader, err := gzip.NewReader(r.Body) 78 125 if err != nil { 79 126 gitError(w, err.Error(), http.StatusInternalServerError) 80 - d.l.Error("git: failed to create gzip reader", "handler", "UploadPack", "error", err) 127 + h.l.Error("git: failed to create gzip reader", "handler", "UploadPack", "error", err) 81 128 return 82 129 } 83 130 defer gzipReader.Close() ··· 88 135 w.Header().Set("Connection", "Keep-Alive") 89 136 w.Header().Set("Cache-Control", "no-cache, max-age=0, must-revalidate") 90 137 91 - d.l.Info("git: executing git-upload-pack", "handler", "UploadPack", "repo", repo) 138 + h.l.Info("git: executing git-upload-pack", "handler", "UploadPack", "repo", repo) 92 139 93 140 cmd := service.ServiceCommand{ 94 141 GitProtocol: r.Header.Get("Git-Protocol"), ··· 100 147 w.WriteHeader(http.StatusOK) 101 148 102 149 if err := cmd.UploadPack(); err != nil { 103 - d.l.Error("git: failed to execute git-upload-pack", "handler", "UploadPack", "error", err) 150 + h.l.Error("git: failed to execute git-upload-pack", "handler", "UploadPack", "error", err) 104 151 return 105 152 } 106 153 } 107 154 108 - func (d *Knot) ReceivePack(w http.ResponseWriter, r *http.Request) { 155 + func (h *Knot) ReceivePack(w http.ResponseWriter, r *http.Request) { 109 156 did := chi.URLParam(r, "did") 110 157 name := chi.URLParam(r, "name") 111 - _, err := securejoin.SecureJoin(d.c.Repo.ScanPath, filepath.Join(did, name)) 158 + _, err := securejoin.SecureJoin(h.c.Repo.ScanPath, filepath.Join(did, name)) 112 159 if err != nil { 113 160 gitError(w, err.Error(), http.StatusForbidden) 114 - d.l.Error("git: failed to secure join repo path", "handler", "ReceivePack", "error", err) 161 + h.l.Error("git: failed to secure join repo path", "handler", "ReceivePack", "error", err) 115 162 return 116 163 } 117 164 118 - d.RejectPush(w, r, name) 165 + h.RejectPush(w, r, name) 119 166 } 120 167 121 - func (d *Knot) RejectPush(w http.ResponseWriter, r *http.Request, unqualifiedRepoName string) { 168 + func (h *Knot) RejectPush(w http.ResponseWriter, r *http.Request, unqualifiedRepoName string) { 122 169 // A text/plain response will cause git to print each line of the body 123 170 // prefixed with "remote: ". 124 171 w.Header().Set("content-type", "text/plain; charset=UTF-8") ··· 131 178 ownerHandle := r.Header.Get("x-tangled-repo-owner-handle") 132 179 ownerHandle = strings.TrimPrefix(ownerHandle, "@") 133 180 if ownerHandle != "" && !strings.ContainsAny(ownerHandle, ":") { 134 - hostname := d.c.Server.Hostname 181 + hostname := h.c.Server.Hostname 135 182 if strings.Contains(hostname, ":") { 136 183 hostname = strings.Split(hostname, ":")[0] 137 184 }
+4 -8
knotserver/ingester.go
··· 16 16 "github.com/bluesky-social/jetstream/pkg/models" 17 17 securejoin "github.com/cyphar/filepath-securejoin" 18 18 "tangled.org/core/api/tangled" 19 - "tangled.org/core/idresolver" 20 19 "tangled.org/core/knotserver/db" 21 20 "tangled.org/core/knotserver/git" 22 21 "tangled.org/core/log" ··· 120 119 } 121 120 122 121 // resolve this aturi to extract the repo record 123 - resolver := idresolver.DefaultResolver() 124 - ident, err := resolver.ResolveIdent(ctx, repoAt.Authority().String()) 122 + ident, err := h.resolver.ResolveIdent(ctx, repoAt.Authority().String()) 125 123 if err != nil || ident.Handle.IsInvalidHandle() { 126 124 return fmt.Errorf("failed to resolve handle: %w", err) 127 125 } ··· 163 161 164 162 var pipeline workflow.RawPipeline 165 163 for _, e := range workflowDir { 166 - if !e.IsFile { 164 + if !e.IsFile() { 167 165 continue 168 166 } 169 167 ··· 233 231 return err 234 232 } 235 233 236 - resolver := idresolver.DefaultResolver() 237 - 238 - subjectId, err := resolver.ResolveIdent(ctx, record.Subject) 234 + subjectId, err := h.resolver.ResolveIdent(ctx, record.Subject) 239 235 if err != nil || subjectId.Handle.IsInvalidHandle() { 240 236 return err 241 237 } 242 238 243 239 // TODO: fix this for good, we need to fetch the record here unfortunately 244 240 // resolve this aturi to extract the repo record 245 - owner, err := resolver.ResolveIdent(ctx, repoAt.Authority().String()) 241 + owner, err := h.resolver.ResolveIdent(ctx, repoAt.Authority().String()) 246 242 if err != nil || owner.Handle.IsInvalidHandle() { 247 243 return fmt.Errorf("failed to resolve handle: %w", err) 248 244 }
+154 -8
knotserver/internal.go
··· 13 13 securejoin "github.com/cyphar/filepath-securejoin" 14 14 "github.com/go-chi/chi/v5" 15 15 "github.com/go-chi/chi/v5/middleware" 16 + "github.com/go-git/go-git/v5/plumbing" 16 17 "tangled.org/core/api/tangled" 17 18 "tangled.org/core/hook" 19 + "tangled.org/core/idresolver" 18 20 "tangled.org/core/knotserver/config" 19 21 "tangled.org/core/knotserver/db" 20 22 "tangled.org/core/knotserver/git" 23 + "tangled.org/core/log" 21 24 "tangled.org/core/notifier" 22 25 "tangled.org/core/rbac" 23 26 "tangled.org/core/workflow" 24 27 ) 25 28 26 29 type InternalHandle struct { 27 - db *db.DB 28 - c *config.Config 29 - e *rbac.Enforcer 30 - l *slog.Logger 31 - n *notifier.Notifier 30 + db *db.DB 31 + c *config.Config 32 + e *rbac.Enforcer 33 + l *slog.Logger 34 + n *notifier.Notifier 35 + res *idresolver.Resolver 32 36 } 33 37 34 38 func (h *InternalHandle) PushAllowed(w http.ResponseWriter, r *http.Request) { ··· 64 68 writeJSON(w, data) 65 69 } 66 70 71 + // response in text/plain format 72 + // the body will be qualified repository path on success/push-denied 73 + // or an error message when process failed 74 + func (h *InternalHandle) Guard(w http.ResponseWriter, r *http.Request) { 75 + l := h.l.With("handler", "PostReceiveHook") 76 + 77 + var ( 78 + incomingUser = r.URL.Query().Get("user") 79 + repo = r.URL.Query().Get("repo") 80 + gitCommand = r.URL.Query().Get("gitCmd") 81 + ) 82 + 83 + if incomingUser == "" || repo == "" || gitCommand == "" { 84 + w.WriteHeader(http.StatusBadRequest) 85 + l.Error("invalid request", "incomingUser", incomingUser, "repo", repo, "gitCommand", gitCommand) 86 + fmt.Fprintln(w, "invalid internal request") 87 + return 88 + } 89 + 90 + // did:foo/repo-name or 91 + // handle/repo-name or 92 + // any of the above with a leading slash (/) 93 + components := strings.Split(strings.TrimPrefix(strings.Trim(repo, "'"), "/"), "/") 94 + l.Info("command components", "components", components) 95 + 96 + if len(components) != 2 { 97 + w.WriteHeader(http.StatusBadRequest) 98 + l.Error("invalid repo format", "components", components) 99 + fmt.Fprintln(w, "invalid repo format, needs <user>/<repo> or /<user>/<repo>") 100 + return 101 + } 102 + repoOwner := components[0] 103 + repoName := components[1] 104 + 105 + resolver := idresolver.DefaultResolver(h.c.Server.PlcUrl) 106 + 107 + repoOwnerIdent, err := resolver.ResolveIdent(r.Context(), repoOwner) 108 + if err != nil || repoOwnerIdent.Handle.IsInvalidHandle() { 109 + l.Error("Error resolving handle", "handle", repoOwner, "err", err) 110 + w.WriteHeader(http.StatusInternalServerError) 111 + fmt.Fprintf(w, "error resolving handle: invalid handle\n") 112 + return 113 + } 114 + repoOwnerDid := repoOwnerIdent.DID.String() 115 + 116 + qualifiedRepo, _ := securejoin.SecureJoin(repoOwnerDid, repoName) 117 + 118 + if gitCommand == "git-receive-pack" { 119 + ok, err := h.e.IsPushAllowed(incomingUser, rbac.ThisServer, qualifiedRepo) 120 + if err != nil || !ok { 121 + w.WriteHeader(http.StatusForbidden) 122 + fmt.Fprint(w, repo) 123 + return 124 + } 125 + } 126 + 127 + w.WriteHeader(http.StatusOK) 128 + fmt.Fprint(w, qualifiedRepo) 129 + } 130 + 67 131 type PushOptions struct { 68 132 skipCi bool 69 133 verboseCi bool ··· 118 182 // non-fatal 119 183 } 120 184 185 + err = h.emitCompareLink(&resp.Messages, line, repoDid, repoName) 186 + if err != nil { 187 + l.Error("failed to reply with compare link", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 188 + // non-fatal 189 + } 190 + 121 191 err = h.triggerPipeline(&resp.Messages, line, gitUserDid, repoDid, repoName, pushOptions) 122 192 if err != nil { 123 193 l.Error("failed to trigger pipeline", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) ··· 173 243 return errors.Join(errs, h.db.InsertEvent(event, h.n)) 174 244 } 175 245 176 - func (h *InternalHandle) triggerPipeline(clientMsgs *[]string, line git.PostReceiveLine, gitUserDid, repoDid, repoName string, pushOptions PushOptions) error { 246 + func (h *InternalHandle) triggerPipeline( 247 + clientMsgs *[]string, 248 + line git.PostReceiveLine, 249 + gitUserDid string, 250 + repoDid string, 251 + repoName string, 252 + pushOptions PushOptions, 253 + ) error { 177 254 if pushOptions.skipCi { 178 255 return nil 179 256 } ··· 200 277 201 278 var pipeline workflow.RawPipeline 202 279 for _, e := range workflowDir { 203 - if !e.IsFile { 280 + if !e.IsFile() { 204 281 continue 205 282 } 206 283 ··· 268 345 return h.db.InsertEvent(event, h.n) 269 346 } 270 347 271 - func Internal(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, l *slog.Logger, n *notifier.Notifier) http.Handler { 348 + func (h *InternalHandle) emitCompareLink( 349 + clientMsgs *[]string, 350 + line git.PostReceiveLine, 351 + repoDid string, 352 + repoName string, 353 + ) error { 354 + // this is a second push to a branch, don't reply with the link again 355 + if !line.OldSha.IsZero() { 356 + return nil 357 + } 358 + 359 + // the ref was not updated to a new hash, don't reply with the link 360 + // 361 + // NOTE: do we need this? 362 + if line.NewSha.String() == line.OldSha.String() { 363 + return nil 364 + } 365 + 366 + pushedRef := plumbing.ReferenceName(line.Ref) 367 + 368 + userIdent, err := h.res.ResolveIdent(context.Background(), repoDid) 369 + user := repoDid 370 + if err == nil { 371 + user = userIdent.Handle.String() 372 + } 373 + 374 + didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName) 375 + if err != nil { 376 + return err 377 + } 378 + 379 + repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo) 380 + if err != nil { 381 + return err 382 + } 383 + 384 + gr, err := git.PlainOpen(repoPath) 385 + if err != nil { 386 + return err 387 + } 388 + 389 + defaultBranch, err := gr.FindMainBranch() 390 + if err != nil { 391 + return err 392 + } 393 + 394 + // pushing to default branch 395 + if pushedRef == plumbing.NewBranchReferenceName(defaultBranch) { 396 + return nil 397 + } 398 + 399 + // pushing a tag, don't prompt the user the open a PR 400 + if pushedRef.IsTag() { 401 + return nil 402 + } 403 + 404 + ZWS := "\u200B" 405 + *clientMsgs = append(*clientMsgs, ZWS) 406 + *clientMsgs = append(*clientMsgs, fmt.Sprintf("Create a PR pointing to %s", defaultBranch)) 407 + *clientMsgs = append(*clientMsgs, fmt.Sprintf("\t%s/%s/%s/compare/%s...%s", h.c.AppViewEndpoint, user, repoName, defaultBranch, strings.TrimPrefix(line.Ref, "refs/heads/"))) 408 + *clientMsgs = append(*clientMsgs, ZWS) 409 + return nil 410 + } 411 + 412 + func Internal(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, n *notifier.Notifier) http.Handler { 272 413 r := chi.NewRouter() 414 + l := log.FromContext(ctx) 415 + l = log.SubLogger(l, "internal") 416 + res := idresolver.DefaultResolver(c.Server.PlcUrl) 273 417 274 418 h := InternalHandle{ 275 419 db, ··· 277 421 e, 278 422 l, 279 423 n, 424 + res, 280 425 } 281 426 282 427 r.Get("/push-allowed", h.PushAllowed) 283 428 r.Get("/keys", h.InternalKeys) 429 + r.Get("/guard", h.Guard) 284 430 r.Post("/hooks/post-receive", h.PostReceiveHook) 285 431 r.Mount("/debug", middleware.Profiler()) 286 432
+53
knotserver/middleware.go
··· 1 + package knotserver 2 + 3 + import ( 4 + "log/slog" 5 + "net/http" 6 + "time" 7 + ) 8 + 9 + func (h *Knot) RequestLogger(next http.Handler) http.Handler { 10 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 11 + start := time.Now() 12 + 13 + next.ServeHTTP(w, r) 14 + 15 + // Build query params as slog.Attrs for the group 16 + queryParams := r.URL.Query() 17 + queryAttrs := make([]any, 0, len(queryParams)) 18 + for key, values := range queryParams { 19 + if len(values) == 1 { 20 + queryAttrs = append(queryAttrs, slog.String(key, values[0])) 21 + } else { 22 + queryAttrs = append(queryAttrs, slog.Any(key, values)) 23 + } 24 + } 25 + 26 + h.l.LogAttrs(r.Context(), slog.LevelInfo, "", 27 + slog.Group("request", 28 + slog.String("method", r.Method), 29 + slog.String("path", r.URL.Path), 30 + slog.Group("query", queryAttrs...), 31 + slog.Duration("duration", time.Since(start)), 32 + ), 33 + ) 34 + }) 35 + } 36 + 37 + func (h *Knot) CORS(next http.Handler) http.Handler { 38 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 39 + // Set CORS headers 40 + w.Header().Set("Access-Control-Allow-Origin", "*") 41 + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") 42 + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") 43 + w.Header().Set("Access-Control-Max-Age", "86400") 44 + 45 + // Handle preflight requests 46 + if r.Method == "OPTIONS" { 47 + w.WriteHeader(http.StatusOK) 48 + return 49 + } 50 + 51 + next.ServeHTTP(w, r) 52 + }) 53 + }
+19 -10
knotserver/router.go
··· 12 12 "tangled.org/core/knotserver/config" 13 13 "tangled.org/core/knotserver/db" 14 14 "tangled.org/core/knotserver/xrpc" 15 - tlog "tangled.org/core/log" 15 + "tangled.org/core/log" 16 16 "tangled.org/core/notifier" 17 17 "tangled.org/core/rbac" 18 18 "tangled.org/core/xrpc/serviceauth" ··· 28 28 resolver *idresolver.Resolver 29 29 } 30 30 31 - func Setup(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, jc *jetstream.JetstreamClient, l *slog.Logger, n *notifier.Notifier) (http.Handler, error) { 32 - r := chi.NewRouter() 33 - 31 + func Setup(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, jc *jetstream.JetstreamClient, n *notifier.Notifier) (http.Handler, error) { 34 32 h := Knot{ 35 33 c: c, 36 34 db: db, 37 35 e: e, 38 - l: l, 36 + l: log.FromContext(ctx), 39 37 jc: jc, 40 38 n: n, 41 - resolver: idresolver.DefaultResolver(), 39 + resolver: idresolver.DefaultResolver(c.Server.PlcUrl), 42 40 } 43 41 44 42 err := e.AddKnot(rbac.ThisServer) ··· 67 65 return nil, fmt.Errorf("failed to start jetstream: %w", err) 68 66 } 69 67 68 + return h.Router(), nil 69 + } 70 + 71 + func (h *Knot) Router() http.Handler { 72 + r := chi.NewRouter() 73 + 74 + r.Use(h.CORS) 75 + r.Use(h.RequestLogger) 76 + 70 77 r.Get("/", func(w http.ResponseWriter, r *http.Request) { 71 78 w.Write([]byte("This is a knot server. More info at https://tangled.sh")) 72 79 }) ··· 75 82 r.Route("/{name}", func(r chi.Router) { 76 83 // routes for git operations 77 84 r.Get("/info/refs", h.InfoRefs) 85 + r.Post("/git-upload-archive", h.UploadArchive) 78 86 r.Post("/git-upload-pack", h.UploadPack) 79 87 r.Post("/git-receive-pack", h.ReceivePack) 80 88 }) ··· 86 94 // Socket that streams git oplogs 87 95 r.Get("/events", h.Events) 88 96 89 - return r, nil 97 + return r 90 98 } 91 99 92 100 func (h *Knot) XrpcRouter() http.Handler { 93 - logger := tlog.New("knots") 94 - 95 101 serviceAuth := serviceauth.NewServiceAuth(h.l, h.resolver, h.c.Server.Did().String()) 102 + 103 + l := log.SubLogger(h.l, "xrpc") 96 104 97 105 xrpc := &xrpc.Xrpc{ 98 106 Config: h.c, 99 107 Db: h.db, 100 108 Ingester: h.jc, 101 109 Enforcer: h.e, 102 - Logger: logger, 110 + Logger: l, 103 111 Notifier: h.n, 104 112 Resolver: h.resolver, 105 113 ServiceAuth: serviceAuth, 106 114 } 115 + 107 116 return xrpc.Router() 108 117 } 109 118
+6 -5
knotserver/server.go
··· 43 43 44 44 func Run(ctx context.Context, cmd *cli.Command) error { 45 45 logger := log.FromContext(ctx) 46 - iLogger := log.New("knotserver/internal") 46 + logger = log.SubLogger(logger, cmd.Name) 47 + ctx = log.IntoContext(ctx, logger) 47 48 48 49 c, err := config.Load(ctx) 49 50 if err != nil { ··· 63 64 logger.Info("running in dev mode, signature verification is disabled") 64 65 } 65 66 66 - db, err := db.Setup(c.Server.DBPath) 67 + db, err := db.Setup(ctx, c.Server.DBPath) 67 68 if err != nil { 68 69 return fmt.Errorf("failed to load db: %w", err) 69 70 } ··· 80 81 tangled.KnotMemberNSID, 81 82 tangled.RepoPullNSID, 82 83 tangled.RepoCollaboratorNSID, 83 - }, nil, logger, db, true, c.Server.LogDids) 84 + }, nil, log.SubLogger(logger, "jetstream"), db, true, c.Server.LogDids) 84 85 if err != nil { 85 86 logger.Error("failed to setup jetstream", "error", err) 86 87 } 87 88 88 89 notifier := notifier.New() 89 90 90 - mux, err := Setup(ctx, c, db, e, jc, logger, &notifier) 91 + mux, err := Setup(ctx, c, db, e, jc, &notifier) 91 92 if err != nil { 92 93 return fmt.Errorf("failed to setup server: %w", err) 93 94 } 94 95 95 - imux := Internal(ctx, c, db, e, iLogger, &notifier) 96 + imux := Internal(ctx, c, db, e, &notifier) 96 97 97 98 logger.Info("starting internal server", "address", c.Server.InternalListenAddr) 98 99 go http.ListenAndServe(c.Server.InternalListenAddr, imux)
+1 -1
knotserver/xrpc/create_repo.go
··· 84 84 repoPath, _ := securejoin.SecureJoin(h.Config.Repo.ScanPath, relativeRepoPath) 85 85 86 86 if data.Source != nil && *data.Source != "" { 87 - err = git.Fork(repoPath, *data.Source) 87 + err = git.Fork(repoPath, *data.Source, h.Config) 88 88 if err != nil { 89 89 l.Error("forking repo", "error", err.Error()) 90 90 writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError)
+87
knotserver/xrpc/delete_branch.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + 8 + comatproto "github.com/bluesky-social/indigo/api/atproto" 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + "github.com/bluesky-social/indigo/xrpc" 11 + securejoin "github.com/cyphar/filepath-securejoin" 12 + "tangled.org/core/api/tangled" 13 + "tangled.org/core/knotserver/git" 14 + "tangled.org/core/rbac" 15 + 16 + xrpcerr "tangled.org/core/xrpc/errors" 17 + ) 18 + 19 + func (x *Xrpc) DeleteBranch(w http.ResponseWriter, r *http.Request) { 20 + l := x.Logger 21 + fail := func(e xrpcerr.XrpcError) { 22 + l.Error("failed", "kind", e.Tag, "error", e.Message) 23 + writeError(w, e, http.StatusBadRequest) 24 + } 25 + 26 + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 27 + if !ok { 28 + fail(xrpcerr.MissingActorDidError) 29 + return 30 + } 31 + 32 + var data tangled.RepoDeleteBranch_Input 33 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 34 + fail(xrpcerr.GenericError(err)) 35 + return 36 + } 37 + 38 + // unfortunately we have to resolve repo-at here 39 + repoAt, err := syntax.ParseATURI(data.Repo) 40 + if err != nil { 41 + fail(xrpcerr.InvalidRepoError(data.Repo)) 42 + return 43 + } 44 + 45 + // resolve this aturi to extract the repo record 46 + ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 47 + if err != nil || ident.Handle.IsInvalidHandle() { 48 + fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 49 + return 50 + } 51 + 52 + xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 53 + resp, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 54 + if err != nil { 55 + fail(xrpcerr.GenericError(err)) 56 + return 57 + } 58 + 59 + repo := resp.Value.Val.(*tangled.Repo) 60 + didPath, err := securejoin.SecureJoin(ident.DID.String(), repo.Name) 61 + if err != nil { 62 + fail(xrpcerr.GenericError(err)) 63 + return 64 + } 65 + 66 + if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 67 + l.Error("insufficent permissions", "did", actorDid.String(), "repo", didPath) 68 + writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 69 + return 70 + } 71 + 72 + path, _ := securejoin.SecureJoin(x.Config.Repo.ScanPath, didPath) 73 + gr, err := git.PlainOpen(path) 74 + if err != nil { 75 + fail(xrpcerr.GenericError(err)) 76 + return 77 + } 78 + 79 + err = gr.DeleteBranch(data.Branch) 80 + if err != nil { 81 + l.Error("deleting branch", "error", err.Error(), "branch", data.Branch) 82 + writeError(w, xrpcerr.GitError(err), http.StatusInternalServerError) 83 + return 84 + } 85 + 86 + w.WriteHeader(http.StatusOK) 87 + }
+1 -1
knotserver/xrpc/merge.go
··· 85 85 mo.CommitterEmail = x.Config.Git.UserEmail 86 86 mo.FormatPatch = patchutil.IsFormatPatch(data.Patch) 87 87 88 - err = gr.MergeWithOptions([]byte(data.Patch), data.Branch, mo) 88 + err = gr.MergeWithOptions(data.Patch, data.Branch, mo) 89 89 if err != nil { 90 90 var mergeErr *git.ErrMerge 91 91 if errors.As(err, &mergeErr) {
+3 -1
knotserver/xrpc/merge_check.go
··· 51 51 return 52 52 } 53 53 54 - err = gr.MergeCheck([]byte(data.Patch), data.Branch) 54 + err = gr.MergeCheck(data.Patch, data.Branch) 55 55 56 56 response := tangled.RepoMergeCheck_Output{ 57 57 Is_conflicted: false, ··· 80 80 response.Error = &errMsg 81 81 } 82 82 } 83 + 84 + l.Debug("merge check response", "isConflicted", response.Is_conflicted, "err", response.Error, "conflicts", response.Conflicts) 83 85 84 86 w.Header().Set("Content-Type", "application/json") 85 87 w.WriteHeader(http.StatusOK)
+21 -2
knotserver/xrpc/repo_blob.go
··· 42 42 return 43 43 } 44 44 45 + // first check if this path is a submodule 46 + submodule, err := gr.Submodule(treePath) 47 + if err != nil { 48 + // this is okay, continue and try to treat it as a regular file 49 + } else { 50 + response := tangled.RepoBlob_Output{ 51 + Ref: ref, 52 + Path: treePath, 53 + Submodule: &tangled.RepoBlob_Submodule{ 54 + Name: submodule.Name, 55 + Url: submodule.URL, 56 + Branch: &submodule.Branch, 57 + }, 58 + } 59 + writeJson(w, response) 60 + return 61 + } 62 + 45 63 contents, err := gr.RawContent(treePath) 46 64 if err != nil { 47 65 x.Logger.Error("file content", "error", err.Error(), "treePath", treePath) ··· 101 119 var encoding string 102 120 103 121 isBinary := !isTextual(mimeType) 122 + size := int64(len(contents)) 104 123 105 124 if isBinary { 106 125 content = base64.StdEncoding.EncodeToString(contents) ··· 113 132 response := tangled.RepoBlob_Output{ 114 133 Ref: ref, 115 134 Path: treePath, 116 - Content: content, 135 + Content: &content, 117 136 Encoding: &encoding, 118 - Size: &[]int64{int64(len(contents))}[0], 137 + Size: &size, 119 138 IsBinary: &isBinary, 120 139 } 121 140
+20 -4
knotserver/xrpc/repo_compare.go
··· 4 4 "fmt" 5 5 "net/http" 6 6 7 + "github.com/bluekeyes/go-gitdiff/gitdiff" 7 8 "tangled.org/core/knotserver/git" 8 9 "tangled.org/core/types" 9 10 xrpcerr "tangled.org/core/xrpc/errors" ··· 71 72 return 72 73 } 73 74 75 + var combinedPatch []*gitdiff.File 76 + var combinedPatchRaw string 77 + // we need the combined patch 78 + if len(formatPatch) >= 2 { 79 + diffTree, err := gr.DiffTree(commit1, commit2) 80 + if err != nil { 81 + x.Logger.Error("error comparing revisions", "msg", err.Error()) 82 + } else { 83 + combinedPatch = diffTree.Diff 84 + combinedPatchRaw = diffTree.Patch 85 + } 86 + } 87 + 74 88 response := types.RepoFormatPatchResponse{ 75 - Rev1: commit1.Hash.String(), 76 - Rev2: commit2.Hash.String(), 77 - FormatPatch: formatPatch, 78 - Patch: rawPatch, 89 + Rev1: commit1.Hash.String(), 90 + Rev2: commit2.Hash.String(), 91 + FormatPatch: formatPatch, 92 + FormatPatchRaw: rawPatch, 93 + CombinedPatch: combinedPatch, 94 + CombinedPatchRaw: combinedPatchRaw, 79 95 } 80 96 81 97 writeJson(w, response)
+6 -1
knotserver/xrpc/repo_log.go
··· 62 62 return 63 63 } 64 64 65 + tcommits := make([]types.Commit, len(commits)) 66 + for i, c := range commits { 67 + tcommits[i].FromGoGitCommit(c) 68 + } 69 + 65 70 // Create response using existing types.RepoLogResponse 66 71 response := types.RepoLogResponse{ 67 - Commits: commits, 72 + Commits: tcommits, 68 73 Ref: ref, 69 74 Page: (offset / limit) + 1, 70 75 PerPage: limit,
+3 -5
knotserver/xrpc/repo_tree.go
··· 67 67 treeEntries := make([]*tangled.RepoTree_TreeEntry, len(files)) 68 68 for i, file := range files { 69 69 entry := &tangled.RepoTree_TreeEntry{ 70 - Name: file.Name, 71 - Mode: file.Mode, 72 - Size: file.Size, 73 - Is_file: file.IsFile, 74 - Is_subtree: file.IsSubtree, 70 + Name: file.Name, 71 + Mode: file.Mode, 72 + Size: file.Size, 75 73 } 76 74 77 75 if file.LastCommit != nil {
+1
knotserver/xrpc/xrpc.go
··· 38 38 r.Use(x.ServiceAuth.VerifyServiceAuth) 39 39 40 40 r.Post("/"+tangled.RepoSetDefaultBranchNSID, x.SetDefaultBranch) 41 + r.Post("/"+tangled.RepoDeleteBranchNSID, x.DeleteBranch) 41 42 r.Post("/"+tangled.RepoCreateNSID, x.CreateRepo) 42 43 r.Post("/"+tangled.RepoDeleteNSID, x.DeleteRepo) 43 44 r.Post("/"+tangled.RepoForkStatusNSID, x.ForkStatus)
+5
lexicons/actor/profile.json
··· 64 64 "type": "string", 65 65 "format": "at-uri" 66 66 } 67 + }, 68 + "pronouns": { 69 + "type": "string", 70 + "description": "Preferred gender pronouns.", 71 + "maxLength": 40 67 72 } 68 73 } 69 74 }
+14
lexicons/issue/comment.json
··· 29 29 "replyTo": { 30 30 "type": "string", 31 31 "format": "at-uri" 32 + }, 33 + "mentions": { 34 + "type": "array", 35 + "items": { 36 + "type": "string", 37 + "format": "did" 38 + } 39 + }, 40 + "references": { 41 + "type": "array", 42 + "items": { 43 + "type": "string", 44 + "format": "at-uri" 45 + } 32 46 } 33 47 } 34 48 }
+14
lexicons/issue/issue.json
··· 24 24 "createdAt": { 25 25 "type": "string", 26 26 "format": "datetime" 27 + }, 28 + "mentions": { 29 + "type": "array", 30 + "items": { 31 + "type": "string", 32 + "format": "did" 33 + } 34 + }, 35 + "references": { 36 + "type": "array", 37 + "items": { 38 + "type": "string", 39 + "format": "at-uri" 40 + } 27 41 } 28 42 } 29 43 }
+14
lexicons/pulls/comment.json
··· 25 25 "createdAt": { 26 26 "type": "string", 27 27 "format": "datetime" 28 + }, 29 + "mentions": { 30 + "type": "array", 31 + "items": { 32 + "type": "string", 33 + "format": "did" 34 + } 35 + }, 36 + "references": { 37 + "type": "array", 38 + "items": { 39 + "type": "string", 40 + "format": "at-uri" 41 + } 28 42 } 29 43 } 30 44 }
+14
lexicons/pulls/pull.json
··· 36 36 "createdAt": { 37 37 "type": "string", 38 38 "format": "datetime" 39 + }, 40 + "mentions": { 41 + "type": "array", 42 + "items": { 43 + "type": "string", 44 + "format": "did" 45 + } 46 + }, 47 + "references": { 48 + "type": "array", 49 + "items": { 50 + "type": "string", 51 + "format": "at-uri" 52 + } 39 53 } 40 54 } 41 55 }
+49 -5
lexicons/repo/blob.json
··· 6 6 "type": "query", 7 7 "parameters": { 8 8 "type": "params", 9 - "required": ["repo", "ref", "path"], 9 + "required": [ 10 + "repo", 11 + "ref", 12 + "path" 13 + ], 10 14 "properties": { 11 15 "repo": { 12 16 "type": "string", ··· 31 35 "encoding": "application/json", 32 36 "schema": { 33 37 "type": "object", 34 - "required": ["ref", "path", "content"], 38 + "required": [ 39 + "ref", 40 + "path" 41 + ], 35 42 "properties": { 36 43 "ref": { 37 44 "type": "string", ··· 48 55 "encoding": { 49 56 "type": "string", 50 57 "description": "Content encoding", 51 - "enum": ["utf-8", "base64"] 58 + "enum": [ 59 + "utf-8", 60 + "base64" 61 + ] 52 62 }, 53 63 "size": { 54 64 "type": "integer", ··· 61 71 "mimeType": { 62 72 "type": "string", 63 73 "description": "MIME type of the file" 74 + }, 75 + "submodule": { 76 + "type": "ref", 77 + "ref": "#submodule", 78 + "description": "Submodule information if path is a submodule" 64 79 }, 65 80 "lastCommit": { 66 81 "type": "ref", ··· 90 105 }, 91 106 "lastCommit": { 92 107 "type": "object", 93 - "required": ["hash", "message", "when"], 108 + "required": [ 109 + "hash", 110 + "message", 111 + "when" 112 + ], 94 113 "properties": { 95 114 "hash": { 96 115 "type": "string", ··· 117 136 }, 118 137 "signature": { 119 138 "type": "object", 120 - "required": ["name", "email", "when"], 139 + "required": [ 140 + "name", 141 + "email", 142 + "when" 143 + ], 121 144 "properties": { 122 145 "name": { 123 146 "type": "string", ··· 131 154 "type": "string", 132 155 "format": "datetime", 133 156 "description": "Author timestamp" 157 + } 158 + } 159 + }, 160 + "submodule": { 161 + "type": "object", 162 + "required": [ 163 + "name", 164 + "url" 165 + ], 166 + "properties": { 167 + "name": { 168 + "type": "string", 169 + "description": "Submodule name" 170 + }, 171 + "url": { 172 + "type": "string", 173 + "description": "Submodule repository URL" 174 + }, 175 + "branch": { 176 + "type": "string", 177 + "description": "Branch to track in the submodule" 134 178 } 135 179 } 136 180 }
+30
lexicons/repo/deleteBranch.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.deleteBranch", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Delete a branch on this repository", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": [ 13 + "repo", 14 + "branch" 15 + ], 16 + "properties": { 17 + "repo": { 18 + "type": "string", 19 + "format": "at-uri" 20 + }, 21 + "branch": { 22 + "type": "string" 23 + } 24 + } 25 + } 26 + } 27 + } 28 + } 29 + } 30 +
+15
lexicons/repo/repo.json
··· 32 32 "minGraphemes": 1, 33 33 "maxGraphemes": 140 34 34 }, 35 + "website": { 36 + "type": "string", 37 + "format": "uri", 38 + "description": "Any URI related to the repo" 39 + }, 40 + "topics": { 41 + "type": "array", 42 + "description": "Topics related to the repo", 43 + "items": { 44 + "type": "string", 45 + "minLength": 1, 46 + "maxLength": 50 47 + }, 48 + "maxLength": 50 49 + }, 35 50 "source": { 36 51 "type": "string", 37 52 "format": "uri",
+1 -9
lexicons/repo/tree.json
··· 91 91 }, 92 92 "treeEntry": { 93 93 "type": "object", 94 - "required": ["name", "mode", "size", "is_file", "is_subtree"], 94 + "required": ["name", "mode", "size"], 95 95 "properties": { 96 96 "name": { 97 97 "type": "string", ··· 104 104 "size": { 105 105 "type": "integer", 106 106 "description": "File size in bytes" 107 - }, 108 - "is_file": { 109 - "type": "boolean", 110 - "description": "Whether this entry is a file" 111 - }, 112 - "is_subtree": { 113 - "type": "boolean", 114 - "description": "Whether this entry is a directory/subtree" 115 107 }, 116 108 "last_commit": { 117 109 "type": "ref",
+23 -9
log/log.go
··· 4 4 "context" 5 5 "log/slog" 6 6 "os" 7 + 8 + "github.com/charmbracelet/log" 7 9 ) 8 10 9 - // NewHandler sets up a new slog.Handler with the service name 10 - // as an attribute 11 11 func NewHandler(name string) slog.Handler { 12 - handler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ 13 - Level: slog.LevelDebug, 12 + return log.NewWithOptions(os.Stderr, log.Options{ 13 + ReportTimestamp: true, 14 + Prefix: name, 15 + Level: log.DebugLevel, 14 16 }) 15 - 16 - var attrs []slog.Attr 17 - attrs = append(attrs, slog.Attr{Key: "service", Value: slog.StringValue(name)}) 18 - handler.WithAttrs(attrs) 19 - return handler 20 17 } 21 18 22 19 func New(name string) *slog.Logger { ··· 49 46 50 47 return slog.Default() 51 48 } 49 + 50 + // sublogger derives a new logger from an existing one by appending a suffix to its prefix. 51 + func SubLogger(base *slog.Logger, suffix string) *slog.Logger { 52 + // try to get the underlying charmbracelet logger 53 + if cl, ok := base.Handler().(*log.Logger); ok { 54 + prefix := cl.GetPrefix() 55 + if prefix != "" { 56 + prefix = prefix + "/" + suffix 57 + } else { 58 + prefix = suffix 59 + } 60 + return slog.New(NewHandler(prefix)) 61 + } 62 + 63 + // Fallback: no known handler type 64 + return slog.New(NewHandler(suffix)) 65 + }
+145 -43
nix/gomod2nix.toml
··· 13 13 [mod."github.com/ProtonMail/go-crypto"] 14 14 version = "v1.3.0" 15 15 hash = "sha256-TUG+C4MyeWglOmiwiW2/NUVurFHXLgEPRd3X9uQ1NGI=" 16 + [mod."github.com/RoaringBitmap/roaring/v2"] 17 + version = "v2.4.5" 18 + hash = "sha256-igWY0S1PTolQkfctYcmVJioJyV1pk2V81X6o6BA1XQA=" 16 19 [mod."github.com/alecthomas/assert/v2"] 17 20 version = "v2.11.0" 18 21 hash = "sha256-tDJCDKZ0R4qNA7hgMKWrpDyogt1802LCJDBCExxdqaU=" ··· 29 32 [mod."github.com/avast/retry-go/v4"] 30 33 version = "v4.6.1" 31 34 hash = "sha256-PeZc8k4rDV64+k8nZt/oy1YNVbLevltXP3ZD1jf6Z6k=" 35 + [mod."github.com/aymanbagabas/go-osc52/v2"] 36 + version = "v2.0.1" 37 + hash = "sha256-6Bp0jBZ6npvsYcKZGHHIUSVSTAMEyieweAX2YAKDjjg=" 32 38 [mod."github.com/aymerick/douceur"] 33 39 version = "v0.2.0" 34 40 hash = "sha256-NiBX8EfOvLXNiK3pJaZX4N73YgfzdrzRXdiBFe3X3sE=" 35 41 [mod."github.com/beorn7/perks"] 36 42 version = "v1.0.1" 37 43 hash = "sha256-h75GUqfwJKngCJQVE5Ao5wnO3cfKD9lSIteoLp/3xJ4=" 44 + [mod."github.com/bits-and-blooms/bitset"] 45 + version = "v1.22.0" 46 + hash = "sha256-lY1K29h4vlAmJVvwKgbTG8BTACYGjFaginCszN+ST6w=" 47 + [mod."github.com/blevesearch/bleve/v2"] 48 + version = "v2.5.3" 49 + hash = "sha256-DkpX43WMpB8+9KCibdNjyf6N/1a51xJTfGF97xdoCAQ=" 50 + [mod."github.com/blevesearch/bleve_index_api"] 51 + version = "v1.2.8" 52 + hash = "sha256-LyGDBRvK2GThgUFLZoAbDOOKP1M9Z8oy0E2M6bHZdrk=" 53 + [mod."github.com/blevesearch/geo"] 54 + version = "v0.2.4" 55 + hash = "sha256-W1OV/pvqzJC28VJomGnIU/HeBZ689+p54vWdZ1z/bxc=" 56 + [mod."github.com/blevesearch/go-faiss"] 57 + version = "v1.0.25" 58 + hash = "sha256-bcm976UX22aNIuSjBxFaYMKTltO9lbqyeG4Z3KVG3/Y=" 59 + [mod."github.com/blevesearch/go-porterstemmer"] 60 + version = "v1.0.3" 61 + hash = "sha256-hUjo6g1ehUD1awBmta0ji/xoooD2qG7O22HIeSQiRFo=" 62 + [mod."github.com/blevesearch/gtreap"] 63 + version = "v0.1.1" 64 + hash = "sha256-B4p/5RnECRfV4yOiSQDLMHb23uI7lsQDePhNK+zjbF4=" 65 + [mod."github.com/blevesearch/mmap-go"] 66 + version = "v1.0.4" 67 + hash = "sha256-8y0nMAE9goKjYhR/FFEvtbP7cvM46xneE461L1Jn2Pg=" 68 + [mod."github.com/blevesearch/scorch_segment_api/v2"] 69 + version = "v2.3.10" 70 + hash = "sha256-BcBRjVOrsYySdsdgEjS3qHFm/c58KUNJepRPUO0lFmY=" 71 + [mod."github.com/blevesearch/segment"] 72 + version = "v0.9.1" 73 + hash = "sha256-0EAT737kNxl8IJFGl2SD9mOzxolONGgpfaYEGr7JXkQ=" 74 + [mod."github.com/blevesearch/snowballstem"] 75 + version = "v0.9.0" 76 + hash = "sha256-NQsXrhXcYXn4jQcvwjwLc96SGMRcqVlrR6hYKWGk7/s=" 77 + [mod."github.com/blevesearch/upsidedown_store_api"] 78 + version = "v1.0.2" 79 + hash = "sha256-P69Mnh6YR5RI73bD6L7BYDxkVmaqPMNUrjbfSJoKWuo=" 80 + [mod."github.com/blevesearch/vellum"] 81 + version = "v1.1.0" 82 + hash = "sha256-GJ1wslEJEZhPbMiANw0W4Dgb1ZouiILbWEaIUfxZTkw=" 83 + [mod."github.com/blevesearch/zapx/v11"] 84 + version = "v11.4.2" 85 + hash = "sha256-YzRcc2GwV4VL2Bc+tXOOUL6xNi8LWS76DXEcTkFPTaQ=" 86 + [mod."github.com/blevesearch/zapx/v12"] 87 + version = "v12.4.2" 88 + hash = "sha256-yqyzkMWpyXZSF9KLjtiuOmnRUfhaZImk27mU8lsMyJY=" 89 + [mod."github.com/blevesearch/zapx/v13"] 90 + version = "v13.4.2" 91 + hash = "sha256-VSS2fI7YUkeGMBH89TB9yW5qG8MWjM6zKbl8DboHsB4=" 92 + [mod."github.com/blevesearch/zapx/v14"] 93 + version = "v14.4.2" 94 + hash = "sha256-mAWr+vK0uZWMUaJfGfchzQo4dzMdBbD3Z7F84Jn/ktg=" 95 + [mod."github.com/blevesearch/zapx/v15"] 96 + version = "v15.4.2" 97 + hash = "sha256-R8Eh3N4e8CDXiW47J8ZBnfMY1TTnX1SJPwQc4gYChi8=" 98 + [mod."github.com/blevesearch/zapx/v16"] 99 + version = "v16.2.4" 100 + hash = "sha256-Jo5k7DflV/ghszOWJTCOGVyyLMvlvSYyxRrmSIFjyEE=" 38 101 [mod."github.com/bluekeyes/go-gitdiff"] 39 102 version = "v0.8.2" 40 103 hash = "sha256-GWm5i1ukuBukV0GMF1rffpbOSSXZdfg6/0pABMiGzLQ=" 41 104 replaced = "tangled.sh/oppi.li/go-gitdiff" 42 105 [mod."github.com/bluesky-social/indigo"] 43 - version = "v0.0.0-20250724221105-5827c8fb61bb" 44 - hash = "sha256-uDYmzP4/mT7xP62LIL4QIOlkaKWS/IT1uho+udyVOAI=" 106 + version = "v0.0.0-20251003000214-3259b215110e" 107 + hash = "sha256-qi/GrquJznbLnnHVpd7IqoryCESbi6xE4X1SiEM2qlo=" 45 108 [mod."github.com/bluesky-social/jetstream"] 46 109 version = "v0.0.0-20241210005130-ea96859b93d1" 47 110 hash = "sha256-AiapbrkjXboIKc5QNiWH0KyNs0zKnn6UlGwWFlkUfm0=" 48 111 [mod."github.com/bmatcuk/doublestar/v4"] 49 - version = "v4.7.1" 50 - hash = "sha256-idO38nWZtmxvE0CgdziepwFM+Xc70Isr6NiKEZjHOjA=" 112 + version = "v4.9.1" 113 + hash = "sha256-0iyHjyTAsfhgYSsE+NKxSNGBuM3Id615VWeQhssTShE=" 51 114 [mod."github.com/carlmjohnson/versioninfo"] 52 115 version = "v0.22.5" 53 116 hash = "sha256-tf7yKVFTUPmGKBLK43bjyIRQUboCYduh3I5HXE5+LPw=" ··· 63 126 [mod."github.com/cespare/xxhash/v2"] 64 127 version = "v2.3.0" 65 128 hash = "sha256-7hRlwSR+fos1kx4VZmJ/7snR7zHh8ZFKX+qqqqGcQpY=" 129 + [mod."github.com/charmbracelet/colorprofile"] 130 + version = "v0.2.3-0.20250311203215-f60798e515dc" 131 + hash = "sha256-D9E/bMOyLXAUVOHA1/6o3i+vVmLfwIMOWib6sU7A6+Q=" 132 + [mod."github.com/charmbracelet/lipgloss"] 133 + version = "v1.1.0" 134 + hash = "sha256-RHsRT2EZ1nDOElxAK+6/DC9XAaGVjDTgPvRh3pyCfY4=" 135 + [mod."github.com/charmbracelet/log"] 136 + version = "v0.4.2" 137 + hash = "sha256-3w1PCM/c4JvVEh2d0sMfv4C77Xs1bPa1Ea84zdynC7I=" 138 + [mod."github.com/charmbracelet/x/ansi"] 139 + version = "v0.8.0" 140 + hash = "sha256-/YyDkGrULV2BtnNk3ojeSl0nUWQwIfIdW7WJuGbAZas=" 141 + [mod."github.com/charmbracelet/x/cellbuf"] 142 + version = "v0.0.13-0.20250311204145-2c3ea96c31dd" 143 + hash = "sha256-XAhCOt8qJ2vR77lH1ez0IVU1/2CaLTq9jSmrHVg5HHU=" 144 + [mod."github.com/charmbracelet/x/term"] 145 + version = "v0.2.1" 146 + hash = "sha256-VBkCZLI90PhMasftGw3403IqoV7d3E5WEGAIVrN5xQM=" 66 147 [mod."github.com/cloudflare/circl"] 67 148 version = "v1.6.2-0.20250618153321-aa837fd1539d" 68 149 hash = "sha256-0s/i/XmMcuvPQ+qK9OIU5KxwYZyLVXRtdlYvIXRJT3Y=" ··· 84 165 [mod."github.com/davecgh/go-spew"] 85 166 version = "v1.1.2-0.20180830191138-d8f796af33cc" 86 167 hash = "sha256-fV9oI51xjHdOmEx6+dlq7Ku2Ag+m/bmbzPo6A4Y74qc=" 87 - [mod."github.com/decred/dcrd/dcrec/secp256k1/v4"] 88 - version = "v4.4.0" 89 - hash = "sha256-qrhEIwhDll3cxoVpMbm1NQ9/HTI42S7ms8Buzlo5HCg=" 90 168 [mod."github.com/dgraph-io/ristretto"] 91 169 version = "v0.2.0" 92 170 hash = "sha256-bnpxX+oO/Qf7IJevA0gsbloVoqRx+5bh7RQ9d9eLNYw=" ··· 145 223 [mod."github.com/go-jose/go-jose/v3"] 146 224 version = "v3.0.4" 147 225 hash = "sha256-RrLHCu9l6k0XVobdZQJ9Sx/VTQcWjrdLR5BEG7yXTEQ=" 226 + [mod."github.com/go-logfmt/logfmt"] 227 + version = "v0.6.0" 228 + hash = "sha256-RtIG2qARd5sT10WQ7F3LR8YJhS8exs+KiuUiVf75bWg=" 148 229 [mod."github.com/go-logr/logr"] 149 230 version = "v1.4.3" 150 231 hash = "sha256-Nnp/dEVNMxLp3RSPDHZzGbI8BkSNuZMX0I0cjWKXXLA=" ··· 163 244 [mod."github.com/gogo/protobuf"] 164 245 version = "v1.3.2" 165 246 hash = "sha256-pogILFrrk+cAtb0ulqn9+gRZJ7sGnnLLdtqITvxvG6c=" 247 + [mod."github.com/goki/freetype"] 248 + version = "v1.0.5" 249 + hash = "sha256-8ILVMx5w1/nV88RZPoG45QJ0jH1YEPJGLpZQdBJFqIs=" 166 250 [mod."github.com/golang-jwt/jwt/v5"] 167 251 version = "v5.2.3" 168 252 hash = "sha256-dY2avNPPS3xokn5E+VCLxXcQk7DsM7err2QGrG0nXKo=" ··· 172 256 [mod."github.com/golang/mock"] 173 257 version = "v1.6.0" 174 258 hash = "sha256-fWdnMQisRbiRzGT3ISrUHovquzLRHWvcv1JEsJFZRno=" 259 + [mod."github.com/golang/protobuf"] 260 + version = "v1.5.4" 261 + hash = "sha256-N3+Lv9lEZjrdOWdQhFj6Y3Iap4rVLEQeI8/eFFyAMZ0=" 262 + [mod."github.com/golang/snappy"] 263 + version = "v0.0.4" 264 + hash = "sha256-Umx+5xHAQCN/Gi4HbtMhnDCSPFAXSsjVbXd8n5LhjAA=" 175 265 [mod."github.com/google/go-querystring"] 176 266 version = "v1.1.0" 177 267 hash = "sha256-itsKgKghuX26czU79cK6C2n+lc27jm5Dw1XbIRgwZJY=" ··· 268 358 [mod."github.com/ipfs/go-metrics-interface"] 269 359 version = "v0.3.0" 270 360 hash = "sha256-b3tp3jxecLmJEGx2kW7MiKGlAKPEWg/LJ7hXylSC8jQ=" 361 + [mod."github.com/json-iterator/go"] 362 + version = "v1.1.12" 363 + hash = "sha256-To8A0h+lbfZ/6zM+2PpRpY3+L6725OPC66lffq6fUoM=" 271 364 [mod."github.com/kevinburke/ssh_config"] 272 365 version = "v1.2.0" 273 366 hash = "sha256-Ta7ZOmyX8gG5tzWbY2oES70EJPfI90U7CIJS9EAce0s=" ··· 277 370 [mod."github.com/klauspost/cpuid/v2"] 278 371 version = "v2.3.0" 279 372 hash = "sha256-50JhbQyT67BK38HIdJihPtjV7orYp96HknI2VP7A9Yc=" 280 - [mod."github.com/lestrrat-go/blackmagic"] 281 - version = "v1.0.4" 282 - hash = "sha256-HmWOpwoPDNMwLdOi7onNn3Sb+ZsAa3Ai3gVBbXmQ0e8=" 283 - [mod."github.com/lestrrat-go/httpcc"] 284 - version = "v1.0.1" 285 - hash = "sha256-SMRSwJpqDIs/xL0l2e8vP0W65qtCHX2wigcOeqPJmos=" 286 - [mod."github.com/lestrrat-go/httprc"] 287 - version = "v1.0.6" 288 - hash = "sha256-mfZzePEhrmyyu/avEBd2MsDXyto8dq5+fyu5lA8GUWM=" 289 - [mod."github.com/lestrrat-go/iter"] 290 - version = "v1.0.2" 291 - hash = "sha256-30tErRf7Qu/NOAt1YURXY/XJSA6sCr6hYQfO8QqHrtw=" 292 - [mod."github.com/lestrrat-go/jwx/v2"] 293 - version = "v2.1.6" 294 - hash = "sha256-0LszXRZIba+X8AOrs3T4uanAUafBdlVB8/MpUNEFpbc=" 295 - [mod."github.com/lestrrat-go/option"] 296 - version = "v1.0.1" 297 - hash = "sha256-jVcIYYVsxElIS/l2akEw32vdEPR8+anR6oeT1FoYULI=" 373 + [mod."github.com/lucasb-eyer/go-colorful"] 374 + version = "v1.2.0" 375 + hash = "sha256-Gg9dDJFCTaHrKHRR1SrJgZ8fWieJkybljybkI9x0gyE=" 298 376 [mod."github.com/mattn/go-isatty"] 299 377 version = "v0.0.20" 300 378 hash = "sha256-qhw9hWtU5wnyFyuMbKx+7RB8ckQaFQ8D+8GKPkN3HHQ=" 379 + [mod."github.com/mattn/go-runewidth"] 380 + version = "v0.0.16" 381 + hash = "sha256-NC+ntvwIpqDNmXb7aixcg09il80ygq6JAnW0Gb5b/DQ=" 301 382 [mod."github.com/mattn/go-sqlite3"] 302 383 version = "v1.14.24" 303 384 hash = "sha256-taGKFZFQlR5++5b2oZ1dYS3RERKv6yh1gniNWhb4egg=" ··· 319 400 [mod."github.com/moby/term"] 320 401 version = "v0.5.2" 321 402 hash = "sha256-/G20jUZKx36ktmPU/nEw/gX7kRTl1Dbu7zvNBYNt4xU=" 403 + [mod."github.com/modern-go/concurrent"] 404 + version = "v0.0.0-20180306012644-bacd9c7ef1dd" 405 + hash = "sha256-OTySieAgPWR4oJnlohaFTeK1tRaVp/b0d1rYY8xKMzo=" 406 + [mod."github.com/modern-go/reflect2"] 407 + version = "v1.0.2" 408 + hash = "sha256-+W9EIW7okXIXjWEgOaMh58eLvBZ7OshW2EhaIpNLSBU=" 322 409 [mod."github.com/morikuni/aec"] 323 410 version = "v1.0.0" 324 411 hash = "sha256-5zYgLeGr3K+uhGKlN3xv0PO67V+2Zw+cezjzNCmAWOE=" 325 412 [mod."github.com/mr-tron/base58"] 326 413 version = "v1.2.0" 327 414 hash = "sha256-8FzMu3kHUbBX10pUdtGf59Ag7BNupx8ZHeUaodR1/Vk=" 415 + [mod."github.com/mschoch/smat"] 416 + version = "v0.2.0" 417 + hash = "sha256-DZvUJXjIcta3U+zxzgU3wpoGn/V4lpBY7Xme8aQUi+E=" 418 + [mod."github.com/muesli/termenv"] 419 + version = "v0.16.0" 420 + hash = "sha256-hGo275DJlyLtcifSLpWnk8jardOksdeX9lH4lBeE3gI=" 328 421 [mod."github.com/multiformats/go-base32"] 329 422 version = "v0.1.0" 330 423 hash = "sha256-O2IM7FB+Y9MkDdZztyQL5F8oEnmON2Yew7XkotQziio=" ··· 391 484 [mod."github.com/resend/resend-go/v2"] 392 485 version = "v2.15.0" 393 486 hash = "sha256-1lMoxuMLQXaNWFKadS6rpztAKwvIl3/LWMXqw7f5WYg=" 487 + [mod."github.com/rivo/uniseg"] 488 + version = "v0.4.7" 489 + hash = "sha256-rDcdNYH6ZD8KouyyiZCUEy8JrjOQoAkxHBhugrfHjFo=" 394 490 [mod."github.com/ryanuber/go-glob"] 395 491 version = "v1.0.0" 396 492 hash = "sha256-YkMl1utwUhi3E0sHK23ISpAsPyj4+KeXyXKoFYGXGVY=" 397 - [mod."github.com/segmentio/asm"] 398 - version = "v1.2.0" 399 - hash = "sha256-zbNuKxNrUDUc6IlmRQNuJQzVe5Ol/mqp7srDg9IMMqs=" 400 493 [mod."github.com/sergi/go-diff"] 401 494 version = "v1.1.0" 402 495 hash = "sha256-8NJMabldpf40uwQN20T6QXx5KORDibCBJL02KD661xY=" ··· 407 500 [mod."github.com/spaolacci/murmur3"] 408 501 version = "v1.1.0" 409 502 hash = "sha256-RWD4PPrlAsZZ8Xy356MBxpj+/NZI7w2XOU14Ob7/Y9M=" 503 + [mod."github.com/srwiley/oksvg"] 504 + version = "v0.0.0-20221011165216-be6e8873101c" 505 + hash = "sha256-lZb6Y8HkrDpx9pxS+QQTcXI2MDSSv9pUyVTat59OrSk=" 506 + [mod."github.com/srwiley/rasterx"] 507 + version = "v0.0.0-20220730225603-2ab79fcdd4ef" 508 + hash = "sha256-/XmSE/J+f6FLWXGvljh6uBK71uoCAK3h82XQEQ1Ki68=" 410 509 [mod."github.com/stretchr/testify"] 411 510 version = "v1.10.0" 412 511 hash = "sha256-fJ4gnPr0vnrOhjQYQwJ3ARDKPsOtA7d4olQmQWR+wpI=" ··· 425 524 [mod."github.com/whyrusleeping/cbor-gen"] 426 525 version = "v0.3.1" 427 526 hash = "sha256-PAd8M2Z8t6rVRBII+Rg8Bz+QaJIwbW64bfyqsv31kgc=" 428 - [mod."github.com/wyatt915/goldmark-treeblood"] 429 - version = "v0.0.0-20250825231212-5dcbdb2f4b57" 430 - hash = "sha256-IZEsUXTBTsNgWoD7vqRUc9aFCCHNjzk1IUmI9O+NCnM=" 431 - [mod."github.com/wyatt915/treeblood"] 432 - version = "v0.1.15" 433 - hash = "sha256-hb99exdkoY2Qv8WdDxhwgPXGbEYimUr6wFtPXEvcO9g=" 527 + [mod."github.com/xo/terminfo"] 528 + version = "v0.0.0-20220910002029-abceb7e1c41e" 529 + hash = "sha256-GyCDxxMQhXA3Pi/TsWXpA8cX5akEoZV7CFx4RO3rARU=" 434 530 [mod."github.com/yuin/goldmark"] 435 - version = "v1.7.12" 436 - hash = "sha256-thLYBS4woL2X5qRdo7vP+xCvjlGRDU0jXtDCUt6vvWM=" 531 + version = "v1.7.13" 532 + hash = "sha256-vBCxZrPYPc8x/nvAAv3Au59dCCyfS80Vw3/a9EXK7TE=" 437 533 [mod."github.com/yuin/goldmark-highlighting/v2"] 438 534 version = "v2.0.0-20230729083705-37449abec8cc" 439 535 hash = "sha256-HpiwU7jIeDUAg2zOpTIiviQir8dpRPuXYh2nqFFccpg=" 536 + [mod."gitlab.com/staticnoise/goldmark-callout"] 537 + version = "v0.0.0-20240609120641-6366b799e4ab" 538 + hash = "sha256-CgqBIYAuSmL2hcFu5OW18nWWaSy3pp3CNp5jlWzBX44=" 440 539 [mod."gitlab.com/yawning/secp256k1-voi"] 441 540 version = "v0.0.0-20230925100816-f2616030848b" 442 541 hash = "sha256-X8INg01LTg13iOuwPI3uOhPN7r01sPZtmtwJ2sudjCA=" 443 542 [mod."gitlab.com/yawning/tuplehash"] 444 543 version = "v0.0.0-20230713102510-df83abbf9a02" 445 544 hash = "sha256-pehQduoaJRLchebhgvMYacVvbuNIBA++XkiqCuqdato=" 545 + [mod."go.etcd.io/bbolt"] 546 + version = "v1.4.0" 547 + hash = "sha256-nR/YGQjwz6ue99IFbgw/01Pl8PhoOjpKiwVy5sJxlps=" 446 548 [mod."go.opentelemetry.io/auto/sdk"] 447 549 version = "v1.1.0" 448 550 hash = "sha256-cA9qCCu8P1NSJRxgmpfkfa5rKyn9X+Y/9FSmSd5xjyo=" ··· 479 581 [mod."golang.org/x/exp"] 480 582 version = "v0.0.0-20250620022241-b7579e27df2b" 481 583 hash = "sha256-IsDTeuWLj4UkPO4NhWTvFeZ22WNtlxjoWiyAJh6zdig=" 584 + [mod."golang.org/x/image"] 585 + version = "v0.31.0" 586 + hash = "sha256-ZFTlu9+4QToPPLA8C5UcG2eq/lQylq81RoG/WtYo9rg=" 482 587 [mod."golang.org/x/net"] 483 588 version = "v0.42.0" 484 589 hash = "sha256-YxileisIIez+kcAI+21kY5yk0iRuEqti2YdmS8jvP2s=" 485 590 [mod."golang.org/x/sync"] 486 - version = "v0.16.0" 487 - hash = "sha256-sqKDRESeMzLe0jWGWltLZL/JIgrn0XaIeBWCzVN3Bks=" 591 + version = "v0.17.0" 592 + hash = "sha256-M85lz4hK3/fzmcUViAp/CowHSxnr3BHSO7pjHp1O6i0=" 488 593 [mod."golang.org/x/sys"] 489 594 version = "v0.34.0" 490 595 hash = "sha256-5rZ7p8IaGli5X1sJbfIKOcOEwY4c0yQhinJPh2EtK50=" 491 596 [mod."golang.org/x/text"] 492 - version = "v0.27.0" 493 - hash = "sha256-VX0rOh6L3qIvquKSGjfZQFU8URNtGvkNvxE7OZtboW8=" 597 + version = "v0.29.0" 598 + hash = "sha256-2cWBtJje+Yc+AnSgCANqBlIwnOMZEGkpQ2cFI45VfLI=" 494 599 [mod."golang.org/x/time"] 495 600 version = "v0.12.0" 496 601 hash = "sha256-Cp3oxrCMH2wyxjzr5SHVmyhgaoUuSl56Uy00Q7DYEpw=" ··· 527 632 [mod."lukechampine.com/blake3"] 528 633 version = "v1.4.1" 529 634 hash = "sha256-HaZGo9L44ptPsgxIhvKy3+0KZZm1+xt+cZC1rDQA9Yc=" 530 - [mod."tangled.org/anirudh.fi/atproto-oauth"] 531 - version = "v0.0.0-20250724194903-28e660378cb1" 532 - hash = "sha256-z7huwCTTHqLb1hxQW62lz9GQ3Orqt4URfeOVhQVd1f8="
+285 -18
nix/modules/appview.nix
··· 3 3 lib, 4 4 ... 5 5 }: let 6 - cfg = config.services.tangled-appview; 6 + cfg = config.services.tangled.appview; 7 7 in 8 8 with lib; { 9 9 options = { 10 - services.tangled-appview = { 10 + services.tangled.appview = { 11 11 enable = mkOption { 12 12 type = types.bool; 13 13 default = false; 14 14 description = "Enable tangled appview"; 15 15 }; 16 + 16 17 package = mkOption { 17 18 type = types.package; 18 19 description = "Package to use for the appview"; 19 20 }; 21 + 22 + # core configuration 20 23 port = mkOption { 21 - type = types.int; 24 + type = types.port; 22 25 default = 3000; 23 26 description = "Port to run the appview on"; 24 27 }; 25 - cookie_secret = mkOption { 28 + 29 + listenAddr = mkOption { 30 + type = types.str; 31 + default = "0.0.0.0:${toString cfg.port}"; 32 + description = "Listen address for the appview service"; 33 + }; 34 + 35 + dbPath = mkOption { 26 36 type = types.str; 27 - default = "00000000000000000000000000000000"; 28 - description = "Cookie secret"; 37 + default = "/var/lib/appview/appview.db"; 38 + description = "Path to the SQLite database file"; 39 + }; 40 + 41 + appviewHost = mkOption { 42 + type = types.str; 43 + default = "https://tangled.org"; 44 + example = "https://example.com"; 45 + description = "Public host URL for the appview instance"; 46 + }; 47 + 48 + appviewName = mkOption { 49 + type = types.str; 50 + default = "Tangled"; 51 + description = "Display name for the appview instance"; 52 + }; 53 + 54 + dev = mkOption { 55 + type = types.bool; 56 + default = false; 57 + description = "Enable development mode"; 58 + }; 59 + 60 + disallowedNicknamesFile = mkOption { 61 + type = types.nullOr types.path; 62 + default = null; 63 + description = "Path to file containing disallowed nicknames"; 64 + }; 65 + 66 + # redis configuration 67 + redis = { 68 + addr = mkOption { 69 + type = types.str; 70 + default = "localhost:6379"; 71 + description = "Redis server address"; 72 + }; 73 + 74 + db = mkOption { 75 + type = types.int; 76 + default = 0; 77 + description = "Redis database number"; 78 + }; 79 + }; 80 + 81 + # jetstream configuration 82 + jetstream = { 83 + endpoint = mkOption { 84 + type = types.str; 85 + default = "wss://jetstream1.us-east.bsky.network/subscribe"; 86 + description = "Jetstream WebSocket endpoint"; 87 + }; 88 + }; 89 + 90 + # knotstream consumer configuration 91 + knotstream = { 92 + retryInterval = mkOption { 93 + type = types.str; 94 + default = "60s"; 95 + description = "Initial retry interval for knotstream consumer"; 96 + }; 97 + 98 + maxRetryInterval = mkOption { 99 + type = types.str; 100 + default = "120m"; 101 + description = "Maximum retry interval for knotstream consumer"; 102 + }; 103 + 104 + connectionTimeout = mkOption { 105 + type = types.str; 106 + default = "5s"; 107 + description = "Connection timeout for knotstream consumer"; 108 + }; 109 + 110 + workerCount = mkOption { 111 + type = types.int; 112 + default = 64; 113 + description = "Number of workers for knotstream consumer"; 114 + }; 115 + 116 + queueSize = mkOption { 117 + type = types.int; 118 + default = 100; 119 + description = "Queue size for knotstream consumer"; 120 + }; 121 + }; 122 + 123 + # spindlestream consumer configuration 124 + spindlestream = { 125 + retryInterval = mkOption { 126 + type = types.str; 127 + default = "60s"; 128 + description = "Initial retry interval for spindlestream consumer"; 129 + }; 130 + 131 + maxRetryInterval = mkOption { 132 + type = types.str; 133 + default = "120m"; 134 + description = "Maximum retry interval for spindlestream consumer"; 135 + }; 136 + 137 + connectionTimeout = mkOption { 138 + type = types.str; 139 + default = "5s"; 140 + description = "Connection timeout for spindlestream consumer"; 141 + }; 142 + 143 + workerCount = mkOption { 144 + type = types.int; 145 + default = 64; 146 + description = "Number of workers for spindlestream consumer"; 147 + }; 148 + 149 + queueSize = mkOption { 150 + type = types.int; 151 + default = 100; 152 + description = "Queue size for spindlestream consumer"; 153 + }; 154 + }; 155 + 156 + # resend configuration 157 + resend = { 158 + sentFrom = mkOption { 159 + type = types.str; 160 + default = "noreply@notifs.tangled.sh"; 161 + description = "Email address to send notifications from"; 162 + }; 163 + }; 164 + 165 + # posthog configuration 166 + posthog = { 167 + endpoint = mkOption { 168 + type = types.str; 169 + default = "https://eu.i.posthog.com"; 170 + description = "PostHog API endpoint"; 171 + }; 172 + }; 173 + 174 + # camo configuration 175 + camo = { 176 + host = mkOption { 177 + type = types.str; 178 + default = "https://camo.tangled.sh"; 179 + description = "Camo proxy host URL"; 180 + }; 29 181 }; 182 + 183 + # avatar configuration 184 + avatar = { 185 + host = mkOption { 186 + type = types.str; 187 + default = "https://avatar.tangled.sh"; 188 + description = "Avatar service host URL"; 189 + }; 190 + }; 191 + 192 + plc = { 193 + url = mkOption { 194 + type = types.str; 195 + default = "https://plc.directory"; 196 + description = "PLC directory URL"; 197 + }; 198 + }; 199 + 200 + pds = { 201 + host = mkOption { 202 + type = types.str; 203 + default = "https://tngl.sh"; 204 + description = "PDS host URL"; 205 + }; 206 + }; 207 + 208 + label = { 209 + defaults = mkOption { 210 + type = types.listOf types.str; 211 + default = [ 212 + "at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/wontfix" 213 + "at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/good-first-issue" 214 + "at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/duplicate" 215 + "at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/documentation" 216 + "at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/assignee" 217 + ]; 218 + description = "Default label definitions"; 219 + }; 220 + 221 + goodFirstIssue = mkOption { 222 + type = types.str; 223 + default = "at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/good-first-issue"; 224 + description = "Good first issue label definition"; 225 + }; 226 + }; 227 + 30 228 environmentFile = mkOption { 31 229 type = with types; nullOr path; 32 230 default = null; 33 - example = "/etc/tangled-appview.env"; 231 + example = "/etc/appview.env"; 34 232 description = '' 35 233 Additional environment file as defined in {manpage}`systemd.exec(5)`. 36 234 37 - Sensitive secrets such as {env}`TANGLED_COOKIE_SECRET` may be 38 - passed to the service without makeing them world readable in the 39 - nix store. 40 - 235 + Sensitive secrets such as {env}`TANGLED_COOKIE_SECRET`, 236 + {env}`TANGLED_OAUTH_CLIENT_SECRET`, {env}`TANGLED_RESEND_API_KEY`, 237 + {env}`TANGLED_CAMO_SHARED_SECRET`, {env}`TANGLED_AVATAR_SHARED_SECRET`, 238 + {env}`TANGLED_REDIS_PASS`, {env}`TANGLED_PDS_ADMIN_SECRET`, 239 + {env}`TANGLED_CLOUDFLARE_API_TOKEN`, {env}`TANGLED_CLOUDFLARE_ZONE_ID`, 240 + {env}`TANGLED_CLOUDFLARE_TURNSTILE_SITE_KEY`, 241 + {env}`TANGLED_CLOUDFLARE_TURNSTILE_SECRET_KEY`, 242 + {env}`TANGLED_POSTHOG_API_KEY`, {env}`TANGLED_APP_PASSWORD`, 243 + and {env}`TANGLED_ALT_APP_PASSWORD` may be passed to the service 244 + without making them world readable in the nix store. 41 245 ''; 42 246 }; 43 247 }; 44 248 }; 45 249 46 250 config = mkIf cfg.enable { 47 - systemd.services.tangled-appview = { 251 + services.redis.servers.appview = { 252 + enable = true; 253 + port = 6379; 254 + }; 255 + 256 + systemd.services.appview = { 48 257 description = "tangled appview service"; 49 258 wantedBy = ["multi-user.target"]; 259 + after = ["redis-appview.service" "network-online.target"]; 260 + requires = ["redis-appview.service"]; 261 + wants = ["network-online.target"]; 50 262 51 263 serviceConfig = { 52 - ListenStream = "0.0.0.0:${toString cfg.port}"; 264 + Type = "simple"; 53 265 ExecStart = "${cfg.package}/bin/appview"; 54 266 Restart = "always"; 55 - EnvironmentFile = optional (cfg.environmentFile != null) cfg.environmentFile; 267 + RestartSec = "10s"; 268 + EnvironmentFile = mkIf (cfg.environmentFile != null) cfg.environmentFile; 269 + 270 + # state directory 271 + StateDirectory = "appview"; 272 + WorkingDirectory = "/var/lib/appview"; 273 + 274 + # security hardening 275 + NoNewPrivileges = true; 276 + PrivateTmp = true; 277 + ProtectSystem = "strict"; 278 + ProtectHome = true; 279 + ReadWritePaths = ["/var/lib/appview"]; 56 280 }; 57 281 58 - environment = { 59 - TANGLED_DB_PATH = "appview.db"; 60 - TANGLED_COOKIE_SECRET = cfg.cookie_secret; 61 - }; 282 + environment = 283 + { 284 + TANGLED_DB_PATH = cfg.dbPath; 285 + TANGLED_LISTEN_ADDR = cfg.listenAddr; 286 + TANGLED_APPVIEW_HOST = cfg.appviewHost; 287 + TANGLED_APPVIEW_NAME = cfg.appviewName; 288 + TANGLED_DEV = 289 + if cfg.dev 290 + then "true" 291 + else "false"; 292 + } 293 + // optionalAttrs (cfg.disallowedNicknamesFile != null) { 294 + TANGLED_DISALLOWED_NICKNAMES_FILE = cfg.disallowedNicknamesFile; 295 + } 296 + // { 297 + TANGLED_REDIS_ADDR = cfg.redis.addr; 298 + TANGLED_REDIS_DB = toString cfg.redis.db; 299 + 300 + TANGLED_JETSTREAM_ENDPOINT = cfg.jetstream.endpoint; 301 + 302 + TANGLED_KNOTSTREAM_RETRY_INTERVAL = cfg.knotstream.retryInterval; 303 + TANGLED_KNOTSTREAM_MAX_RETRY_INTERVAL = cfg.knotstream.maxRetryInterval; 304 + TANGLED_KNOTSTREAM_CONNECTION_TIMEOUT = cfg.knotstream.connectionTimeout; 305 + TANGLED_KNOTSTREAM_WORKER_COUNT = toString cfg.knotstream.workerCount; 306 + TANGLED_KNOTSTREAM_QUEUE_SIZE = toString cfg.knotstream.queueSize; 307 + 308 + TANGLED_SPINDLESTREAM_RETRY_INTERVAL = cfg.spindlestream.retryInterval; 309 + TANGLED_SPINDLESTREAM_MAX_RETRY_INTERVAL = cfg.spindlestream.maxRetryInterval; 310 + TANGLED_SPINDLESTREAM_CONNECTION_TIMEOUT = cfg.spindlestream.connectionTimeout; 311 + TANGLED_SPINDLESTREAM_WORKER_COUNT = toString cfg.spindlestream.workerCount; 312 + TANGLED_SPINDLESTREAM_QUEUE_SIZE = toString cfg.spindlestream.queueSize; 313 + 314 + TANGLED_RESEND_SENT_FROM = cfg.resend.sentFrom; 315 + 316 + TANGLED_POSTHOG_ENDPOINT = cfg.posthog.endpoint; 317 + 318 + TANGLED_CAMO_HOST = cfg.camo.host; 319 + 320 + TANGLED_AVATAR_HOST = cfg.avatar.host; 321 + 322 + TANGLED_PLC_URL = cfg.plc.url; 323 + 324 + TANGLED_PDS_HOST = cfg.pds.host; 325 + 326 + TANGLED_LABEL_DEFAULTS = concatStringsSep "," cfg.label.defaults; 327 + TANGLED_LABEL_GFI = cfg.label.goodFirstIssue; 328 + }; 62 329 }; 63 330 }; 64 331 }
+78 -6
nix/modules/knot.nix
··· 4 4 lib, 5 5 ... 6 6 }: let 7 - cfg = config.services.tangled-knot; 7 + cfg = config.services.tangled.knot; 8 8 in 9 9 with lib; { 10 10 options = { 11 - services.tangled-knot = { 11 + services.tangled.knot = { 12 12 enable = mkOption { 13 13 type = types.bool; 14 14 default = false; ··· 22 22 23 23 appviewEndpoint = mkOption { 24 24 type = types.str; 25 - default = "https://tangled.sh"; 25 + default = "https://tangled.org"; 26 26 description = "Appview endpoint"; 27 27 }; 28 28 ··· 51 51 description = "Path where repositories are scanned from"; 52 52 }; 53 53 54 + readme = mkOption { 55 + type = types.listOf types.str; 56 + default = [ 57 + "README.md" 58 + "readme.md" 59 + "README" 60 + "readme" 61 + "README.markdown" 62 + "readme.markdown" 63 + "README.txt" 64 + "readme.txt" 65 + "README.rst" 66 + "readme.rst" 67 + "README.org" 68 + "readme.org" 69 + "README.asciidoc" 70 + "readme.asciidoc" 71 + ]; 72 + description = "List of README filenames to look for (in priority order)"; 73 + }; 74 + 54 75 mainBranch = mkOption { 55 76 type = types.str; 56 77 default = "main"; 57 78 description = "Default branch name for repositories"; 79 + }; 80 + }; 81 + 82 + git = { 83 + userName = mkOption { 84 + type = types.str; 85 + default = "Tangled"; 86 + description = "Git user name used as committer"; 87 + }; 88 + 89 + userEmail = mkOption { 90 + type = types.str; 91 + default = "noreply@tangled.org"; 92 + description = "Git user email used as committer"; 58 93 }; 59 94 }; 60 95 ··· 107 142 108 143 hostname = mkOption { 109 144 type = types.str; 110 - example = "knot.tangled.sh"; 145 + example = "my.knot.com"; 111 146 description = "Hostname for the server (required)"; 147 + }; 148 + 149 + plcUrl = mkOption { 150 + type = types.str; 151 + default = "https://plc.directory"; 152 + description = "atproto PLC directory"; 153 + }; 154 + 155 + jetstreamEndpoint = mkOption { 156 + type = types.str; 157 + default = "wss://jetstream1.us-west.bsky.network/subscribe"; 158 + description = "Jetstream endpoint to subscribe to"; 159 + }; 160 + 161 + logDids = mkOption { 162 + type = types.bool; 163 + default = true; 164 + description = "Enable logging of DIDs"; 112 165 }; 113 166 114 167 dev = mkOption { ··· 142 195 Match User ${cfg.gitUser} 143 196 AuthorizedKeysCommand /etc/ssh/keyfetch_wrapper 144 197 AuthorizedKeysCommandUser nobody 198 + ChallengeResponseAuthentication no 199 + PasswordAuthentication no 145 200 ''; 146 201 }; 147 202 ··· 178 233 mkdir -p "${cfg.stateDir}/.config/git" 179 234 cat > "${cfg.stateDir}/.config/git/config" << EOF 180 235 [user] 181 - name = Git User 182 - email = git@example.com 236 + name = ${cfg.git.userName} 237 + email = ${cfg.git.userEmail} 183 238 [receive] 184 239 advertisePushOptions = true 240 + [uploadpack] 241 + allowFilter = true 185 242 EOF 186 243 ${setMotd} 187 244 chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.stateDir}" ··· 193 250 WorkingDirectory = cfg.stateDir; 194 251 Environment = [ 195 252 "KNOT_REPO_SCAN_PATH=${cfg.repo.scanPath}" 253 + "KNOT_REPO_README=${concatStringsSep "," cfg.repo.readme}" 196 254 "KNOT_REPO_MAIN_BRANCH=${cfg.repo.mainBranch}" 255 + "KNOT_GIT_USER_NAME=${cfg.git.userName}" 256 + "KNOT_GIT_USER_EMAIL=${cfg.git.userEmail}" 197 257 "APPVIEW_ENDPOINT=${cfg.appviewEndpoint}" 198 258 "KNOT_SERVER_INTERNAL_LISTEN_ADDR=${cfg.server.internalListenAddr}" 199 259 "KNOT_SERVER_LISTEN_ADDR=${cfg.server.listenAddr}" 200 260 "KNOT_SERVER_DB_PATH=${cfg.server.dbPath}" 201 261 "KNOT_SERVER_HOSTNAME=${cfg.server.hostname}" 262 + "KNOT_SERVER_PLC_URL=${cfg.server.plcUrl}" 263 + "KNOT_SERVER_JETSTREAM_ENDPOINT=${cfg.server.jetstreamEndpoint}" 202 264 "KNOT_SERVER_OWNER=${cfg.server.owner}" 265 + "KNOT_SERVER_LOG_DIDS=${ 266 + if cfg.server.logDids 267 + then "true" 268 + else "false" 269 + }" 270 + "KNOT_SERVER_DEV=${ 271 + if cfg.server.dev 272 + then "true" 273 + else "false" 274 + }" 203 275 ]; 204 276 ExecStart = "${cfg.package}/bin/knot server"; 205 277 Restart = "always";
+12 -5
nix/modules/spindle.nix
··· 3 3 lib, 4 4 ... 5 5 }: let 6 - cfg = config.services.tangled-spindle; 6 + cfg = config.services.tangled.spindle; 7 7 in 8 8 with lib; { 9 9 options = { 10 - services.tangled-spindle = { 10 + services.tangled.spindle = { 11 11 enable = mkOption { 12 12 type = types.bool; 13 13 default = false; ··· 33 33 34 34 hostname = mkOption { 35 35 type = types.str; 36 - example = "spindle.tangled.sh"; 36 + example = "my.spindle.com"; 37 37 description = "Hostname for the server (required)"; 38 38 }; 39 39 40 + plcUrl = mkOption { 41 + type = types.str; 42 + default = "https://plc.directory"; 43 + description = "atproto PLC directory"; 44 + }; 45 + 40 46 jetstreamEndpoint = mkOption { 41 47 type = types.str; 42 48 default = "wss://jetstream1.us-west.bsky.network/subscribe"; ··· 92 98 pipelines = { 93 99 nixery = mkOption { 94 100 type = types.str; 95 - default = "nixery.tangled.sh"; 101 + default = "nixery.tangled.sh"; # note: this is *not* on tangled.org yet 96 102 description = "Nixery instance to use"; 97 103 }; 98 104 ··· 119 125 "SPINDLE_SERVER_LISTEN_ADDR=${cfg.server.listenAddr}" 120 126 "SPINDLE_SERVER_DB_PATH=${cfg.server.dbPath}" 121 127 "SPINDLE_SERVER_HOSTNAME=${cfg.server.hostname}" 122 - "SPINDLE_SERVER_JETSTREAM=${cfg.server.jetstreamEndpoint}" 128 + "SPINDLE_SERVER_PLC_URL=${cfg.server.plcUrl}" 129 + "SPINDLE_SERVER_JETSTREAM_ENDPOINT=${cfg.server.jetstreamEndpoint}" 123 130 "SPINDLE_SERVER_DEV=${lib.boolToString cfg.server.dev}" 124 131 "SPINDLE_SERVER_OWNER=${cfg.server.owner}" 125 132 "SPINDLE_SERVER_MAX_JOB_COUNT=${toString cfg.server.maxJobCount}"
+2
nix/pkgs/appview-static-files.nix
··· 5 5 lucide-src, 6 6 inter-fonts-src, 7 7 ibm-plex-mono-src, 8 + actor-typeahead-src, 8 9 sqlite-lib, 9 10 tailwindcss, 10 11 src, ··· 24 25 cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 fonts/ 25 26 cp -f ${inter-fonts-src}/InterVariable*.ttf fonts/ 26 27 cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono*.woff2 fonts/ 28 + cp -f ${actor-typeahead-src}/actor-typeahead.js . 27 29 # tailwindcss -c $src/tailwind.config.js -i $src/input.css -o tw.css won't work 28 30 # for whatever reason (produces broken css), so we are doing this instead 29 31 cd ${src} && ${tailwindcss}/bin/tailwindcss -i input.css -o $out/tw.css
-18
nix/pkgs/genjwks.nix
··· 1 - { 2 - buildGoApplication, 3 - modules, 4 - }: 5 - buildGoApplication { 6 - pname = "genjwks"; 7 - version = "0.1.0"; 8 - src = ../../cmd/genjwks; 9 - postPatch = '' 10 - ln -s ${../../go.mod} ./go.mod 11 - ''; 12 - postInstall = '' 13 - mv $out/bin/core $out/bin/genjwks 14 - ''; 15 - inherit modules; 16 - doCheck = false; 17 - CGO_ENABLED = 0; 18 - }
+12
nix/pkgs/goat.nix
··· 1 + { 2 + buildGoModule, 3 + indigo, 4 + }: 5 + buildGoModule { 6 + pname = "goat"; 7 + version = "0.1.0"; 8 + src = indigo; 9 + subPackages = ["cmd/goat"]; 10 + vendorHash = "sha256-VbDrcN4r5b7utRFQzVsKgDsVgdQLSXl7oZ5kdPA/huw="; 11 + doCheck = false; 12 + }
+1 -1
nix/pkgs/knot-unwrapped.nix
··· 4 4 sqlite-lib, 5 5 src, 6 6 }: let 7 - version = "1.9.1-alpha"; 7 + version = "1.11.0-alpha"; 8 8 in 9 9 buildGoApplication { 10 10 pname = "knot";
+7 -5
nix/pkgs/sqlite-lib.nix
··· 1 1 { 2 - gcc, 3 2 stdenv, 4 3 sqlite-lib-src, 5 4 }: 6 5 stdenv.mkDerivation { 7 6 name = "sqlite-lib"; 8 7 src = sqlite-lib-src; 9 - nativeBuildInputs = [gcc]; 8 + 10 9 buildPhase = '' 11 - gcc -c sqlite3.c 12 - ar rcs libsqlite3.a sqlite3.o 13 - ranlib libsqlite3.a 10 + $CC -c sqlite3.c 11 + $AR rcs libsqlite3.a sqlite3.o 12 + $RANLIB libsqlite3.a 13 + ''; 14 + 15 + installPhase = '' 14 16 mkdir -p $out/include $out/lib 15 17 cp *.h $out/include 16 18 cp libsqlite3.a $out/lib
+24 -11
nix/vm.nix
··· 10 10 if var == "" 11 11 then throw "\$${name} must be defined, see docs/hacking.md for more details" 12 12 else var; 13 + envVarOr = name: default: let 14 + var = builtins.getEnv name; 15 + in 16 + if var != "" 17 + then var 18 + else default; 19 + 20 + plcUrl = envVarOr "TANGLED_VM_PLC_URL" "https://plc.directory"; 21 + jetstream = envVarOr "TANGLED_VM_JETSTREAM_ENDPOINT" "wss://jetstream1.us-west.bsky.network/subscribe"; 13 22 in 14 23 nixpkgs.lib.nixosSystem { 15 24 inherit system; ··· 39 48 # knot 40 49 { 41 50 from = "host"; 42 - host.port = 6000; 43 - guest.port = 6000; 51 + host.port = 6444; 52 + guest.port = 6444; 44 53 } 45 54 # spindle 46 55 { ··· 73 82 time.timeZone = "Europe/London"; 74 83 services.getty.autologinUser = "root"; 75 84 environment.systemPackages = with pkgs; [curl vim git sqlite litecli]; 76 - services.tangled-knot = { 85 + services.tangled.knot = { 77 86 enable = true; 78 87 motd = "Welcome to the development knot!\n"; 79 88 server = { 80 89 owner = envVar "TANGLED_VM_KNOT_OWNER"; 81 - hostname = "localhost:6000"; 82 - listenAddr = "0.0.0.0:6000"; 90 + hostname = envVarOr "TANGLED_VM_KNOT_HOST" "localhost:6444"; 91 + plcUrl = plcUrl; 92 + jetstreamEndpoint = jetstream; 93 + listenAddr = "0.0.0.0:6444"; 83 94 }; 84 95 }; 85 - services.tangled-spindle = { 96 + services.tangled.spindle = { 86 97 enable = true; 87 98 server = { 88 99 owner = envVar "TANGLED_VM_SPINDLE_OWNER"; 89 - hostname = "localhost:6555"; 100 + hostname = envVarOr "TANGLED_VM_SPINDLE_HOST" "localhost:6555"; 101 + plcUrl = plcUrl; 102 + jetstreamEndpoint = jetstream; 90 103 listenAddr = "0.0.0.0:6555"; 91 104 dev = true; 92 105 queueSize = 100; ··· 99 112 users = { 100 113 # So we don't have to deal with permission clashing between 101 114 # blank disk VMs and existing state 102 - users.${config.services.tangled-knot.gitUser}.uid = 666; 103 - groups.${config.services.tangled-knot.gitUser}.gid = 666; 115 + users.${config.services.tangled.knot.gitUser}.uid = 666; 116 + groups.${config.services.tangled.knot.gitUser}.gid = 666; 104 117 105 118 # TODO: separate spindle user 106 119 }; ··· 120 133 serviceConfig.PermissionsStartOnly = true; 121 134 }; 122 135 in { 123 - knot = mkDataSyncScripts "/mnt/knot-data" config.services.tangled-knot.stateDir; 124 - spindle = mkDataSyncScripts "/mnt/spindle-data" (builtins.dirOf config.services.tangled-spindle.server.dbPath); 136 + knot = mkDataSyncScripts "/mnt/knot-data" config.services.tangled.knot.stateDir; 137 + spindle = mkDataSyncScripts "/mnt/spindle-data" (builtins.dirOf config.services.tangled.spindle.server.dbPath); 125 138 }; 126 139 }) 127 140 ];
+122
orm/orm.go
··· 1 + package orm 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + "fmt" 7 + "log/slog" 8 + "reflect" 9 + "strings" 10 + ) 11 + 12 + type migrationFn = func(*sql.Tx) error 13 + 14 + func RunMigration(c *sql.Conn, logger *slog.Logger, name string, migrationFn migrationFn) error { 15 + logger = logger.With("migration", name) 16 + 17 + tx, err := c.BeginTx(context.Background(), nil) 18 + if err != nil { 19 + return err 20 + } 21 + defer tx.Rollback() 22 + 23 + var exists bool 24 + err = tx.QueryRow("select exists (select 1 from migrations where name = ?)", name).Scan(&exists) 25 + if err != nil { 26 + return err 27 + } 28 + 29 + if !exists { 30 + // run migration 31 + err = migrationFn(tx) 32 + if err != nil { 33 + logger.Error("failed to run migration", "err", err) 34 + return err 35 + } 36 + 37 + // mark migration as complete 38 + _, err = tx.Exec("insert into migrations (name) values (?)", name) 39 + if err != nil { 40 + logger.Error("failed to mark migration as complete", "err", err) 41 + return err 42 + } 43 + 44 + // commit the transaction 45 + if err := tx.Commit(); err != nil { 46 + return err 47 + } 48 + 49 + logger.Info("migration applied successfully") 50 + } else { 51 + logger.Warn("skipped migration, already applied") 52 + } 53 + 54 + return nil 55 + } 56 + 57 + type Filter struct { 58 + Key string 59 + arg any 60 + Cmp string 61 + } 62 + 63 + func newFilter(key, cmp string, arg any) Filter { 64 + return Filter{ 65 + Key: key, 66 + arg: arg, 67 + Cmp: cmp, 68 + } 69 + } 70 + 71 + func FilterEq(key string, arg any) Filter { return newFilter(key, "=", arg) } 72 + func FilterNotEq(key string, arg any) Filter { return newFilter(key, "<>", arg) } 73 + func FilterGte(key string, arg any) Filter { return newFilter(key, ">=", arg) } 74 + func FilterLte(key string, arg any) Filter { return newFilter(key, "<=", arg) } 75 + func FilterIs(key string, arg any) Filter { return newFilter(key, "is", arg) } 76 + func FilterIsNot(key string, arg any) Filter { return newFilter(key, "is not", arg) } 77 + func FilterIn(key string, arg any) Filter { return newFilter(key, "in", arg) } 78 + func FilterLike(key string, arg any) Filter { return newFilter(key, "like", arg) } 79 + func FilterNotLike(key string, arg any) Filter { return newFilter(key, "not like", arg) } 80 + func FilterContains(key string, arg any) Filter { 81 + return newFilter(key, "like", fmt.Sprintf("%%%v%%", arg)) 82 + } 83 + 84 + func (f Filter) Condition() string { 85 + rv := reflect.ValueOf(f.arg) 86 + kind := rv.Kind() 87 + 88 + // if we have `FilterIn(k, [1, 2, 3])`, compile it down to `k in (?, ?, ?)` 89 + if (kind == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8) || kind == reflect.Array { 90 + if rv.Len() == 0 { 91 + // always false 92 + return "1 = 0" 93 + } 94 + 95 + placeholders := make([]string, rv.Len()) 96 + for i := range placeholders { 97 + placeholders[i] = "?" 98 + } 99 + 100 + return fmt.Sprintf("%s %s (%s)", f.Key, f.Cmp, strings.Join(placeholders, ", ")) 101 + } 102 + 103 + return fmt.Sprintf("%s %s ?", f.Key, f.Cmp) 104 + } 105 + 106 + func (f Filter) Arg() []any { 107 + rv := reflect.ValueOf(f.arg) 108 + kind := rv.Kind() 109 + if (kind == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8) || kind == reflect.Array { 110 + if rv.Len() == 0 { 111 + return nil 112 + } 113 + 114 + out := make([]any, rv.Len()) 115 + for i := range rv.Len() { 116 + out[i] = rv.Index(i).Interface() 117 + } 118 + return out 119 + } 120 + 121 + return []any{f.arg} 122 + }
+18 -8
patchutil/patchutil.go
··· 1 1 package patchutil 2 2 3 3 import ( 4 + "errors" 4 5 "fmt" 5 6 "log" 6 7 "os" ··· 42 43 // IsPatchValid checks if the given patch string is valid. 43 44 // It performs very basic sniffing for either git-diff or git-format-patch 44 45 // header lines. For format patches, it attempts to extract and validate each one. 45 - func IsPatchValid(patch string) bool { 46 + var ( 47 + EmptyPatchError error = errors.New("patch is empty") 48 + GenericPatchError error = errors.New("patch is invalid") 49 + FormatPatchError error = errors.New("patch is not a valid format-patch") 50 + ) 51 + 52 + func IsPatchValid(patch string) error { 46 53 if len(patch) == 0 { 47 - return false 54 + return EmptyPatchError 48 55 } 49 56 50 57 lines := strings.Split(patch, "\n") 51 58 if len(lines) < 2 { 52 - return false 59 + return EmptyPatchError 53 60 } 54 61 55 62 firstLine := strings.TrimSpace(lines[0]) ··· 60 67 strings.HasPrefix(firstLine, "Index: ") || 61 68 strings.HasPrefix(firstLine, "+++ ") || 62 69 strings.HasPrefix(firstLine, "@@ ") { 63 - return true 70 + return nil 64 71 } 65 72 66 73 // check if it's format-patch ··· 70 77 // it's safe to say it's broken. 71 78 patches, err := ExtractPatches(patch) 72 79 if err != nil { 73 - return false 80 + return fmt.Errorf("%w: %w", FormatPatchError, err) 74 81 } 75 - return len(patches) > 0 82 + if len(patches) == 0 { 83 + return EmptyPatchError 84 + } 85 + 86 + return nil 76 87 } 77 88 78 - return false 89 + return GenericPatchError 79 90 } 80 91 81 92 func IsFormatPatch(patch string) bool { ··· 285 296 } 286 297 287 298 nd := types.NiceDiff{} 288 - nd.Commit.Parent = targetBranch 289 299 290 300 for _, d := range diffs { 291 301 ndiff := types.Diff{}
+13 -12
patchutil/patchutil_test.go
··· 1 1 package patchutil 2 2 3 3 import ( 4 + "errors" 4 5 "reflect" 5 6 "testing" 6 7 ) ··· 9 10 tests := []struct { 10 11 name string 11 12 patch string 12 - expected bool 13 + expected error 13 14 }{ 14 15 { 15 16 name: `empty patch`, 16 17 patch: ``, 17 - expected: false, 18 + expected: EmptyPatchError, 18 19 }, 19 20 { 20 21 name: `single line patch`, 21 22 patch: `single line`, 22 - expected: false, 23 + expected: EmptyPatchError, 23 24 }, 24 25 { 25 26 name: `valid diff patch`, ··· 31 32 -old line 32 33 +new line 33 34 context`, 34 - expected: true, 35 + expected: nil, 35 36 }, 36 37 { 37 38 name: `valid patch starting with ---`, ··· 41 42 -old line 42 43 +new line 43 44 context`, 44 - expected: true, 45 + expected: nil, 45 46 }, 46 47 { 47 48 name: `valid patch starting with Index`, ··· 53 54 -old line 54 55 +new line 55 56 context`, 56 - expected: true, 57 + expected: nil, 57 58 }, 58 59 { 59 60 name: `valid patch starting with +++`, ··· 63 64 -old line 64 65 +new line 65 66 context`, 66 - expected: true, 67 + expected: nil, 67 68 }, 68 69 { 69 70 name: `valid patch starting with @@`, ··· 72 73 +new line 73 74 context 74 75 `, 75 - expected: true, 76 + expected: nil, 76 77 }, 77 78 { 78 79 name: `valid format patch`, ··· 90 91 +new content 91 92 -- 92 93 2.48.1`, 93 - expected: true, 94 + expected: nil, 94 95 }, 95 96 { 96 97 name: `invalid format patch`, 97 98 patch: `From 1234567890123456789012345678901234567890 Mon Sep 17 00:00:00 2001 98 99 From: Author <author@example.com> 99 100 This is not a valid patch format`, 100 - expected: false, 101 + expected: FormatPatchError, 101 102 }, 102 103 { 103 104 name: `not a patch at all`, ··· 105 106 just some 106 107 random text 107 108 that isn't a patch`, 108 - expected: false, 109 + expected: GenericPatchError, 109 110 }, 110 111 } 111 112 112 113 for _, tt := range tests { 113 114 t.Run(tt.name, func(t *testing.T) { 114 115 result := IsPatchValid(tt.patch) 115 - if result != tt.expected { 116 + if !errors.Is(result, tt.expected) { 116 117 t.Errorf("IsPatchValid() = %v, want %v", result, tt.expected) 117 118 } 118 119 })
+8
rbac/rbac.go
··· 285 285 return e.E.Enforce(user, domain, repo, "repo:delete") 286 286 } 287 287 288 + func (e *Enforcer) IsRepoOwner(user, domain, repo string) (bool, error) { 289 + return e.E.Enforce(user, domain, repo, "repo:owner") 290 + } 291 + 292 + func (e *Enforcer) IsRepoCollaborator(user, domain, repo string) (bool, error) { 293 + return e.E.Enforce(user, domain, repo, "repo:collaborator") 294 + } 295 + 288 296 func (e *Enforcer) IsPushAllowed(user, domain, repo string) (bool, error) { 289 297 return e.E.Enforce(user, domain, repo, "repo:push") 290 298 }
-26
scripts/appview.sh
··· 1 - #!/bin/bash 2 - 3 - # Variables 4 - BINARY_NAME="appview" 5 - BINARY_PATH=".bin/app" 6 - SERVER="95.111.206.63" 7 - USER="appview" 8 - 9 - # SCP the binary to root's home directory 10 - scp "$BINARY_PATH" root@$SERVER:/root/"$BINARY_NAME" 11 - 12 - # SSH into the server and perform the necessary operations 13 - ssh root@$SERVER <<EOF 14 - set -e # Exit on error 15 - 16 - # Move binary to /usr/local/bin and set executable permissions 17 - mv /root/$BINARY_NAME /usr/local/bin/$BINARY_NAME 18 - chmod +x /usr/local/bin/$BINARY_NAME 19 - 20 - su appview 21 - cd ~ 22 - ./reset.sh 23 - EOF 24 - 25 - echo "Deployment complete." 26 -
-5
scripts/generate-jwks.sh
··· 1 - #! /usr/bin/env bash 2 - 3 - set -e 4 - 5 - go run ./cmd/genjwks/
+31
sets/gen.go
··· 1 + package sets 2 + 3 + import ( 4 + "math/rand" 5 + "reflect" 6 + "testing/quick" 7 + ) 8 + 9 + func (_ Set[T]) Generate(rand *rand.Rand, size int) reflect.Value { 10 + s := New[T]() 11 + 12 + var zero T 13 + itemType := reflect.TypeOf(zero) 14 + 15 + for { 16 + if s.Len() >= size { 17 + break 18 + } 19 + 20 + item, ok := quick.Value(itemType, rand) 21 + if !ok { 22 + continue 23 + } 24 + 25 + if val, ok := item.Interface().(T); ok { 26 + s.Insert(val) 27 + } 28 + } 29 + 30 + return reflect.ValueOf(s) 31 + }
+35
sets/readme.txt
··· 1 + sets 2 + ---- 3 + set datastructure for go with generics and iterators. the 4 + api is supposed to mimic rust's std::collections::HashSet api. 5 + 6 + s1 := sets.Collect(slices.Values([]int{1, 2, 3, 4})) 7 + s2 := sets.Collect(slices.Values([]int{1, 2, 3, 4, 5, 6})) 8 + 9 + union := sets.Collect(s1.Union(s2)) 10 + intersect := sets.Collect(s1.Intersection(s2)) 11 + diff := sets.Collect(s1.Difference(s2)) 12 + symdiff := sets.Collect(s1.SymmetricDifference(s2)) 13 + 14 + s1.Len() // 4 15 + s1.Contains(1) // true 16 + s1.IsEmpty() // false 17 + s1.IsSubset(s2) // true 18 + s1.IsSuperset(s2) // false 19 + s1.IsDisjoint(s2) // false 20 + 21 + if exists := s1.Insert(1); exists { 22 + // already existed in set 23 + } 24 + 25 + if existed := s1.Remove(1); existed { 26 + // existed in set, now removed 27 + } 28 + 29 + 30 + testing 31 + ------- 32 + includes property-based tests using the wonderful 33 + testing/quick module! 34 + 35 + go test -v
+174
sets/set.go
··· 1 + package sets 2 + 3 + import ( 4 + "iter" 5 + "maps" 6 + ) 7 + 8 + type Set[T comparable] struct { 9 + data map[T]struct{} 10 + } 11 + 12 + func New[T comparable]() Set[T] { 13 + return Set[T]{ 14 + data: make(map[T]struct{}), 15 + } 16 + } 17 + 18 + func (s *Set[T]) Insert(item T) bool { 19 + _, exists := s.data[item] 20 + s.data[item] = struct{}{} 21 + return !exists 22 + } 23 + 24 + func Singleton[T comparable](item T) Set[T] { 25 + n := New[T]() 26 + _ = n.Insert(item) 27 + return n 28 + } 29 + 30 + func (s *Set[T]) Remove(item T) bool { 31 + _, exists := s.data[item] 32 + if exists { 33 + delete(s.data, item) 34 + } 35 + return exists 36 + } 37 + 38 + func (s Set[T]) Contains(item T) bool { 39 + _, exists := s.data[item] 40 + return exists 41 + } 42 + 43 + func (s Set[T]) Len() int { 44 + return len(s.data) 45 + } 46 + 47 + func (s Set[T]) IsEmpty() bool { 48 + return len(s.data) == 0 49 + } 50 + 51 + func (s *Set[T]) Clear() { 52 + s.data = make(map[T]struct{}) 53 + } 54 + 55 + func (s Set[T]) All() iter.Seq[T] { 56 + return func(yield func(T) bool) { 57 + for item := range s.data { 58 + if !yield(item) { 59 + return 60 + } 61 + } 62 + } 63 + } 64 + 65 + func (s Set[T]) Clone() Set[T] { 66 + return Set[T]{ 67 + data: maps.Clone(s.data), 68 + } 69 + } 70 + 71 + func (s Set[T]) Union(other Set[T]) iter.Seq[T] { 72 + if s.Len() >= other.Len() { 73 + return chain(s.All(), other.Difference(s)) 74 + } else { 75 + return chain(other.All(), s.Difference(other)) 76 + } 77 + } 78 + 79 + func chain[T any](seqs ...iter.Seq[T]) iter.Seq[T] { 80 + return func(yield func(T) bool) { 81 + for _, seq := range seqs { 82 + for item := range seq { 83 + if !yield(item) { 84 + return 85 + } 86 + } 87 + } 88 + } 89 + } 90 + 91 + func (s Set[T]) Intersection(other Set[T]) iter.Seq[T] { 92 + return func(yield func(T) bool) { 93 + for item := range s.data { 94 + if other.Contains(item) { 95 + if !yield(item) { 96 + return 97 + } 98 + } 99 + } 100 + } 101 + } 102 + 103 + func (s Set[T]) Difference(other Set[T]) iter.Seq[T] { 104 + return func(yield func(T) bool) { 105 + for item := range s.data { 106 + if !other.Contains(item) { 107 + if !yield(item) { 108 + return 109 + } 110 + } 111 + } 112 + } 113 + } 114 + 115 + func (s Set[T]) SymmetricDifference(other Set[T]) iter.Seq[T] { 116 + return func(yield func(T) bool) { 117 + for item := range s.data { 118 + if !other.Contains(item) { 119 + if !yield(item) { 120 + return 121 + } 122 + } 123 + } 124 + for item := range other.data { 125 + if !s.Contains(item) { 126 + if !yield(item) { 127 + return 128 + } 129 + } 130 + } 131 + } 132 + } 133 + 134 + func (s Set[T]) IsSubset(other Set[T]) bool { 135 + for item := range s.data { 136 + if !other.Contains(item) { 137 + return false 138 + } 139 + } 140 + return true 141 + } 142 + 143 + func (s Set[T]) IsSuperset(other Set[T]) bool { 144 + return other.IsSubset(s) 145 + } 146 + 147 + func (s Set[T]) IsDisjoint(other Set[T]) bool { 148 + for item := range s.data { 149 + if other.Contains(item) { 150 + return false 151 + } 152 + } 153 + return true 154 + } 155 + 156 + func (s Set[T]) Equal(other Set[T]) bool { 157 + if s.Len() != other.Len() { 158 + return false 159 + } 160 + for item := range s.data { 161 + if !other.Contains(item) { 162 + return false 163 + } 164 + } 165 + return true 166 + } 167 + 168 + func Collect[T comparable](seq iter.Seq[T]) Set[T] { 169 + result := New[T]() 170 + for item := range seq { 171 + result.Insert(item) 172 + } 173 + return result 174 + }
+411
sets/set_test.go
··· 1 + package sets 2 + 3 + import ( 4 + "slices" 5 + "testing" 6 + "testing/quick" 7 + ) 8 + 9 + func TestNew(t *testing.T) { 10 + s := New[int]() 11 + if s.Len() != 0 { 12 + t.Errorf("New set should be empty, got length %d", s.Len()) 13 + } 14 + if !s.IsEmpty() { 15 + t.Error("New set should be empty") 16 + } 17 + } 18 + 19 + func TestFromSlice(t *testing.T) { 20 + s := Collect(slices.Values([]int{1, 2, 3, 2, 1})) 21 + if s.Len() != 3 { 22 + t.Errorf("Expected length 3, got %d", s.Len()) 23 + } 24 + if !s.Contains(1) || !s.Contains(2) || !s.Contains(3) { 25 + t.Error("Set should contain all unique elements from slice") 26 + } 27 + } 28 + 29 + func TestInsert(t *testing.T) { 30 + s := New[string]() 31 + 32 + if !s.Insert("hello") { 33 + t.Error("First insert should return true") 34 + } 35 + if s.Insert("hello") { 36 + t.Error("Duplicate insert should return false") 37 + } 38 + if s.Len() != 1 { 39 + t.Errorf("Expected length 1, got %d", s.Len()) 40 + } 41 + } 42 + 43 + func TestRemove(t *testing.T) { 44 + s := Collect(slices.Values([]int{1, 2, 3})) 45 + 46 + if !s.Remove(2) { 47 + t.Error("Remove existing element should return true") 48 + } 49 + if s.Remove(2) { 50 + t.Error("Remove non-existing element should return false") 51 + } 52 + if s.Contains(2) { 53 + t.Error("Element should be removed") 54 + } 55 + if s.Len() != 2 { 56 + t.Errorf("Expected length 2, got %d", s.Len()) 57 + } 58 + } 59 + 60 + func TestContains(t *testing.T) { 61 + s := Collect(slices.Values([]int{1, 2, 3})) 62 + 63 + if !s.Contains(1) { 64 + t.Error("Should contain 1") 65 + } 66 + if s.Contains(4) { 67 + t.Error("Should not contain 4") 68 + } 69 + } 70 + 71 + func TestClear(t *testing.T) { 72 + s := Collect(slices.Values([]int{1, 2, 3})) 73 + s.Clear() 74 + 75 + if !s.IsEmpty() { 76 + t.Error("Set should be empty after clear") 77 + } 78 + if s.Len() != 0 { 79 + t.Errorf("Expected length 0, got %d", s.Len()) 80 + } 81 + } 82 + 83 + func TestIterator(t *testing.T) { 84 + s := Collect(slices.Values([]int{1, 2, 3})) 85 + var items []int 86 + 87 + for item := range s.All() { 88 + items = append(items, item) 89 + } 90 + 91 + slices.Sort(items) 92 + expected := []int{1, 2, 3} 93 + if !slices.Equal(items, expected) { 94 + t.Errorf("Expected %v, got %v", expected, items) 95 + } 96 + } 97 + 98 + func TestClone(t *testing.T) { 99 + s1 := Collect(slices.Values([]int{1, 2, 3})) 100 + s2 := s1.Clone() 101 + 102 + if !s1.Equal(s2) { 103 + t.Error("Cloned set should be equal to original") 104 + } 105 + 106 + s2.Insert(4) 107 + if s1.Contains(4) { 108 + t.Error("Modifying clone should not affect original") 109 + } 110 + } 111 + 112 + func TestUnion(t *testing.T) { 113 + s1 := Collect(slices.Values([]int{1, 2})) 114 + s2 := Collect(slices.Values([]int{2, 3})) 115 + 116 + result := Collect(s1.Union(s2)) 117 + expected := Collect(slices.Values([]int{1, 2, 3})) 118 + 119 + if !result.Equal(expected) { 120 + t.Errorf("Expected %v, got %v", expected, result) 121 + } 122 + } 123 + 124 + func TestIntersection(t *testing.T) { 125 + s1 := Collect(slices.Values([]int{1, 2, 3})) 126 + s2 := Collect(slices.Values([]int{2, 3, 4})) 127 + 128 + expected := Collect(slices.Values([]int{2, 3})) 129 + result := Collect(s1.Intersection(s2)) 130 + 131 + if !result.Equal(expected) { 132 + t.Errorf("Expected %v, got %v", expected, result) 133 + } 134 + } 135 + 136 + func TestDifference(t *testing.T) { 137 + s1 := Collect(slices.Values([]int{1, 2, 3})) 138 + s2 := Collect(slices.Values([]int{2, 3, 4})) 139 + 140 + expected := Collect(slices.Values([]int{1})) 141 + result := Collect(s1.Difference(s2)) 142 + 143 + if !result.Equal(expected) { 144 + t.Errorf("Expected %v, got %v", expected, result) 145 + } 146 + } 147 + 148 + func TestSymmetricDifference(t *testing.T) { 149 + s1 := Collect(slices.Values([]int{1, 2, 3})) 150 + s2 := Collect(slices.Values([]int{2, 3, 4})) 151 + 152 + expected := Collect(slices.Values([]int{1, 4})) 153 + result := Collect(s1.SymmetricDifference(s2)) 154 + 155 + if !result.Equal(expected) { 156 + t.Errorf("Expected %v, got %v", expected, result) 157 + } 158 + } 159 + 160 + func TestSymmetricDifferenceCommutativeProperty(t *testing.T) { 161 + s1 := Collect(slices.Values([]int{1, 2, 3})) 162 + s2 := Collect(slices.Values([]int{2, 3, 4})) 163 + 164 + result1 := Collect(s1.SymmetricDifference(s2)) 165 + result2 := Collect(s2.SymmetricDifference(s1)) 166 + 167 + if !result1.Equal(result2) { 168 + t.Errorf("Expected %v, got %v", result1, result2) 169 + } 170 + } 171 + 172 + func TestIsSubset(t *testing.T) { 173 + s1 := Collect(slices.Values([]int{1, 2})) 174 + s2 := Collect(slices.Values([]int{1, 2, 3})) 175 + 176 + if !s1.IsSubset(s2) { 177 + t.Error("s1 should be subset of s2") 178 + } 179 + if s2.IsSubset(s1) { 180 + t.Error("s2 should not be subset of s1") 181 + } 182 + } 183 + 184 + func TestIsSuperset(t *testing.T) { 185 + s1 := Collect(slices.Values([]int{1, 2, 3})) 186 + s2 := Collect(slices.Values([]int{1, 2})) 187 + 188 + if !s1.IsSuperset(s2) { 189 + t.Error("s1 should be superset of s2") 190 + } 191 + if s2.IsSuperset(s1) { 192 + t.Error("s2 should not be superset of s1") 193 + } 194 + } 195 + 196 + func TestIsDisjoint(t *testing.T) { 197 + s1 := Collect(slices.Values([]int{1, 2})) 198 + s2 := Collect(slices.Values([]int{3, 4})) 199 + s3 := Collect(slices.Values([]int{2, 3})) 200 + 201 + if !s1.IsDisjoint(s2) { 202 + t.Error("s1 and s2 should be disjoint") 203 + } 204 + if s1.IsDisjoint(s3) { 205 + t.Error("s1 and s3 should not be disjoint") 206 + } 207 + } 208 + 209 + func TestEqual(t *testing.T) { 210 + s1 := Collect(slices.Values([]int{1, 2, 3})) 211 + s2 := Collect(slices.Values([]int{3, 2, 1})) 212 + s3 := Collect(slices.Values([]int{1, 2})) 213 + 214 + if !s1.Equal(s2) { 215 + t.Error("s1 and s2 should be equal") 216 + } 217 + if s1.Equal(s3) { 218 + t.Error("s1 and s3 should not be equal") 219 + } 220 + } 221 + 222 + func TestCollect(t *testing.T) { 223 + s1 := Collect(slices.Values([]int{1, 2})) 224 + s2 := Collect(slices.Values([]int{2, 3})) 225 + 226 + unionSet := Collect(s1.Union(s2)) 227 + if unionSet.Len() != 3 { 228 + t.Errorf("Expected union set length 3, got %d", unionSet.Len()) 229 + } 230 + if !unionSet.Contains(1) || !unionSet.Contains(2) || !unionSet.Contains(3) { 231 + t.Error("Union set should contain 1, 2, and 3") 232 + } 233 + 234 + diffSet := Collect(s1.Difference(s2)) 235 + if diffSet.Len() != 1 { 236 + t.Errorf("Expected difference set length 1, got %d", diffSet.Len()) 237 + } 238 + if !diffSet.Contains(1) { 239 + t.Error("Difference set should contain 1") 240 + } 241 + } 242 + 243 + func TestPropertySingleonLen(t *testing.T) { 244 + f := func(item int) bool { 245 + single := Singleton(item) 246 + return single.Len() == 1 247 + } 248 + 249 + if err := quick.Check(f, nil); err != nil { 250 + t.Error(err) 251 + } 252 + } 253 + 254 + func TestPropertyInsertIdempotent(t *testing.T) { 255 + f := func(s Set[int], item int) bool { 256 + clone := s.Clone() 257 + 258 + clone.Insert(item) 259 + firstLen := clone.Len() 260 + 261 + clone.Insert(item) 262 + secondLen := clone.Len() 263 + 264 + return firstLen == secondLen 265 + } 266 + 267 + if err := quick.Check(f, nil); err != nil { 268 + t.Error(err) 269 + } 270 + } 271 + 272 + func TestPropertyUnionCommutative(t *testing.T) { 273 + f := func(s1 Set[int], s2 Set[int]) bool { 274 + union1 := Collect(s1.Union(s2)) 275 + union2 := Collect(s2.Union(s1)) 276 + return union1.Equal(union2) 277 + } 278 + 279 + if err := quick.Check(f, nil); err != nil { 280 + t.Error(err) 281 + } 282 + } 283 + 284 + func TestPropertyIntersectionCommutative(t *testing.T) { 285 + f := func(s1 Set[int], s2 Set[int]) bool { 286 + inter1 := Collect(s1.Intersection(s2)) 287 + inter2 := Collect(s2.Intersection(s1)) 288 + return inter1.Equal(inter2) 289 + } 290 + 291 + if err := quick.Check(f, nil); err != nil { 292 + t.Error(err) 293 + } 294 + } 295 + 296 + func TestPropertyCloneEquals(t *testing.T) { 297 + f := func(s Set[int]) bool { 298 + clone := s.Clone() 299 + return s.Equal(clone) 300 + } 301 + 302 + if err := quick.Check(f, nil); err != nil { 303 + t.Error(err) 304 + } 305 + } 306 + 307 + func TestPropertyIntersectionIsSubset(t *testing.T) { 308 + f := func(s1 Set[int], s2 Set[int]) bool { 309 + inter := Collect(s1.Intersection(s2)) 310 + return inter.IsSubset(s1) && inter.IsSubset(s2) 311 + } 312 + 313 + if err := quick.Check(f, nil); err != nil { 314 + t.Error(err) 315 + } 316 + } 317 + 318 + func TestPropertyUnionIsSuperset(t *testing.T) { 319 + f := func(s1 Set[int], s2 Set[int]) bool { 320 + union := Collect(s1.Union(s2)) 321 + return union.IsSuperset(s1) && union.IsSuperset(s2) 322 + } 323 + 324 + if err := quick.Check(f, nil); err != nil { 325 + t.Error(err) 326 + } 327 + } 328 + 329 + func TestPropertyDifferenceDisjoint(t *testing.T) { 330 + f := func(s1 Set[int], s2 Set[int]) bool { 331 + diff := Collect(s1.Difference(s2)) 332 + return diff.IsDisjoint(s2) 333 + } 334 + 335 + if err := quick.Check(f, nil); err != nil { 336 + t.Error(err) 337 + } 338 + } 339 + 340 + func TestPropertySymmetricDifferenceCommutative(t *testing.T) { 341 + f := func(s1 Set[int], s2 Set[int]) bool { 342 + symDiff1 := Collect(s1.SymmetricDifference(s2)) 343 + symDiff2 := Collect(s2.SymmetricDifference(s1)) 344 + return symDiff1.Equal(symDiff2) 345 + } 346 + 347 + if err := quick.Check(f, nil); err != nil { 348 + t.Error(err) 349 + } 350 + } 351 + 352 + func TestPropertyRemoveWorks(t *testing.T) { 353 + f := func(s Set[int], item int) bool { 354 + clone := s.Clone() 355 + clone.Insert(item) 356 + clone.Remove(item) 357 + return !clone.Contains(item) 358 + } 359 + 360 + if err := quick.Check(f, nil); err != nil { 361 + t.Error(err) 362 + } 363 + } 364 + 365 + func TestPropertyClearEmpty(t *testing.T) { 366 + f := func(s Set[int]) bool { 367 + s.Clear() 368 + return s.IsEmpty() && s.Len() == 0 369 + } 370 + 371 + if err := quick.Check(f, nil); err != nil { 372 + t.Error(err) 373 + } 374 + } 375 + 376 + func TestPropertyIsSubsetReflexive(t *testing.T) { 377 + f := func(s Set[int]) bool { 378 + return s.IsSubset(s) 379 + } 380 + 381 + if err := quick.Check(f, nil); err != nil { 382 + t.Error(err) 383 + } 384 + } 385 + 386 + func TestPropertyDeMorganUnion(t *testing.T) { 387 + f := func(s1 Set[int], s2 Set[int], universe Set[int]) bool { 388 + // create a universe that contains both sets 389 + u := universe.Clone() 390 + for item := range s1.All() { 391 + u.Insert(item) 392 + } 393 + for item := range s2.All() { 394 + u.Insert(item) 395 + } 396 + 397 + // (A u B)' = A' n B' 398 + union := Collect(s1.Union(s2)) 399 + complementUnion := Collect(u.Difference(union)) 400 + 401 + complementS1 := Collect(u.Difference(s1)) 402 + complementS2 := Collect(u.Difference(s2)) 403 + intersectionComplements := Collect(complementS1.Intersection(complementS2)) 404 + 405 + return complementUnion.Equal(intersectionComplements) 406 + } 407 + 408 + if err := quick.Check(f, nil); err != nil { 409 + t.Error(err) 410 + } 411 + }
+1
spindle/config/config.go
··· 13 13 DBPath string `env:"DB_PATH, default=spindle.db"` 14 14 Hostname string `env:"HOSTNAME, required"` 15 15 JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"` 16 + PlcUrl string `env:"PLC_URL, default=https://plc.directory"` 16 17 Dev bool `env:"DEV, default=false"` 17 18 Owner string `env:"OWNER, required"` 18 19 Secrets Secrets `env:",prefix=SECRETS_"`
+1
spindle/db/repos.go
··· 16 16 if err != nil { 17 17 return nil, err 18 18 } 19 + defer rows.Close() 19 20 20 21 var knots []string 21 22 for rows.Next() {
+29 -22
spindle/engine/engine.go
··· 3 3 import ( 4 4 "context" 5 5 "errors" 6 - "fmt" 7 6 "log/slog" 7 + "sync" 8 8 9 9 securejoin "github.com/cyphar/filepath-securejoin" 10 - "golang.org/x/sync/errgroup" 11 10 "tangled.org/core/notifier" 12 11 "tangled.org/core/spindle/config" 13 12 "tangled.org/core/spindle/db" ··· 31 30 } 32 31 } 33 32 34 - eg, ctx := errgroup.WithContext(ctx) 33 + var wg sync.WaitGroup 35 34 for eng, wfs := range pipeline.Workflows { 36 35 workflowTimeout := eng.WorkflowTimeout() 37 36 l.Info("using workflow timeout", "timeout", workflowTimeout) 38 37 39 38 for _, w := range wfs { 40 - eg.Go(func() error { 39 + wg.Add(1) 40 + go func() { 41 + defer wg.Done() 42 + 41 43 wid := models.WorkflowId{ 42 44 PipelineId: pipelineId, 43 45 Name: w.Name, ··· 45 47 46 48 err := db.StatusRunning(wid, n) 47 49 if err != nil { 48 - return err 50 + l.Error("failed to set workflow status to running", "wid", wid, "err", err) 51 + return 49 52 } 50 53 51 54 err = eng.SetupWorkflow(ctx, wid, &w) ··· 61 64 62 65 dbErr := db.StatusFailed(wid, err.Error(), -1, n) 63 66 if dbErr != nil { 64 - return dbErr 67 + l.Error("failed to set workflow status to failed", "wid", wid, "err", dbErr) 65 68 } 66 - return err 69 + return 67 70 } 68 71 defer eng.DestroyWorkflow(ctx, wid) 69 72 ··· 79 82 defer cancel() 80 83 81 84 for stepIdx, step := range w.Steps { 85 + // log start of step 82 86 if wfLogger != nil { 83 - ctl := wfLogger.ControlWriter(stepIdx, step) 84 - ctl.Write([]byte(step.Name())) 87 + wfLogger. 88 + ControlWriter(stepIdx, step, models.StepStatusStart). 89 + Write([]byte{0}) 85 90 } 86 91 87 92 err = eng.RunStep(ctx, wid, &w, stepIdx, allSecrets, wfLogger) 93 + 94 + // log end of step 95 + if wfLogger != nil { 96 + wfLogger. 97 + ControlWriter(stepIdx, step, models.StepStatusEnd). 98 + Write([]byte{0}) 99 + } 100 + 88 101 if err != nil { 89 102 if errors.Is(err, ErrTimedOut) { 90 103 dbErr := db.StatusTimeout(wid, n) 91 104 if dbErr != nil { 92 - return dbErr 105 + l.Error("failed to set workflow status to timeout", "wid", wid, "err", dbErr) 93 106 } 94 107 } else { 95 108 dbErr := db.StatusFailed(wid, err.Error(), -1, n) 96 109 if dbErr != nil { 97 - return dbErr 110 + l.Error("failed to set workflow status to failed", "wid", wid, "err", dbErr) 98 111 } 99 112 } 100 - 101 - return fmt.Errorf("starting steps image: %w", err) 113 + return 102 114 } 103 115 } 104 116 105 117 err = db.StatusSuccess(wid, n) 106 118 if err != nil { 107 - return err 119 + l.Error("failed to set workflow status to success", "wid", wid, "err", err) 108 120 } 109 - 110 - return nil 111 - }) 121 + }() 112 122 } 113 123 } 114 124 115 - if err := eg.Wait(); err != nil { 116 - l.Error("failed to run one or more workflows", "err", err) 117 - } else { 118 - l.Error("successfully ran full pipeline") 119 - } 125 + wg.Wait() 126 + l.Info("all workflows completed") 120 127 }
+13 -12
spindle/engines/nixery/engine.go
··· 73 73 type addlFields struct { 74 74 image string 75 75 container string 76 - env map[string]string 77 76 } 78 77 79 78 func (e *Engine) InitWorkflow(twf tangled.Pipeline_Workflow, tpl tangled.Pipeline) (*models.Workflow, error) { ··· 103 102 swf.Steps = append(swf.Steps, sstep) 104 103 } 105 104 swf.Name = twf.Name 106 - addl.env = dwf.Environment 105 + swf.Environment = dwf.Environment 107 106 addl.image = workflowImage(dwf.Dependencies, e.cfg.NixeryPipelines.Nixery) 108 107 109 108 setup := &setupSteps{} 110 109 111 110 setup.addStep(nixConfStep()) 112 - setup.addStep(cloneStep(twf, *tpl.TriggerMetadata, e.cfg.Server.Dev)) 111 + setup.addStep(models.BuildCloneStep(twf, *tpl.TriggerMetadata, e.cfg.Server.Dev)) 113 112 // this step could be empty 114 113 if s := dependencyStep(dwf.Dependencies); s != nil { 115 114 setup.addStep(*s) ··· 222 221 }, 223 222 ReadonlyRootfs: false, 224 223 CapDrop: []string{"ALL"}, 225 - CapAdd: []string{"CAP_DAC_OVERRIDE"}, 224 + CapAdd: []string{"CAP_DAC_OVERRIDE", "CAP_CHOWN", "CAP_FOWNER", "CAP_SETUID", "CAP_SETGID"}, 226 225 SecurityOpt: []string{"no-new-privileges"}, 227 226 ExtraHosts: []string{"host.docker.internal:host-gateway"}, 228 227 }, nil, nil, "") ··· 288 287 289 288 func (e *Engine) RunStep(ctx context.Context, wid models.WorkflowId, w *models.Workflow, idx int, secrets []secrets.UnlockedSecret, wfLogger *models.WorkflowLogger) error { 290 289 addl := w.Data.(addlFields) 291 - workflowEnvs := ConstructEnvs(addl.env) 290 + workflowEnvs := ConstructEnvs(w.Environment) 292 291 // TODO(winter): should SetupWorkflow also have secret access? 293 292 // IMO yes, but probably worth thinking on. 294 293 for _, s := range secrets { 295 294 workflowEnvs.AddEnv(s.Key, s.Value) 296 295 } 297 296 298 - step := w.Steps[idx].(Step) 297 + step := w.Steps[idx] 299 298 300 299 select { 301 300 case <-ctx.Done(): ··· 304 303 } 305 304 306 305 envs := append(EnvVars(nil), workflowEnvs...) 307 - for k, v := range step.environment { 308 - envs.AddEnv(k, v) 306 + if nixStep, ok := step.(Step); ok { 307 + for k, v := range nixStep.environment { 308 + envs.AddEnv(k, v) 309 + } 309 310 } 310 311 envs.AddEnv("HOME", homeDir) 311 312 312 313 mkExecResp, err := e.docker.ContainerExecCreate(ctx, addl.container, container.ExecOptions{ 313 - Cmd: []string{"bash", "-c", step.command}, 314 + Cmd: []string{"bash", "-c", step.Command()}, 314 315 AttachStdout: true, 315 316 AttachStderr: true, 316 317 Env: envs, ··· 333 334 // Docker doesn't provide an API to kill an exec run 334 335 // (sure, we could grab the PID and kill it ourselves, 335 336 // but that's wasted effort) 336 - e.l.Warn("step timed out", "step", step.Name) 337 + e.l.Warn("step timed out", "step", step.Name()) 337 338 338 339 <-tailDone 339 340 ··· 381 382 defer logs.Close() 382 383 383 384 _, err = stdcopy.StdCopy( 384 - wfLogger.DataWriter("stdout"), 385 - wfLogger.DataWriter("stderr"), 385 + wfLogger.DataWriter(stepIdx, "stdout"), 386 + wfLogger.DataWriter(stepIdx, "stderr"), 386 387 logs.Reader, 387 388 ) 388 389 if err != nil && err != io.EOF && !errors.Is(err, context.DeadlineExceeded) {
-73
spindle/engines/nixery/setup_steps.go
··· 2 2 3 3 import ( 4 4 "fmt" 5 - "path" 6 5 "strings" 7 - 8 - "tangled.org/core/api/tangled" 9 - "tangled.org/core/workflow" 10 6 ) 11 7 12 8 func nixConfStep() Step { ··· 17 13 command: setupCmd, 18 14 name: "Configure Nix", 19 15 } 20 - } 21 - 22 - // cloneOptsAsSteps processes clone options and adds corresponding steps 23 - // to the beginning of the workflow's step list if cloning is not skipped. 24 - // 25 - // the steps to do here are: 26 - // - git init 27 - // - git remote add origin <url> 28 - // - git fetch --depth=<d> --recurse-submodules=<yes|no> <sha> 29 - // - git checkout FETCH_HEAD 30 - func cloneStep(twf tangled.Pipeline_Workflow, tr tangled.Pipeline_TriggerMetadata, dev bool) Step { 31 - if twf.Clone.Skip { 32 - return Step{} 33 - } 34 - 35 - var commands []string 36 - 37 - // initialize git repo in workspace 38 - commands = append(commands, "git init") 39 - 40 - // add repo as git remote 41 - scheme := "https://" 42 - if dev { 43 - scheme = "http://" 44 - tr.Repo.Knot = strings.ReplaceAll(tr.Repo.Knot, "localhost", "host.docker.internal") 45 - } 46 - url := scheme + path.Join(tr.Repo.Knot, tr.Repo.Did, tr.Repo.Repo) 47 - commands = append(commands, fmt.Sprintf("git remote add origin %s", url)) 48 - 49 - // run git fetch 50 - { 51 - var fetchArgs []string 52 - 53 - // default clone depth is 1 54 - depth := 1 55 - if twf.Clone.Depth > 1 { 56 - depth = int(twf.Clone.Depth) 57 - } 58 - fetchArgs = append(fetchArgs, fmt.Sprintf("--depth=%d", depth)) 59 - 60 - // optionally recurse submodules 61 - if twf.Clone.Submodules { 62 - fetchArgs = append(fetchArgs, "--recurse-submodules=yes") 63 - } 64 - 65 - // set remote to fetch from 66 - fetchArgs = append(fetchArgs, "origin") 67 - 68 - // set revision to checkout 69 - switch workflow.TriggerKind(tr.Kind) { 70 - case workflow.TriggerKindManual: 71 - // TODO: unimplemented 72 - case workflow.TriggerKindPush: 73 - fetchArgs = append(fetchArgs, tr.Push.NewSha) 74 - case workflow.TriggerKindPullRequest: 75 - fetchArgs = append(fetchArgs, tr.PullRequest.SourceSha) 76 - } 77 - 78 - commands = append(commands, fmt.Sprintf("git fetch %s", strings.Join(fetchArgs, " "))) 79 - } 80 - 81 - // run git checkout 82 - commands = append(commands, "git checkout FETCH_HEAD") 83 - 84 - cloneStep := Step{ 85 - command: strings.Join(commands, "\n"), 86 - name: "Clone repository into workspace", 87 - } 88 - return cloneStep 89 16 } 90 17 91 18 // dependencyStep processes dependencies defined in the workflow.
+3 -7
spindle/ingester.go
··· 9 9 10 10 "tangled.org/core/api/tangled" 11 11 "tangled.org/core/eventconsumer" 12 - "tangled.org/core/idresolver" 13 12 "tangled.org/core/rbac" 14 13 "tangled.org/core/spindle/db" 15 14 ··· 142 141 func (s *Spindle) ingestRepo(ctx context.Context, e *models.Event) error { 143 142 var err error 144 143 did := e.Did 145 - resolver := idresolver.DefaultResolver() 146 144 147 145 l := s.l.With("component", "ingester", "record", tangled.RepoNSID) 148 146 ··· 190 188 } 191 189 192 190 // add collaborators to rbac 193 - owner, err := resolver.ResolveIdent(ctx, did) 191 + owner, err := s.res.ResolveIdent(ctx, did) 194 192 if err != nil || owner.Handle.IsInvalidHandle() { 195 193 return err 196 194 } ··· 225 223 return err 226 224 } 227 225 228 - resolver := idresolver.DefaultResolver() 229 - 230 - subjectId, err := resolver.ResolveIdent(ctx, record.Subject) 226 + subjectId, err := s.res.ResolveIdent(ctx, record.Subject) 231 227 if err != nil || subjectId.Handle.IsInvalidHandle() { 232 228 return err 233 229 } ··· 240 236 241 237 // TODO: get rid of this entirely 242 238 // resolve this aturi to extract the repo record 243 - owner, err := resolver.ResolveIdent(ctx, repoAt.Authority().String()) 239 + owner, err := s.res.ResolveIdent(ctx, repoAt.Authority().String()) 244 240 if err != nil || owner.Handle.IsInvalidHandle() { 245 241 return fmt.Errorf("failed to resolve handle: %w", err) 246 242 }
+35
spindle/middleware.go
··· 1 + package spindle 2 + 3 + import ( 4 + "log/slog" 5 + "net/http" 6 + "time" 7 + ) 8 + 9 + func (s *Spindle) RequestLogger(next http.Handler) http.Handler { 10 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 11 + start := time.Now() 12 + 13 + next.ServeHTTP(w, r) 14 + 15 + // Build query params as slog.Attrs for the group 16 + queryParams := r.URL.Query() 17 + queryAttrs := make([]any, 0, len(queryParams)) 18 + for key, values := range queryParams { 19 + if len(values) == 1 { 20 + queryAttrs = append(queryAttrs, slog.String(key, values[0])) 21 + } else { 22 + queryAttrs = append(queryAttrs, slog.Any(key, values)) 23 + } 24 + } 25 + 26 + s.l.LogAttrs(r.Context(), slog.LevelInfo, "", 27 + slog.Group("request", 28 + slog.String("method", r.Method), 29 + slog.String("path", r.URL.Path), 30 + slog.Group("query", queryAttrs...), 31 + slog.Duration("duration", time.Since(start)), 32 + ), 33 + ) 34 + }) 35 + }
+150
spindle/models/clone.go
··· 1 + package models 2 + 3 + import ( 4 + "fmt" 5 + "strings" 6 + 7 + "tangled.org/core/api/tangled" 8 + "tangled.org/core/workflow" 9 + ) 10 + 11 + type CloneStep struct { 12 + name string 13 + kind StepKind 14 + commands []string 15 + } 16 + 17 + func (s CloneStep) Name() string { 18 + return s.name 19 + } 20 + 21 + func (s CloneStep) Commands() []string { 22 + return s.commands 23 + } 24 + 25 + func (s CloneStep) Command() string { 26 + return strings.Join(s.commands, "\n") 27 + } 28 + 29 + func (s CloneStep) Kind() StepKind { 30 + return s.kind 31 + } 32 + 33 + // BuildCloneStep generates git clone commands. 34 + // The caller must ensure the current working directory is set to the desired 35 + // workspace directory before executing these commands. 36 + // 37 + // The generated commands are: 38 + // - git init 39 + // - git remote add origin <url> 40 + // - git fetch --depth=<d> --recurse-submodules=<yes|no> <sha> 41 + // - git checkout FETCH_HEAD 42 + // 43 + // Supports all trigger types (push, PR, manual) and clone options. 44 + func BuildCloneStep(twf tangled.Pipeline_Workflow, tr tangled.Pipeline_TriggerMetadata, dev bool) CloneStep { 45 + if twf.Clone != nil && twf.Clone.Skip { 46 + return CloneStep{} 47 + } 48 + 49 + commitSHA, err := extractCommitSHA(tr) 50 + if err != nil { 51 + return CloneStep{ 52 + kind: StepKindSystem, 53 + name: "Clone repository into workspace (error)", 54 + commands: []string{fmt.Sprintf("echo 'Failed to get clone info: %s' && exit 1", err.Error())}, 55 + } 56 + } 57 + 58 + repoURL := BuildRepoURL(tr.Repo, dev) 59 + 60 + var cloneOpts tangled.Pipeline_CloneOpts 61 + if twf.Clone != nil { 62 + cloneOpts = *twf.Clone 63 + } 64 + fetchArgs := buildFetchArgs(cloneOpts, commitSHA) 65 + 66 + return CloneStep{ 67 + kind: StepKindSystem, 68 + name: "Clone repository into workspace", 69 + commands: []string{ 70 + "git init", 71 + fmt.Sprintf("git remote add origin %s", repoURL), 72 + fmt.Sprintf("git fetch %s", strings.Join(fetchArgs, " ")), 73 + "git checkout FETCH_HEAD", 74 + }, 75 + } 76 + } 77 + 78 + // extractCommitSHA extracts the commit SHA from trigger metadata based on trigger type 79 + func extractCommitSHA(tr tangled.Pipeline_TriggerMetadata) (string, error) { 80 + switch workflow.TriggerKind(tr.Kind) { 81 + case workflow.TriggerKindPush: 82 + if tr.Push == nil { 83 + return "", fmt.Errorf("push trigger metadata is nil") 84 + } 85 + return tr.Push.NewSha, nil 86 + 87 + case workflow.TriggerKindPullRequest: 88 + if tr.PullRequest == nil { 89 + return "", fmt.Errorf("pull request trigger metadata is nil") 90 + } 91 + return tr.PullRequest.SourceSha, nil 92 + 93 + case workflow.TriggerKindManual: 94 + // Manual triggers don't have an explicit SHA in the metadata 95 + // For now, return empty string - could be enhanced to fetch from default branch 96 + // TODO: Implement manual trigger SHA resolution (fetch default branch HEAD) 97 + return "", nil 98 + 99 + default: 100 + return "", fmt.Errorf("unknown trigger kind: %s", tr.Kind) 101 + } 102 + } 103 + 104 + // BuildRepoURL constructs the repository URL from repo metadata. 105 + func BuildRepoURL(repo *tangled.Pipeline_TriggerRepo, devMode bool) string { 106 + if repo == nil { 107 + return "" 108 + } 109 + 110 + scheme := "https://" 111 + if devMode { 112 + scheme = "http://" 113 + } 114 + 115 + // Get host from knot 116 + host := repo.Knot 117 + 118 + // In dev mode, replace localhost with host.docker.internal for Docker networking 119 + if devMode && strings.Contains(host, "localhost") { 120 + host = strings.ReplaceAll(host, "localhost", "host.docker.internal") 121 + } 122 + 123 + // Build URL: {scheme}{knot}/{did}/{repo} 124 + return fmt.Sprintf("%s%s/%s/%s", scheme, host, repo.Did, repo.Repo) 125 + } 126 + 127 + // buildFetchArgs constructs the arguments for git fetch based on clone options 128 + func buildFetchArgs(clone tangled.Pipeline_CloneOpts, sha string) []string { 129 + args := []string{} 130 + 131 + // Set fetch depth (default to 1 for shallow clone) 132 + depth := clone.Depth 133 + if depth == 0 { 134 + depth = 1 135 + } 136 + args = append(args, fmt.Sprintf("--depth=%d", depth)) 137 + 138 + // Add submodules if requested 139 + if clone.Submodules { 140 + args = append(args, "--recurse-submodules=yes") 141 + } 142 + 143 + // Add remote and SHA 144 + args = append(args, "origin") 145 + if sha != "" { 146 + args = append(args, sha) 147 + } 148 + 149 + return args 150 + }
+371
spindle/models/clone_test.go
··· 1 + package models 2 + 3 + import ( 4 + "strings" 5 + "testing" 6 + 7 + "tangled.org/core/api/tangled" 8 + "tangled.org/core/workflow" 9 + ) 10 + 11 + func TestBuildCloneStep_PushTrigger(t *testing.T) { 12 + twf := tangled.Pipeline_Workflow{ 13 + Clone: &tangled.Pipeline_CloneOpts{ 14 + Depth: 1, 15 + Submodules: false, 16 + Skip: false, 17 + }, 18 + } 19 + tr := tangled.Pipeline_TriggerMetadata{ 20 + Kind: string(workflow.TriggerKindPush), 21 + Push: &tangled.Pipeline_PushTriggerData{ 22 + NewSha: "abc123", 23 + OldSha: "def456", 24 + Ref: "refs/heads/main", 25 + }, 26 + Repo: &tangled.Pipeline_TriggerRepo{ 27 + Knot: "example.com", 28 + Did: "did:plc:user123", 29 + Repo: "my-repo", 30 + }, 31 + } 32 + 33 + step := BuildCloneStep(twf, tr, false) 34 + 35 + if step.Kind() != StepKindSystem { 36 + t.Errorf("Expected StepKindSystem, got %v", step.Kind()) 37 + } 38 + 39 + if step.Name() != "Clone repository into workspace" { 40 + t.Errorf("Expected 'Clone repository into workspace', got '%s'", step.Name()) 41 + } 42 + 43 + commands := step.Commands() 44 + if len(commands) != 4 { 45 + t.Errorf("Expected 4 commands, got %d", len(commands)) 46 + } 47 + 48 + // Verify commands contain expected git operations 49 + allCmds := strings.Join(commands, " ") 50 + if !strings.Contains(allCmds, "git init") { 51 + t.Error("Commands should contain 'git init'") 52 + } 53 + if !strings.Contains(allCmds, "git remote add origin") { 54 + t.Error("Commands should contain 'git remote add origin'") 55 + } 56 + if !strings.Contains(allCmds, "git fetch") { 57 + t.Error("Commands should contain 'git fetch'") 58 + } 59 + if !strings.Contains(allCmds, "abc123") { 60 + t.Error("Commands should contain commit SHA") 61 + } 62 + if !strings.Contains(allCmds, "git checkout FETCH_HEAD") { 63 + t.Error("Commands should contain 'git checkout FETCH_HEAD'") 64 + } 65 + if !strings.Contains(allCmds, "https://example.com/did:plc:user123/my-repo") { 66 + t.Error("Commands should contain expected repo URL") 67 + } 68 + } 69 + 70 + func TestBuildCloneStep_PullRequestTrigger(t *testing.T) { 71 + twf := tangled.Pipeline_Workflow{ 72 + Clone: &tangled.Pipeline_CloneOpts{ 73 + Depth: 1, 74 + Skip: false, 75 + }, 76 + } 77 + tr := tangled.Pipeline_TriggerMetadata{ 78 + Kind: string(workflow.TriggerKindPullRequest), 79 + PullRequest: &tangled.Pipeline_PullRequestTriggerData{ 80 + SourceSha: "pr-sha-789", 81 + SourceBranch: "feature-branch", 82 + TargetBranch: "main", 83 + Action: "opened", 84 + }, 85 + Repo: &tangled.Pipeline_TriggerRepo{ 86 + Knot: "example.com", 87 + Did: "did:plc:user123", 88 + Repo: "my-repo", 89 + }, 90 + } 91 + 92 + step := BuildCloneStep(twf, tr, false) 93 + 94 + allCmds := strings.Join(step.Commands(), " ") 95 + if !strings.Contains(allCmds, "pr-sha-789") { 96 + t.Error("Commands should contain PR commit SHA") 97 + } 98 + } 99 + 100 + func TestBuildCloneStep_ManualTrigger(t *testing.T) { 101 + twf := tangled.Pipeline_Workflow{ 102 + Clone: &tangled.Pipeline_CloneOpts{ 103 + Depth: 1, 104 + Skip: false, 105 + }, 106 + } 107 + tr := tangled.Pipeline_TriggerMetadata{ 108 + Kind: string(workflow.TriggerKindManual), 109 + Manual: &tangled.Pipeline_ManualTriggerData{ 110 + Inputs: nil, 111 + }, 112 + Repo: &tangled.Pipeline_TriggerRepo{ 113 + Knot: "example.com", 114 + Did: "did:plc:user123", 115 + Repo: "my-repo", 116 + }, 117 + } 118 + 119 + step := BuildCloneStep(twf, tr, false) 120 + 121 + // Manual triggers don't have a SHA yet (TODO), so git fetch won't include a SHA 122 + allCmds := strings.Join(step.Commands(), " ") 123 + // Should still have basic git commands 124 + if !strings.Contains(allCmds, "git init") { 125 + t.Error("Commands should contain 'git init'") 126 + } 127 + if !strings.Contains(allCmds, "git fetch") { 128 + t.Error("Commands should contain 'git fetch'") 129 + } 130 + } 131 + 132 + func TestBuildCloneStep_SkipFlag(t *testing.T) { 133 + twf := tangled.Pipeline_Workflow{ 134 + Clone: &tangled.Pipeline_CloneOpts{ 135 + Skip: true, 136 + }, 137 + } 138 + tr := tangled.Pipeline_TriggerMetadata{ 139 + Kind: string(workflow.TriggerKindPush), 140 + Push: &tangled.Pipeline_PushTriggerData{ 141 + NewSha: "abc123", 142 + }, 143 + Repo: &tangled.Pipeline_TriggerRepo{ 144 + Knot: "example.com", 145 + Did: "did:plc:user123", 146 + Repo: "my-repo", 147 + }, 148 + } 149 + 150 + step := BuildCloneStep(twf, tr, false) 151 + 152 + // Empty step when skip is true 153 + if step.Name() != "" { 154 + t.Error("Expected empty step name when Skip is true") 155 + } 156 + if len(step.Commands()) != 0 { 157 + t.Errorf("Expected no commands when Skip is true, got %d commands", len(step.Commands())) 158 + } 159 + } 160 + 161 + func TestBuildCloneStep_DevMode(t *testing.T) { 162 + twf := tangled.Pipeline_Workflow{ 163 + Clone: &tangled.Pipeline_CloneOpts{ 164 + Depth: 1, 165 + Skip: false, 166 + }, 167 + } 168 + tr := tangled.Pipeline_TriggerMetadata{ 169 + Kind: string(workflow.TriggerKindPush), 170 + Push: &tangled.Pipeline_PushTriggerData{ 171 + NewSha: "abc123", 172 + }, 173 + Repo: &tangled.Pipeline_TriggerRepo{ 174 + Knot: "localhost:3000", 175 + Did: "did:plc:user123", 176 + Repo: "my-repo", 177 + }, 178 + } 179 + 180 + step := BuildCloneStep(twf, tr, true) 181 + 182 + // In dev mode, should use http:// and replace localhost with host.docker.internal 183 + allCmds := strings.Join(step.Commands(), " ") 184 + expectedURL := "http://host.docker.internal:3000/did:plc:user123/my-repo" 185 + if !strings.Contains(allCmds, expectedURL) { 186 + t.Errorf("Expected dev mode URL '%s' in commands", expectedURL) 187 + } 188 + } 189 + 190 + func TestBuildCloneStep_DepthAndSubmodules(t *testing.T) { 191 + twf := tangled.Pipeline_Workflow{ 192 + Clone: &tangled.Pipeline_CloneOpts{ 193 + Depth: 10, 194 + Submodules: true, 195 + Skip: false, 196 + }, 197 + } 198 + tr := tangled.Pipeline_TriggerMetadata{ 199 + Kind: string(workflow.TriggerKindPush), 200 + Push: &tangled.Pipeline_PushTriggerData{ 201 + NewSha: "abc123", 202 + }, 203 + Repo: &tangled.Pipeline_TriggerRepo{ 204 + Knot: "example.com", 205 + Did: "did:plc:user123", 206 + Repo: "my-repo", 207 + }, 208 + } 209 + 210 + step := BuildCloneStep(twf, tr, false) 211 + 212 + allCmds := strings.Join(step.Commands(), " ") 213 + if !strings.Contains(allCmds, "--depth=10") { 214 + t.Error("Commands should contain '--depth=10'") 215 + } 216 + 217 + if !strings.Contains(allCmds, "--recurse-submodules=yes") { 218 + t.Error("Commands should contain '--recurse-submodules=yes'") 219 + } 220 + } 221 + 222 + func TestBuildCloneStep_DefaultDepth(t *testing.T) { 223 + twf := tangled.Pipeline_Workflow{ 224 + Clone: &tangled.Pipeline_CloneOpts{ 225 + Depth: 0, // Default should be 1 226 + Skip: false, 227 + }, 228 + } 229 + tr := tangled.Pipeline_TriggerMetadata{ 230 + Kind: string(workflow.TriggerKindPush), 231 + Push: &tangled.Pipeline_PushTriggerData{ 232 + NewSha: "abc123", 233 + }, 234 + Repo: &tangled.Pipeline_TriggerRepo{ 235 + Knot: "example.com", 236 + Did: "did:plc:user123", 237 + Repo: "my-repo", 238 + }, 239 + } 240 + 241 + step := BuildCloneStep(twf, tr, false) 242 + 243 + allCmds := strings.Join(step.Commands(), " ") 244 + if !strings.Contains(allCmds, "--depth=1") { 245 + t.Error("Commands should default to '--depth=1'") 246 + } 247 + } 248 + 249 + func TestBuildCloneStep_NilPushData(t *testing.T) { 250 + twf := tangled.Pipeline_Workflow{ 251 + Clone: &tangled.Pipeline_CloneOpts{ 252 + Depth: 1, 253 + Skip: false, 254 + }, 255 + } 256 + tr := tangled.Pipeline_TriggerMetadata{ 257 + Kind: string(workflow.TriggerKindPush), 258 + Push: nil, // Nil push data should create error step 259 + Repo: &tangled.Pipeline_TriggerRepo{ 260 + Knot: "example.com", 261 + Did: "did:plc:user123", 262 + Repo: "my-repo", 263 + }, 264 + } 265 + 266 + step := BuildCloneStep(twf, tr, false) 267 + 268 + // Should return an error step 269 + if !strings.Contains(step.Name(), "error") { 270 + t.Error("Expected error in step name when push data is nil") 271 + } 272 + 273 + allCmds := strings.Join(step.Commands(), " ") 274 + if !strings.Contains(allCmds, "Failed to get clone info") { 275 + t.Error("Commands should contain error message") 276 + } 277 + if !strings.Contains(allCmds, "exit 1") { 278 + t.Error("Commands should exit with error") 279 + } 280 + } 281 + 282 + func TestBuildCloneStep_NilPRData(t *testing.T) { 283 + twf := tangled.Pipeline_Workflow{ 284 + Clone: &tangled.Pipeline_CloneOpts{ 285 + Depth: 1, 286 + Skip: false, 287 + }, 288 + } 289 + tr := tangled.Pipeline_TriggerMetadata{ 290 + Kind: string(workflow.TriggerKindPullRequest), 291 + PullRequest: nil, // Nil PR data should create error step 292 + Repo: &tangled.Pipeline_TriggerRepo{ 293 + Knot: "example.com", 294 + Did: "did:plc:user123", 295 + Repo: "my-repo", 296 + }, 297 + } 298 + 299 + step := BuildCloneStep(twf, tr, false) 300 + 301 + // Should return an error step 302 + if !strings.Contains(step.Name(), "error") { 303 + t.Error("Expected error in step name when pull request data is nil") 304 + } 305 + 306 + allCmds := strings.Join(step.Commands(), " ") 307 + if !strings.Contains(allCmds, "Failed to get clone info") { 308 + t.Error("Commands should contain error message") 309 + } 310 + } 311 + 312 + func TestBuildCloneStep_UnknownTriggerKind(t *testing.T) { 313 + twf := tangled.Pipeline_Workflow{ 314 + Clone: &tangled.Pipeline_CloneOpts{ 315 + Depth: 1, 316 + Skip: false, 317 + }, 318 + } 319 + tr := tangled.Pipeline_TriggerMetadata{ 320 + Kind: "unknown_trigger", 321 + Repo: &tangled.Pipeline_TriggerRepo{ 322 + Knot: "example.com", 323 + Did: "did:plc:user123", 324 + Repo: "my-repo", 325 + }, 326 + } 327 + 328 + step := BuildCloneStep(twf, tr, false) 329 + 330 + // Should return an error step 331 + if !strings.Contains(step.Name(), "error") { 332 + t.Error("Expected error in step name for unknown trigger kind") 333 + } 334 + 335 + allCmds := strings.Join(step.Commands(), " ") 336 + if !strings.Contains(allCmds, "unknown trigger kind") { 337 + t.Error("Commands should contain error message about unknown trigger kind") 338 + } 339 + } 340 + 341 + func TestBuildCloneStep_NilCloneOpts(t *testing.T) { 342 + twf := tangled.Pipeline_Workflow{ 343 + Clone: nil, // Nil clone options should use defaults 344 + } 345 + tr := tangled.Pipeline_TriggerMetadata{ 346 + Kind: string(workflow.TriggerKindPush), 347 + Push: &tangled.Pipeline_PushTriggerData{ 348 + NewSha: "abc123", 349 + }, 350 + Repo: &tangled.Pipeline_TriggerRepo{ 351 + Knot: "example.com", 352 + Did: "did:plc:user123", 353 + Repo: "my-repo", 354 + }, 355 + } 356 + 357 + step := BuildCloneStep(twf, tr, false) 358 + 359 + // Should still work with default options 360 + if step.Kind() != StepKindSystem { 361 + t.Errorf("Expected StepKindSystem, got %v", step.Kind()) 362 + } 363 + 364 + allCmds := strings.Join(step.Commands(), " ") 365 + if !strings.Contains(allCmds, "--depth=1") { 366 + t.Error("Commands should default to '--depth=1' when Clone is nil") 367 + } 368 + if !strings.Contains(allCmds, "git init") { 369 + t.Error("Commands should contain 'git init'") 370 + } 371 + }
+14 -11
spindle/models/logger.go
··· 37 37 return l.file.Close() 38 38 } 39 39 40 - func (l *WorkflowLogger) DataWriter(stream string) io.Writer { 41 - // TODO: emit stream 40 + func (l *WorkflowLogger) DataWriter(idx int, stream string) io.Writer { 42 41 return &dataWriter{ 43 42 logger: l, 43 + idx: idx, 44 44 stream: stream, 45 45 } 46 46 } 47 47 48 - func (l *WorkflowLogger) ControlWriter(idx int, step Step) io.Writer { 48 + func (l *WorkflowLogger) ControlWriter(idx int, step Step, stepStatus StepStatus) io.Writer { 49 49 return &controlWriter{ 50 - logger: l, 51 - idx: idx, 52 - step: step, 50 + logger: l, 51 + idx: idx, 52 + step: step, 53 + stepStatus: stepStatus, 53 54 } 54 55 } 55 56 56 57 type dataWriter struct { 57 58 logger *WorkflowLogger 59 + idx int 58 60 stream string 59 61 } 60 62 61 63 func (w *dataWriter) Write(p []byte) (int, error) { 62 64 line := strings.TrimRight(string(p), "\r\n") 63 - entry := NewDataLogLine(line, w.stream) 65 + entry := NewDataLogLine(w.idx, line, w.stream) 64 66 if err := w.logger.encoder.Encode(entry); err != nil { 65 67 return 0, err 66 68 } ··· 68 70 } 69 71 70 72 type controlWriter struct { 71 - logger *WorkflowLogger 72 - idx int 73 - step Step 73 + logger *WorkflowLogger 74 + idx int 75 + step Step 76 + stepStatus StepStatus 74 77 } 75 78 76 79 func (w *controlWriter) Write(_ []byte) (int, error) { 77 - entry := NewControlLogLine(w.idx, w.step) 80 + entry := NewControlLogLine(w.idx, w.step, w.stepStatus) 78 81 if err := w.logger.encoder.Encode(entry); err != nil { 79 82 return 0, err 80 83 }
+23 -8
spindle/models/models.go
··· 4 4 "fmt" 5 5 "regexp" 6 6 "slices" 7 + "time" 7 8 8 9 "tangled.org/core/api/tangled" 9 10 ··· 76 77 var ( 77 78 // step log data 78 79 LogKindData LogKind = "data" 79 - // indicates start/end of a step 80 + // indicates status of a step 80 81 LogKindControl LogKind = "control" 81 82 ) 82 83 84 + // step status indicator in control log lines 85 + type StepStatus string 86 + 87 + var ( 88 + StepStatusStart StepStatus = "start" 89 + StepStatusEnd StepStatus = "end" 90 + ) 91 + 83 92 type LogLine struct { 84 - Kind LogKind `json:"kind"` 85 - Content string `json:"content"` 93 + Kind LogKind `json:"kind"` 94 + Content string `json:"content"` 95 + Time time.Time `json:"time"` 96 + StepId int `json:"step_id"` 86 97 87 98 // fields if kind is "data" 88 99 Stream string `json:"stream,omitempty"` 89 100 90 101 // fields if kind is "control" 91 - StepId int `json:"step_id,omitempty"` 92 - StepKind StepKind `json:"step_kind,omitempty"` 93 - StepCommand string `json:"step_command,omitempty"` 102 + StepStatus StepStatus `json:"step_status,omitempty"` 103 + StepKind StepKind `json:"step_kind,omitempty"` 104 + StepCommand string `json:"step_command,omitempty"` 94 105 } 95 106 96 - func NewDataLogLine(content, stream string) LogLine { 107 + func NewDataLogLine(idx int, content, stream string) LogLine { 97 108 return LogLine{ 98 109 Kind: LogKindData, 110 + Time: time.Now(), 99 111 Content: content, 112 + StepId: idx, 100 113 Stream: stream, 101 114 } 102 115 } 103 116 104 - func NewControlLogLine(idx int, step Step) LogLine { 117 + func NewControlLogLine(idx int, step Step, status StepStatus) LogLine { 105 118 return LogLine{ 106 119 Kind: LogKindControl, 120 + Time: time.Now(), 107 121 Content: step.Name(), 108 122 StepId: idx, 123 + StepStatus: status, 109 124 StepKind: step.Kind(), 110 125 StepCommand: step.Command(), 111 126 }
+4 -3
spindle/models/pipeline.go
··· 22 22 ) 23 23 24 24 type Workflow struct { 25 - Steps []Step 26 - Name string 27 - Data any 25 + Steps []Step 26 + Name string 27 + Data any 28 + Environment map[string]string 28 29 }
+77
spindle/models/pipeline_env.go
··· 1 + package models 2 + 3 + import ( 4 + "strings" 5 + 6 + "github.com/go-git/go-git/v5/plumbing" 7 + "tangled.org/core/api/tangled" 8 + "tangled.org/core/workflow" 9 + ) 10 + 11 + // PipelineEnvVars extracts environment variables from pipeline trigger metadata. 12 + // These are framework-provided variables that are injected into workflow steps. 13 + func PipelineEnvVars(tr *tangled.Pipeline_TriggerMetadata, pipelineId PipelineId, devMode bool) map[string]string { 14 + if tr == nil { 15 + return nil 16 + } 17 + 18 + env := make(map[string]string) 19 + 20 + // Standard CI environment variable 21 + env["CI"] = "true" 22 + 23 + env["TANGLED_PIPELINE_ID"] = pipelineId.Rkey 24 + 25 + // Repo info 26 + if tr.Repo != nil { 27 + env["TANGLED_REPO_KNOT"] = tr.Repo.Knot 28 + env["TANGLED_REPO_DID"] = tr.Repo.Did 29 + env["TANGLED_REPO_NAME"] = tr.Repo.Repo 30 + env["TANGLED_REPO_DEFAULT_BRANCH"] = tr.Repo.DefaultBranch 31 + env["TANGLED_REPO_URL"] = BuildRepoURL(tr.Repo, devMode) 32 + } 33 + 34 + switch workflow.TriggerKind(tr.Kind) { 35 + case workflow.TriggerKindPush: 36 + if tr.Push != nil { 37 + refName := plumbing.ReferenceName(tr.Push.Ref) 38 + refType := "branch" 39 + if refName.IsTag() { 40 + refType = "tag" 41 + } 42 + 43 + env["TANGLED_REF"] = tr.Push.Ref 44 + env["TANGLED_REF_NAME"] = refName.Short() 45 + env["TANGLED_REF_TYPE"] = refType 46 + env["TANGLED_SHA"] = tr.Push.NewSha 47 + env["TANGLED_COMMIT_SHA"] = tr.Push.NewSha 48 + } 49 + 50 + case workflow.TriggerKindPullRequest: 51 + if tr.PullRequest != nil { 52 + // For PRs, the "ref" is the source branch 53 + env["TANGLED_REF"] = "refs/heads/" + tr.PullRequest.SourceBranch 54 + env["TANGLED_REF_NAME"] = tr.PullRequest.SourceBranch 55 + env["TANGLED_REF_TYPE"] = "branch" 56 + env["TANGLED_SHA"] = tr.PullRequest.SourceSha 57 + env["TANGLED_COMMIT_SHA"] = tr.PullRequest.SourceSha 58 + 59 + // PR-specific variables 60 + env["TANGLED_PR_SOURCE_BRANCH"] = tr.PullRequest.SourceBranch 61 + env["TANGLED_PR_TARGET_BRANCH"] = tr.PullRequest.TargetBranch 62 + env["TANGLED_PR_SOURCE_SHA"] = tr.PullRequest.SourceSha 63 + env["TANGLED_PR_ACTION"] = tr.PullRequest.Action 64 + } 65 + 66 + case workflow.TriggerKindManual: 67 + // Manual triggers may not have ref/sha info 68 + // Include any manual inputs if present 69 + if tr.Manual != nil { 70 + for _, pair := range tr.Manual.Inputs { 71 + env["TANGLED_INPUT_"+strings.ToUpper(pair.Key)] = pair.Value 72 + } 73 + } 74 + } 75 + 76 + return env 77 + }
+260
spindle/models/pipeline_env_test.go
··· 1 + package models 2 + 3 + import ( 4 + "testing" 5 + 6 + "tangled.org/core/api/tangled" 7 + "tangled.org/core/workflow" 8 + ) 9 + 10 + func TestPipelineEnvVars_PushBranch(t *testing.T) { 11 + tr := &tangled.Pipeline_TriggerMetadata{ 12 + Kind: string(workflow.TriggerKindPush), 13 + Push: &tangled.Pipeline_PushTriggerData{ 14 + NewSha: "abc123def456", 15 + OldSha: "000000000000", 16 + Ref: "refs/heads/main", 17 + }, 18 + Repo: &tangled.Pipeline_TriggerRepo{ 19 + Knot: "example.com", 20 + Did: "did:plc:user123", 21 + Repo: "my-repo", 22 + DefaultBranch: "main", 23 + }, 24 + } 25 + id := PipelineId{ 26 + Knot: "example.com", 27 + Rkey: "123123", 28 + } 29 + env := PipelineEnvVars(tr, id, false) 30 + 31 + // Check standard CI variable 32 + if env["CI"] != "true" { 33 + t.Errorf("Expected CI='true', got '%s'", env["CI"]) 34 + } 35 + 36 + // Check ref variables 37 + if env["TANGLED_REF"] != "refs/heads/main" { 38 + t.Errorf("Expected TANGLED_REF='refs/heads/main', got '%s'", env["TANGLED_REF"]) 39 + } 40 + if env["TANGLED_REF_NAME"] != "main" { 41 + t.Errorf("Expected TANGLED_REF_NAME='main', got '%s'", env["TANGLED_REF_NAME"]) 42 + } 43 + if env["TANGLED_REF_TYPE"] != "branch" { 44 + t.Errorf("Expected TANGLED_REF_TYPE='branch', got '%s'", env["TANGLED_REF_TYPE"]) 45 + } 46 + 47 + // Check SHA variables 48 + if env["TANGLED_SHA"] != "abc123def456" { 49 + t.Errorf("Expected TANGLED_SHA='abc123def456', got '%s'", env["TANGLED_SHA"]) 50 + } 51 + if env["TANGLED_COMMIT_SHA"] != "abc123def456" { 52 + t.Errorf("Expected TANGLED_COMMIT_SHA='abc123def456', got '%s'", env["TANGLED_COMMIT_SHA"]) 53 + } 54 + 55 + // Check repo variables 56 + if env["TANGLED_REPO_KNOT"] != "example.com" { 57 + t.Errorf("Expected TANGLED_REPO_KNOT='example.com', got '%s'", env["TANGLED_REPO_KNOT"]) 58 + } 59 + if env["TANGLED_REPO_DID"] != "did:plc:user123" { 60 + t.Errorf("Expected TANGLED_REPO_DID='did:plc:user123', got '%s'", env["TANGLED_REPO_DID"]) 61 + } 62 + if env["TANGLED_REPO_NAME"] != "my-repo" { 63 + t.Errorf("Expected TANGLED_REPO_NAME='my-repo', got '%s'", env["TANGLED_REPO_NAME"]) 64 + } 65 + if env["TANGLED_REPO_DEFAULT_BRANCH"] != "main" { 66 + t.Errorf("Expected TANGLED_REPO_DEFAULT_BRANCH='main', got '%s'", env["TANGLED_REPO_DEFAULT_BRANCH"]) 67 + } 68 + if env["TANGLED_REPO_URL"] != "https://example.com/did:plc:user123/my-repo" { 69 + t.Errorf("Expected TANGLED_REPO_URL='https://example.com/did:plc:user123/my-repo', got '%s'", env["TANGLED_REPO_URL"]) 70 + } 71 + } 72 + 73 + func TestPipelineEnvVars_PushTag(t *testing.T) { 74 + tr := &tangled.Pipeline_TriggerMetadata{ 75 + Kind: string(workflow.TriggerKindPush), 76 + Push: &tangled.Pipeline_PushTriggerData{ 77 + NewSha: "abc123def456", 78 + OldSha: "000000000000", 79 + Ref: "refs/tags/v1.2.3", 80 + }, 81 + Repo: &tangled.Pipeline_TriggerRepo{ 82 + Knot: "example.com", 83 + Did: "did:plc:user123", 84 + Repo: "my-repo", 85 + }, 86 + } 87 + id := PipelineId{ 88 + Knot: "example.com", 89 + Rkey: "123123", 90 + } 91 + env := PipelineEnvVars(tr, id, false) 92 + 93 + if env["TANGLED_REF"] != "refs/tags/v1.2.3" { 94 + t.Errorf("Expected TANGLED_REF='refs/tags/v1.2.3', got '%s'", env["TANGLED_REF"]) 95 + } 96 + if env["TANGLED_REF_NAME"] != "v1.2.3" { 97 + t.Errorf("Expected TANGLED_REF_NAME='v1.2.3', got '%s'", env["TANGLED_REF_NAME"]) 98 + } 99 + if env["TANGLED_REF_TYPE"] != "tag" { 100 + t.Errorf("Expected TANGLED_REF_TYPE='tag', got '%s'", env["TANGLED_REF_TYPE"]) 101 + } 102 + } 103 + 104 + func TestPipelineEnvVars_PullRequest(t *testing.T) { 105 + tr := &tangled.Pipeline_TriggerMetadata{ 106 + Kind: string(workflow.TriggerKindPullRequest), 107 + PullRequest: &tangled.Pipeline_PullRequestTriggerData{ 108 + SourceBranch: "feature-branch", 109 + TargetBranch: "main", 110 + SourceSha: "pr-sha-789", 111 + Action: "opened", 112 + }, 113 + Repo: &tangled.Pipeline_TriggerRepo{ 114 + Knot: "example.com", 115 + Did: "did:plc:user123", 116 + Repo: "my-repo", 117 + }, 118 + } 119 + id := PipelineId{ 120 + Knot: "example.com", 121 + Rkey: "123123", 122 + } 123 + env := PipelineEnvVars(tr, id, false) 124 + 125 + // Check ref variables for PR 126 + if env["TANGLED_REF"] != "refs/heads/feature-branch" { 127 + t.Errorf("Expected TANGLED_REF='refs/heads/feature-branch', got '%s'", env["TANGLED_REF"]) 128 + } 129 + if env["TANGLED_REF_NAME"] != "feature-branch" { 130 + t.Errorf("Expected TANGLED_REF_NAME='feature-branch', got '%s'", env["TANGLED_REF_NAME"]) 131 + } 132 + if env["TANGLED_REF_TYPE"] != "branch" { 133 + t.Errorf("Expected TANGLED_REF_TYPE='branch', got '%s'", env["TANGLED_REF_TYPE"]) 134 + } 135 + 136 + // Check SHA variables 137 + if env["TANGLED_SHA"] != "pr-sha-789" { 138 + t.Errorf("Expected TANGLED_SHA='pr-sha-789', got '%s'", env["TANGLED_SHA"]) 139 + } 140 + if env["TANGLED_COMMIT_SHA"] != "pr-sha-789" { 141 + t.Errorf("Expected TANGLED_COMMIT_SHA='pr-sha-789', got '%s'", env["TANGLED_COMMIT_SHA"]) 142 + } 143 + 144 + // Check PR-specific variables 145 + if env["TANGLED_PR_SOURCE_BRANCH"] != "feature-branch" { 146 + t.Errorf("Expected TANGLED_PR_SOURCE_BRANCH='feature-branch', got '%s'", env["TANGLED_PR_SOURCE_BRANCH"]) 147 + } 148 + if env["TANGLED_PR_TARGET_BRANCH"] != "main" { 149 + t.Errorf("Expected TANGLED_PR_TARGET_BRANCH='main', got '%s'", env["TANGLED_PR_TARGET_BRANCH"]) 150 + } 151 + if env["TANGLED_PR_SOURCE_SHA"] != "pr-sha-789" { 152 + t.Errorf("Expected TANGLED_PR_SOURCE_SHA='pr-sha-789', got '%s'", env["TANGLED_PR_SOURCE_SHA"]) 153 + } 154 + if env["TANGLED_PR_ACTION"] != "opened" { 155 + t.Errorf("Expected TANGLED_PR_ACTION='opened', got '%s'", env["TANGLED_PR_ACTION"]) 156 + } 157 + } 158 + 159 + func TestPipelineEnvVars_ManualWithInputs(t *testing.T) { 160 + tr := &tangled.Pipeline_TriggerMetadata{ 161 + Kind: string(workflow.TriggerKindManual), 162 + Manual: &tangled.Pipeline_ManualTriggerData{ 163 + Inputs: []*tangled.Pipeline_Pair{ 164 + {Key: "version", Value: "1.0.0"}, 165 + {Key: "environment", Value: "production"}, 166 + }, 167 + }, 168 + Repo: &tangled.Pipeline_TriggerRepo{ 169 + Knot: "example.com", 170 + Did: "did:plc:user123", 171 + Repo: "my-repo", 172 + }, 173 + } 174 + id := PipelineId{ 175 + Knot: "example.com", 176 + Rkey: "123123", 177 + } 178 + env := PipelineEnvVars(tr, id, false) 179 + 180 + // Check manual input variables 181 + if env["TANGLED_INPUT_VERSION"] != "1.0.0" { 182 + t.Errorf("Expected TANGLED_INPUT_VERSION='1.0.0', got '%s'", env["TANGLED_INPUT_VERSION"]) 183 + } 184 + if env["TANGLED_INPUT_ENVIRONMENT"] != "production" { 185 + t.Errorf("Expected TANGLED_INPUT_ENVIRONMENT='production', got '%s'", env["TANGLED_INPUT_ENVIRONMENT"]) 186 + } 187 + 188 + // Manual triggers shouldn't have ref/sha variables 189 + if _, ok := env["TANGLED_REF"]; ok { 190 + t.Error("Manual trigger should not have TANGLED_REF") 191 + } 192 + if _, ok := env["TANGLED_SHA"]; ok { 193 + t.Error("Manual trigger should not have TANGLED_SHA") 194 + } 195 + } 196 + 197 + func TestPipelineEnvVars_DevMode(t *testing.T) { 198 + tr := &tangled.Pipeline_TriggerMetadata{ 199 + Kind: string(workflow.TriggerKindPush), 200 + Push: &tangled.Pipeline_PushTriggerData{ 201 + NewSha: "abc123", 202 + Ref: "refs/heads/main", 203 + }, 204 + Repo: &tangled.Pipeline_TriggerRepo{ 205 + Knot: "localhost:3000", 206 + Did: "did:plc:user123", 207 + Repo: "my-repo", 208 + }, 209 + } 210 + id := PipelineId{ 211 + Knot: "example.com", 212 + Rkey: "123123", 213 + } 214 + env := PipelineEnvVars(tr, id, true) 215 + 216 + // Dev mode should use http:// and replace localhost with host.docker.internal 217 + expectedURL := "http://host.docker.internal:3000/did:plc:user123/my-repo" 218 + if env["TANGLED_REPO_URL"] != expectedURL { 219 + t.Errorf("Expected TANGLED_REPO_URL='%s', got '%s'", expectedURL, env["TANGLED_REPO_URL"]) 220 + } 221 + } 222 + 223 + func TestPipelineEnvVars_NilTrigger(t *testing.T) { 224 + id := PipelineId{ 225 + Knot: "example.com", 226 + Rkey: "123123", 227 + } 228 + env := PipelineEnvVars(nil, id, false) 229 + 230 + if env != nil { 231 + t.Error("Expected nil env for nil trigger") 232 + } 233 + } 234 + 235 + func TestPipelineEnvVars_NilPushData(t *testing.T) { 236 + tr := &tangled.Pipeline_TriggerMetadata{ 237 + Kind: string(workflow.TriggerKindPush), 238 + Push: nil, 239 + Repo: &tangled.Pipeline_TriggerRepo{ 240 + Knot: "example.com", 241 + Did: "did:plc:user123", 242 + Repo: "my-repo", 243 + }, 244 + } 245 + id := PipelineId{ 246 + Knot: "example.com", 247 + Rkey: "123123", 248 + } 249 + env := PipelineEnvVars(tr, id, false) 250 + 251 + // Should still have repo variables 252 + if env["TANGLED_REPO_KNOT"] != "example.com" { 253 + t.Errorf("Expected TANGLED_REPO_KNOT='example.com', got '%s'", env["TANGLED_REPO_KNOT"]) 254 + } 255 + 256 + // Should not have ref/sha variables 257 + if _, ok := env["TANGLED_REF"]; ok { 258 + t.Error("Should not have TANGLED_REF when push data is nil") 259 + } 260 + }
+15 -7
spindle/secrets/openbao.go
··· 13 13 ) 14 14 15 15 type OpenBaoManager struct { 16 - client *vault.Client 17 - mountPath string 18 - logger *slog.Logger 16 + client *vault.Client 17 + mountPath string 18 + logger *slog.Logger 19 + connectionTimeout time.Duration 19 20 } 20 21 21 22 type OpenBaoManagerOpt func(*OpenBaoManager) ··· 26 27 } 27 28 } 28 29 30 + func WithConnectionTimeout(timeout time.Duration) OpenBaoManagerOpt { 31 + return func(v *OpenBaoManager) { 32 + v.connectionTimeout = timeout 33 + } 34 + } 35 + 29 36 // NewOpenBaoManager creates a new OpenBao manager that connects to a Bao Proxy 30 37 // The proxyAddress should point to the local Bao Proxy (e.g., "http://127.0.0.1:8200") 31 38 // The proxy handles all authentication automatically via Auto-Auth ··· 43 50 } 44 51 45 52 manager := &OpenBaoManager{ 46 - client: client, 47 - mountPath: "spindle", // default KV v2 mount path 48 - logger: logger, 53 + client: client, 54 + mountPath: "spindle", // default KV v2 mount path 55 + logger: logger, 56 + connectionTimeout: 10 * time.Second, // default connection timeout 49 57 } 50 58 51 59 for _, opt := range opts { ··· 62 70 63 71 // testConnection verifies that we can connect to the proxy 64 72 func (v *OpenBaoManager) testConnection() error { 65 - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 73 + ctx, cancel := context.WithTimeout(context.Background(), v.connectionTimeout) 66 74 defer cancel() 67 75 68 76 // try token self-lookup as a quick way to verify proxy works
+5 -2
spindle/secrets/openbao_test.go
··· 152 152 for _, tt := range tests { 153 153 t.Run(tt.name, func(t *testing.T) { 154 154 logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) 155 - manager, err := NewOpenBaoManager(tt.proxyAddr, logger, tt.opts...) 155 + // Use shorter timeout for tests to avoid long waits 156 + opts := append(tt.opts, WithConnectionTimeout(1*time.Second)) 157 + manager, err := NewOpenBaoManager(tt.proxyAddr, logger, opts...) 156 158 157 159 if tt.expectError { 158 160 assert.Error(t, err) ··· 596 598 597 599 // All these will fail because no real proxy is running 598 600 // but we can test that the configuration is properly accepted 599 - manager, err := NewOpenBaoManager(tt.proxyAddr, logger) 601 + // Use shorter timeout for tests to avoid long waits 602 + manager, err := NewOpenBaoManager(tt.proxyAddr, logger, WithConnectionTimeout(1*time.Second)) 600 603 assert.Error(t, err) // Expected because no real proxy 601 604 assert.Nil(t, manager) 602 605 assert.Contains(t, err.Error(), "failed to connect to bao proxy")
+103 -47
spindle/server.go
··· 6 6 "encoding/json" 7 7 "fmt" 8 8 "log/slog" 9 + "maps" 9 10 "net/http" 10 11 11 12 "github.com/go-chi/chi/v5" ··· 49 50 vault secrets.Manager 50 51 } 51 52 52 - func Run(ctx context.Context) error { 53 + // New creates a new Spindle server with the provided configuration and engines. 54 + func New(ctx context.Context, cfg *config.Config, engines map[string]models.Engine) (*Spindle, error) { 53 55 logger := log.FromContext(ctx) 54 56 55 - cfg, err := config.Load(ctx) 56 - if err != nil { 57 - return fmt.Errorf("failed to load config: %w", err) 58 - } 59 - 60 57 d, err := db.Make(cfg.Server.DBPath) 61 58 if err != nil { 62 - return fmt.Errorf("failed to setup db: %w", err) 59 + return nil, fmt.Errorf("failed to setup db: %w", err) 63 60 } 64 61 65 62 e, err := rbac.NewEnforcer(cfg.Server.DBPath) 66 63 if err != nil { 67 - return fmt.Errorf("failed to setup rbac enforcer: %w", err) 64 + return nil, fmt.Errorf("failed to setup rbac enforcer: %w", err) 68 65 } 69 66 e.E.EnableAutoSave(true) 70 67 ··· 74 71 switch cfg.Server.Secrets.Provider { 75 72 case "openbao": 76 73 if cfg.Server.Secrets.OpenBao.ProxyAddr == "" { 77 - return fmt.Errorf("openbao proxy address is required when using openbao secrets provider") 74 + return nil, fmt.Errorf("openbao proxy address is required when using openbao secrets provider") 78 75 } 79 76 vault, err = secrets.NewOpenBaoManager( 80 77 cfg.Server.Secrets.OpenBao.ProxyAddr, ··· 82 79 secrets.WithMountPath(cfg.Server.Secrets.OpenBao.Mount), 83 80 ) 84 81 if err != nil { 85 - return fmt.Errorf("failed to setup openbao secrets provider: %w", err) 82 + return nil, fmt.Errorf("failed to setup openbao secrets provider: %w", err) 86 83 } 87 84 logger.Info("using openbao secrets provider", "proxy_address", cfg.Server.Secrets.OpenBao.ProxyAddr, "mount", cfg.Server.Secrets.OpenBao.Mount) 88 85 case "sqlite", "": 89 86 vault, err = secrets.NewSQLiteManager(cfg.Server.DBPath, secrets.WithTableName("secrets")) 90 87 if err != nil { 91 - return fmt.Errorf("failed to setup sqlite secrets provider: %w", err) 88 + return nil, fmt.Errorf("failed to setup sqlite secrets provider: %w", err) 92 89 } 93 90 logger.Info("using sqlite secrets provider", "path", cfg.Server.DBPath) 94 91 default: 95 - return fmt.Errorf("unknown secrets provider: %s", cfg.Server.Secrets.Provider) 96 - } 97 - 98 - nixeryEng, err := nixery.New(ctx, cfg) 99 - if err != nil { 100 - return err 92 + return nil, fmt.Errorf("unknown secrets provider: %s", cfg.Server.Secrets.Provider) 101 93 } 102 94 103 95 jq := queue.NewQueue(cfg.Server.QueueSize, cfg.Server.MaxJobCount) ··· 108 100 tangled.RepoNSID, 109 101 tangled.RepoCollaboratorNSID, 110 102 } 111 - jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, logger, d, true, true) 103 + jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, log.SubLogger(logger, "jetstream"), d, true, true) 112 104 if err != nil { 113 - return fmt.Errorf("failed to setup jetstream client: %w", err) 105 + return nil, fmt.Errorf("failed to setup jetstream client: %w", err) 114 106 } 115 107 jc.AddDid(cfg.Server.Owner) 116 108 117 109 // Check if the spindle knows about any Dids; 118 110 dids, err := d.GetAllDids() 119 111 if err != nil { 120 - return fmt.Errorf("failed to get all dids: %w", err) 112 + return nil, fmt.Errorf("failed to get all dids: %w", err) 121 113 } 122 114 for _, d := range dids { 123 115 jc.AddDid(d) 124 116 } 125 117 126 - resolver := idresolver.DefaultResolver() 118 + resolver := idresolver.DefaultResolver(cfg.Server.PlcUrl) 127 119 128 - spindle := Spindle{ 120 + spindle := &Spindle{ 129 121 jc: jc, 130 122 e: e, 131 123 db: d, 132 124 l: logger, 133 125 n: &n, 134 - engs: map[string]models.Engine{"nixery": nixeryEng}, 126 + engs: engines, 135 127 jq: jq, 136 128 cfg: cfg, 137 129 res: resolver, ··· 140 132 141 133 err = e.AddSpindle(rbacDomain) 142 134 if err != nil { 143 - return fmt.Errorf("failed to set rbac domain: %w", err) 135 + return nil, fmt.Errorf("failed to set rbac domain: %w", err) 144 136 } 145 137 err = spindle.configureOwner() 146 138 if err != nil { 147 - return err 139 + return nil, err 148 140 } 149 141 logger.Info("owner set", "did", cfg.Server.Owner) 150 142 151 - // starts a job queue runner in the background 152 - jq.Start() 153 - defer jq.Stop() 154 - 155 - // Stop vault token renewal if it implements Stopper 156 - if stopper, ok := vault.(secrets.Stopper); ok { 157 - defer stopper.Stop() 158 - } 159 - 160 143 cursorStore, err := cursor.NewSQLiteStore(cfg.Server.DBPath) 161 144 if err != nil { 162 - return fmt.Errorf("failed to setup sqlite3 cursor store: %w", err) 145 + return nil, fmt.Errorf("failed to setup sqlite3 cursor store: %w", err) 163 146 } 164 147 165 148 err = jc.StartJetstream(ctx, spindle.ingest()) 166 149 if err != nil { 167 - return fmt.Errorf("failed to start jetstream consumer: %w", err) 150 + return nil, fmt.Errorf("failed to start jetstream consumer: %w", err) 168 151 } 169 152 170 153 // for each incoming sh.tangled.pipeline, we execute 171 154 // spindle.processPipeline, which in turn enqueues the pipeline 172 155 // job in the above registered queue. 173 156 ccfg := eventconsumer.NewConsumerConfig() 174 - ccfg.Logger = logger 157 + ccfg.Logger = log.SubLogger(logger, "eventconsumer") 175 158 ccfg.Dev = cfg.Server.Dev 176 159 ccfg.ProcessFunc = spindle.processPipeline 177 160 ccfg.CursorStore = cursorStore 178 161 knownKnots, err := d.Knots() 179 162 if err != nil { 180 - return err 163 + return nil, err 181 164 } 182 165 for _, knot := range knownKnots { 183 166 logger.Info("adding source start", "knot", knot) ··· 185 168 } 186 169 spindle.ks = eventconsumer.NewConsumer(*ccfg) 187 170 171 + return spindle, nil 172 + } 173 + 174 + // DB returns the database instance. 175 + func (s *Spindle) DB() *db.DB { 176 + return s.db 177 + } 178 + 179 + // Queue returns the job queue instance. 180 + func (s *Spindle) Queue() *queue.Queue { 181 + return s.jq 182 + } 183 + 184 + // Engines returns the map of available engines. 185 + func (s *Spindle) Engines() map[string]models.Engine { 186 + return s.engs 187 + } 188 + 189 + // Vault returns the secrets manager instance. 190 + func (s *Spindle) Vault() secrets.Manager { 191 + return s.vault 192 + } 193 + 194 + // Notifier returns the notifier instance. 195 + func (s *Spindle) Notifier() *notifier.Notifier { 196 + return s.n 197 + } 198 + 199 + // Enforcer returns the RBAC enforcer instance. 200 + func (s *Spindle) Enforcer() *rbac.Enforcer { 201 + return s.e 202 + } 203 + 204 + // Start starts the Spindle server (blocking). 205 + func (s *Spindle) Start(ctx context.Context) error { 206 + // starts a job queue runner in the background 207 + s.jq.Start() 208 + defer s.jq.Stop() 209 + 210 + // Stop vault token renewal if it implements Stopper 211 + if stopper, ok := s.vault.(secrets.Stopper); ok { 212 + defer stopper.Stop() 213 + } 214 + 188 215 go func() { 189 - logger.Info("starting knot event consumer") 190 - spindle.ks.Start(ctx) 216 + s.l.Info("starting knot event consumer") 217 + s.ks.Start(ctx) 191 218 }() 192 219 193 - logger.Info("starting spindle server", "address", cfg.Server.ListenAddr) 194 - logger.Error("server error", "error", http.ListenAndServe(cfg.Server.ListenAddr, spindle.Router())) 220 + s.l.Info("starting spindle server", "address", s.cfg.Server.ListenAddr) 221 + return http.ListenAndServe(s.cfg.Server.ListenAddr, s.Router()) 222 + } 223 + 224 + func Run(ctx context.Context) error { 225 + cfg, err := config.Load(ctx) 226 + if err != nil { 227 + return fmt.Errorf("failed to load config: %w", err) 228 + } 229 + 230 + nixeryEng, err := nixery.New(ctx, cfg) 231 + if err != nil { 232 + return err 233 + } 234 + 235 + s, err := New(ctx, cfg, map[string]models.Engine{ 236 + "nixery": nixeryEng, 237 + }) 238 + if err != nil { 239 + return err 240 + } 195 241 196 - return nil 242 + return s.Start(ctx) 197 243 } 198 244 199 245 func (s *Spindle) Router() http.Handler { ··· 210 256 } 211 257 212 258 func (s *Spindle) XrpcRouter() http.Handler { 213 - logger := s.l.With("route", "xrpc") 214 - 215 259 serviceAuth := serviceauth.NewServiceAuth(s.l, s.res, s.cfg.Server.Did().String()) 216 260 261 + l := log.SubLogger(s.l, "xrpc") 262 + 217 263 x := xrpc.Xrpc{ 218 - Logger: logger, 264 + Logger: l, 219 265 Db: s.db, 220 266 Enforcer: s.e, 221 267 Engines: s.engs, ··· 265 311 } 266 312 267 313 workflows := make(map[models.Engine][]models.Workflow) 314 + 315 + // Build pipeline environment variables once for all workflows 316 + pipelineEnv := models.PipelineEnvVars(tpl.TriggerMetadata, pipelineId, s.cfg.Server.Dev) 268 317 269 318 for _, w := range tpl.Workflows { 270 319 if w != nil { ··· 291 340 return err 292 341 } 293 342 343 + // inject TANGLED_* env vars after InitWorkflow 344 + // This prevents user-defined env vars from overriding them 345 + if ewf.Environment == nil { 346 + ewf.Environment = make(map[string]string) 347 + } 348 + maps.Copy(ewf.Environment, pipelineEnv) 349 + 294 350 workflows[eng] = append(workflows[eng], *ewf) 295 351 296 352 err = s.db.StatusPending(models.WorkflowId{ ··· 305 361 306 362 ok := s.jq.Enqueue(queue.Job{ 307 363 Run: func() error { 308 - engine.StartWorkflows(s.l, s.vault, s.cfg, s.db, s.n, ctx, &models.Pipeline{ 364 + engine.StartWorkflows(log.SubLogger(s.l, "engine"), s.vault, s.cfg, s.db, s.n, ctx, &models.Pipeline{ 309 365 RepoOwner: tpl.TriggerMetadata.Repo.Did, 310 366 RepoName: tpl.TriggerMetadata.Repo.Repo, 311 367 Workflows: workflows,
+8 -3
spindle/stream.go
··· 10 10 "strconv" 11 11 "time" 12 12 13 + "tangled.org/core/log" 13 14 "tangled.org/core/spindle/models" 14 15 15 16 "github.com/go-chi/chi/v5" ··· 23 24 } 24 25 25 26 func (s *Spindle) Events(w http.ResponseWriter, r *http.Request) { 26 - l := s.l.With("handler", "Events") 27 + l := log.SubLogger(s.l, "eventstream") 28 + 27 29 l.Debug("received new connection") 28 30 29 31 conn, err := upgrader.Upgrade(w, r, nil) ··· 82 84 } 83 85 case <-time.After(30 * time.Second): 84 86 // send a keep-alive 85 - l.Debug("sent keepalive") 86 87 if err = conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(time.Second)); err != nil { 87 88 l.Error("failed to write control", "err", err) 88 89 } ··· 212 213 if err := conn.WriteMessage(websocket.TextMessage, []byte(line.Text)); err != nil { 213 214 return fmt.Errorf("failed to write to websocket: %w", err) 214 215 } 216 + case <-time.After(30 * time.Second): 217 + // send a keep-alive 218 + if err := conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(time.Second)); err != nil { 219 + return fmt.Errorf("failed to write control: %w", err) 220 + } 215 221 } 216 222 } 217 223 } ··· 222 228 s.l.Debug("err", "err", err) 223 229 return err 224 230 } 225 - s.l.Debug("ops", "ops", events) 226 231 227 232 for _, event := range events { 228 233 // first extract the inner json into a map
+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 + }
+2 -12
types/diff.go
··· 2 2 3 3 import ( 4 4 "github.com/bluekeyes/go-gitdiff/gitdiff" 5 - "github.com/go-git/go-git/v5/plumbing/object" 6 5 ) 7 6 8 7 type DiffOpts struct { ··· 43 42 44 43 // A nicer git diff representation. 45 44 type NiceDiff struct { 46 - Commit struct { 47 - Message string `json:"message"` 48 - Author object.Signature `json:"author"` 49 - This string `json:"this"` 50 - Parent string `json:"parent"` 51 - PGPSignature string `json:"pgp_signature"` 52 - Committer object.Signature `json:"committer"` 53 - Tree string `json:"tree"` 54 - ChangedId string `json:"change_id"` 55 - } `json:"commit"` 56 - Stat struct { 45 + Commit Commit `json:"commit"` 46 + Stat struct { 57 47 FilesChanged int `json:"files_changed"` 58 48 Insertions int `json:"insertions"` 59 49 Deletions int `json:"deletions"`
+46 -23
types/repo.go
··· 1 1 package types 2 2 3 3 import ( 4 + "encoding/json" 5 + 6 + "github.com/bluekeyes/go-gitdiff/gitdiff" 4 7 "github.com/go-git/go-git/v5/plumbing/object" 5 8 ) 6 9 7 10 type RepoIndexResponse struct { 8 - IsEmpty bool `json:"is_empty"` 9 - Ref string `json:"ref,omitempty"` 10 - Readme string `json:"readme,omitempty"` 11 - ReadmeFileName string `json:"readme_file_name,omitempty"` 12 - Commits []*object.Commit `json:"commits,omitempty"` 13 - Description string `json:"description,omitempty"` 14 - Files []NiceTree `json:"files,omitempty"` 15 - Branches []Branch `json:"branches,omitempty"` 16 - Tags []*TagReference `json:"tags,omitempty"` 17 - TotalCommits int `json:"total_commits,omitempty"` 11 + IsEmpty bool `json:"is_empty"` 12 + Ref string `json:"ref,omitempty"` 13 + Readme string `json:"readme,omitempty"` 14 + ReadmeFileName string `json:"readme_file_name,omitempty"` 15 + Commits []Commit `json:"commits,omitempty"` 16 + Description string `json:"description,omitempty"` 17 + Files []NiceTree `json:"files,omitempty"` 18 + Branches []Branch `json:"branches,omitempty"` 19 + Tags []*TagReference `json:"tags,omitempty"` 20 + TotalCommits int `json:"total_commits,omitempty"` 18 21 } 19 22 20 23 type RepoLogResponse struct { 21 - Commits []*object.Commit `json:"commits,omitempty"` 22 - Ref string `json:"ref,omitempty"` 23 - Description string `json:"description,omitempty"` 24 - Log bool `json:"log,omitempty"` 25 - Total int `json:"total,omitempty"` 26 - Page int `json:"page,omitempty"` 27 - PerPage int `json:"per_page,omitempty"` 24 + Commits []Commit `json:"commits,omitempty"` 25 + Ref string `json:"ref,omitempty"` 26 + Description string `json:"description,omitempty"` 27 + Log bool `json:"log,omitempty"` 28 + Total int `json:"total,omitempty"` 29 + Page int `json:"page,omitempty"` 30 + PerPage int `json:"per_page,omitempty"` 28 31 } 29 32 30 33 type RepoCommitResponse struct { ··· 33 36 } 34 37 35 38 type RepoFormatPatchResponse struct { 36 - Rev1 string `json:"rev1,omitempty"` 37 - Rev2 string `json:"rev2,omitempty"` 38 - FormatPatch []FormatPatch `json:"format_patch,omitempty"` 39 - MergeBase string `json:"merge_base,omitempty"` // deprecated 40 - Patch string `json:"patch,omitempty"` 39 + Rev1 string `json:"rev1,omitempty"` 40 + Rev2 string `json:"rev2,omitempty"` 41 + FormatPatch []FormatPatch `json:"format_patch,omitempty"` 42 + FormatPatchRaw string `json:"patch,omitempty"` 43 + CombinedPatch []*gitdiff.File `json:"combined_patch,omitempty"` 44 + CombinedPatchRaw string `json:"combined_patch_raw,omitempty"` 41 45 } 42 46 43 47 type RepoTreeResponse struct { ··· 64 68 type Branch struct { 65 69 Reference `json:"reference"` 66 70 Commit *object.Commit `json:"commit,omitempty"` 67 - IsDefault bool `json:"is_deafult,omitempty"` 71 + IsDefault bool `json:"is_default,omitempty"` 72 + } 73 + 74 + func (b *Branch) UnmarshalJSON(data []byte) error { 75 + aux := &struct { 76 + Reference `json:"reference"` 77 + Commit *object.Commit `json:"commit,omitempty"` 78 + IsDefault bool `json:"is_default,omitempty"` 79 + MispelledIsDefault bool `json:"is_deafult,omitempty"` // mispelled name 80 + }{} 81 + 82 + if err := json.Unmarshal(data, aux); err != nil { 83 + return err 84 + } 85 + 86 + b.Reference = aux.Reference 87 + b.Commit = aux.Commit 88 + b.IsDefault = aux.IsDefault || aux.MispelledIsDefault // whichever was set 89 + 90 + return nil 68 91 } 69 92 70 93 type RepoTagsResponse struct {
+88 -5
types/tree.go
··· 1 1 package types 2 2 3 3 import ( 4 + "fmt" 5 + "os" 4 6 "time" 5 7 6 8 "github.com/go-git/go-git/v5/plumbing" 9 + "github.com/go-git/go-git/v5/plumbing/filemode" 7 10 ) 8 11 9 12 // A nicer git tree representation. 10 13 type NiceTree struct { 11 14 // Relative path 12 - Name string `json:"name"` 13 - Mode string `json:"mode"` 14 - Size int64 `json:"size"` 15 - IsFile bool `json:"is_file"` 16 - IsSubtree bool `json:"is_subtree"` 15 + Name string `json:"name"` 16 + Mode string `json:"mode"` 17 + Size int64 `json:"size"` 17 18 18 19 LastCommit *LastCommitInfo `json:"last_commit,omitempty"` 20 + } 21 + 22 + func (t *NiceTree) FileMode() (filemode.FileMode, error) { 23 + if numericMode, err := filemode.New(t.Mode); err == nil { 24 + return numericMode, nil 25 + } 26 + 27 + // TODO: this is here for backwards compat, can be removed in future versions 28 + osMode, err := parseModeString(t.Mode) 29 + if err != nil { 30 + return filemode.Empty, nil 31 + } 32 + 33 + conv, err := filemode.NewFromOSFileMode(osMode) 34 + if err != nil { 35 + return filemode.Empty, nil 36 + } 37 + 38 + return conv, nil 39 + } 40 + 41 + // ParseFileModeString parses a file mode string like "-rw-r--r--" 42 + // and returns an os.FileMode 43 + func parseModeString(modeStr string) (os.FileMode, error) { 44 + if len(modeStr) != 10 { 45 + return 0, fmt.Errorf("invalid mode string length: expected 10, got %d", len(modeStr)) 46 + } 47 + 48 + var mode os.FileMode 49 + 50 + // Parse file type (first character) 51 + switch modeStr[0] { 52 + case 'd': 53 + mode |= os.ModeDir 54 + case 'l': 55 + mode |= os.ModeSymlink 56 + case '-': 57 + // regular file 58 + default: 59 + return 0, fmt.Errorf("unknown file type: %c", modeStr[0]) 60 + } 61 + 62 + // parse permissions for owner, group, and other 63 + perms := modeStr[1:] 64 + shifts := []int{6, 3, 0} // bit shifts for owner, group, other 65 + 66 + for i := range 3 { 67 + offset := i * 3 68 + shift := shifts[i] 69 + 70 + if perms[offset] == 'r' { 71 + mode |= os.FileMode(4 << shift) 72 + } 73 + if perms[offset+1] == 'w' { 74 + mode |= os.FileMode(2 << shift) 75 + } 76 + if perms[offset+2] == 'x' { 77 + mode |= os.FileMode(1 << shift) 78 + } 79 + } 80 + 81 + return mode, nil 82 + } 83 + 84 + func (t *NiceTree) IsFile() bool { 85 + m, err := t.FileMode() 86 + 87 + if err != nil { 88 + return false 89 + } 90 + 91 + return m.IsFile() 92 + } 93 + 94 + func (t *NiceTree) IsSubmodule() bool { 95 + m, err := t.FileMode() 96 + 97 + if err != nil { 98 + return false 99 + } 100 + 101 + return m == filemode.Submodule 19 102 } 20 103 21 104 type LastCommitInfo struct {
+9 -1
workflow/compile.go
··· 113 113 func (compiler *Compiler) compileWorkflow(w Workflow) *tangled.Pipeline_Workflow { 114 114 cw := &tangled.Pipeline_Workflow{} 115 115 116 - if !w.Match(compiler.Trigger) { 116 + matched, err := w.Match(compiler.Trigger) 117 + if err != nil { 118 + compiler.Diagnostics.AddError( 119 + w.Name, 120 + fmt.Errorf("failed to execute workflow: %w", err), 121 + ) 122 + return nil 123 + } 124 + if !matched { 117 125 compiler.Diagnostics.AddWarning( 118 126 w.Name, 119 127 WorkflowSkipped,
+125
workflow/compile_test.go
··· 95 95 assert.Len(t, c.Diagnostics.Errors, 1) 96 96 assert.Equal(t, MissingEngine, c.Diagnostics.Errors[0].Error) 97 97 } 98 + 99 + func TestCompileWorkflow_MultipleBranchAndTag(t *testing.T) { 100 + wf := Workflow{ 101 + Name: ".tangled/workflows/branch_and_tag.yml", 102 + When: []Constraint{ 103 + { 104 + Event: []string{"push"}, 105 + Branch: []string{"main", "develop"}, 106 + Tag: []string{"v*"}, 107 + }, 108 + }, 109 + Engine: "nixery", 110 + } 111 + 112 + tests := []struct { 113 + name string 114 + trigger tangled.Pipeline_TriggerMetadata 115 + shouldMatch bool 116 + expectedCount int 117 + }{ 118 + { 119 + name: "matches main branch", 120 + trigger: tangled.Pipeline_TriggerMetadata{ 121 + Kind: string(TriggerKindPush), 122 + Push: &tangled.Pipeline_PushTriggerData{ 123 + Ref: "refs/heads/main", 124 + OldSha: strings.Repeat("0", 40), 125 + NewSha: strings.Repeat("f", 40), 126 + }, 127 + }, 128 + shouldMatch: true, 129 + expectedCount: 1, 130 + }, 131 + { 132 + name: "matches develop branch", 133 + trigger: tangled.Pipeline_TriggerMetadata{ 134 + Kind: string(TriggerKindPush), 135 + Push: &tangled.Pipeline_PushTriggerData{ 136 + Ref: "refs/heads/develop", 137 + OldSha: strings.Repeat("0", 40), 138 + NewSha: strings.Repeat("f", 40), 139 + }, 140 + }, 141 + shouldMatch: true, 142 + expectedCount: 1, 143 + }, 144 + { 145 + name: "matches v* tag pattern", 146 + trigger: tangled.Pipeline_TriggerMetadata{ 147 + Kind: string(TriggerKindPush), 148 + Push: &tangled.Pipeline_PushTriggerData{ 149 + Ref: "refs/tags/v1.0.0", 150 + OldSha: strings.Repeat("0", 40), 151 + NewSha: strings.Repeat("f", 40), 152 + }, 153 + }, 154 + shouldMatch: true, 155 + expectedCount: 1, 156 + }, 157 + { 158 + name: "matches v* tag pattern with different version", 159 + trigger: tangled.Pipeline_TriggerMetadata{ 160 + Kind: string(TriggerKindPush), 161 + Push: &tangled.Pipeline_PushTriggerData{ 162 + Ref: "refs/tags/v2.5.3", 163 + OldSha: strings.Repeat("0", 40), 164 + NewSha: strings.Repeat("f", 40), 165 + }, 166 + }, 167 + shouldMatch: true, 168 + expectedCount: 1, 169 + }, 170 + { 171 + name: "does not match master branch", 172 + trigger: tangled.Pipeline_TriggerMetadata{ 173 + Kind: string(TriggerKindPush), 174 + Push: &tangled.Pipeline_PushTriggerData{ 175 + Ref: "refs/heads/master", 176 + OldSha: strings.Repeat("0", 40), 177 + NewSha: strings.Repeat("f", 40), 178 + }, 179 + }, 180 + shouldMatch: false, 181 + expectedCount: 0, 182 + }, 183 + { 184 + name: "does not match non-v tag", 185 + trigger: tangled.Pipeline_TriggerMetadata{ 186 + Kind: string(TriggerKindPush), 187 + Push: &tangled.Pipeline_PushTriggerData{ 188 + Ref: "refs/tags/release-1.0", 189 + OldSha: strings.Repeat("0", 40), 190 + NewSha: strings.Repeat("f", 40), 191 + }, 192 + }, 193 + shouldMatch: false, 194 + expectedCount: 0, 195 + }, 196 + { 197 + name: "does not match feature branch", 198 + trigger: tangled.Pipeline_TriggerMetadata{ 199 + Kind: string(TriggerKindPush), 200 + Push: &tangled.Pipeline_PushTriggerData{ 201 + Ref: "refs/heads/feature/new-feature", 202 + OldSha: strings.Repeat("0", 40), 203 + NewSha: strings.Repeat("f", 40), 204 + }, 205 + }, 206 + shouldMatch: false, 207 + expectedCount: 0, 208 + }, 209 + } 210 + 211 + for _, tt := range tests { 212 + t.Run(tt.name, func(t *testing.T) { 213 + c := Compiler{Trigger: tt.trigger} 214 + cp := c.Compile([]Workflow{wf}) 215 + 216 + assert.Len(t, cp.Workflows, tt.expectedCount) 217 + if tt.shouldMatch { 218 + assert.Equal(t, wf.Name, cp.Workflows[0].Name) 219 + } 220 + }) 221 + } 222 + }
+61 -19
workflow/def.go
··· 8 8 9 9 "tangled.org/core/api/tangled" 10 10 11 + "github.com/bmatcuk/doublestar/v4" 11 12 "github.com/go-git/go-git/v5/plumbing" 12 13 "gopkg.in/yaml.v3" 13 14 ) ··· 33 34 34 35 Constraint struct { 35 36 Event StringList `yaml:"event"` 36 - Branch StringList `yaml:"branch"` // this is optional, and only applied on "push" events 37 + Branch StringList `yaml:"branch"` // required for pull_request; for push, either branch or tag must be specified 38 + Tag StringList `yaml:"tag"` // optional; only applies to push events 37 39 } 38 40 39 41 CloneOpts struct { ··· 59 61 return strings.ReplaceAll(string(t), "_", " ") 60 62 } 61 63 64 + // matchesPattern checks if a name matches any of the given patterns. 65 + // Patterns can be exact matches or glob patterns using * and **. 66 + // * matches any sequence of non-separator characters 67 + // ** matches any sequence of characters including separators 68 + func matchesPattern(name string, patterns []string) (bool, error) { 69 + for _, pattern := range patterns { 70 + matched, err := doublestar.Match(pattern, name) 71 + if err != nil { 72 + return false, err 73 + } 74 + if matched { 75 + return true, nil 76 + } 77 + } 78 + return false, nil 79 + } 80 + 62 81 func FromFile(name string, contents []byte) (Workflow, error) { 63 82 var wf Workflow 64 83 ··· 74 93 } 75 94 76 95 // if any of the constraints on a workflow is true, return true 77 - func (w *Workflow) Match(trigger tangled.Pipeline_TriggerMetadata) bool { 96 + func (w *Workflow) Match(trigger tangled.Pipeline_TriggerMetadata) (bool, error) { 78 97 // manual triggers always run the workflow 79 98 if trigger.Manual != nil { 80 - return true 99 + return true, nil 81 100 } 82 101 83 102 // if not manual, run through the constraint list and see if any one matches 84 103 for _, c := range w.When { 85 - if c.Match(trigger) { 86 - return true 104 + matched, err := c.Match(trigger) 105 + if err != nil { 106 + return false, err 107 + } 108 + if matched { 109 + return true, nil 87 110 } 88 111 } 89 112 90 113 // no constraints, always run this workflow 91 114 if len(w.When) == 0 { 92 - return true 115 + return true, nil 93 116 } 94 117 95 - return false 118 + return false, nil 96 119 } 97 120 98 - func (c *Constraint) Match(trigger tangled.Pipeline_TriggerMetadata) bool { 121 + func (c *Constraint) Match(trigger tangled.Pipeline_TriggerMetadata) (bool, error) { 99 122 match := true 100 123 101 124 // manual triggers always pass this constraint 102 125 if trigger.Manual != nil { 103 - return true 126 + return true, nil 104 127 } 105 128 106 129 // apply event constraints ··· 108 131 109 132 // apply branch constraints for PRs 110 133 if trigger.PullRequest != nil { 111 - match = match && c.MatchBranch(trigger.PullRequest.TargetBranch) 134 + matched, err := c.MatchBranch(trigger.PullRequest.TargetBranch) 135 + if err != nil { 136 + return false, err 137 + } 138 + match = match && matched 112 139 } 113 140 114 141 // apply ref constraints for pushes 115 142 if trigger.Push != nil { 116 - match = match && c.MatchRef(trigger.Push.Ref) 143 + matched, err := c.MatchRef(trigger.Push.Ref) 144 + if err != nil { 145 + return false, err 146 + } 147 + match = match && matched 117 148 } 118 149 119 - return match 120 - } 121 - 122 - func (c *Constraint) MatchBranch(branch string) bool { 123 - return slices.Contains(c.Branch, branch) 150 + return match, nil 124 151 } 125 152 126 - func (c *Constraint) MatchRef(ref string) bool { 153 + func (c *Constraint) MatchRef(ref string) (bool, error) { 127 154 refName := plumbing.ReferenceName(ref) 155 + shortName := refName.Short() 156 + 128 157 if refName.IsBranch() { 129 - return slices.Contains(c.Branch, refName.Short()) 158 + return c.MatchBranch(shortName) 130 159 } 131 - return false 160 + 161 + if refName.IsTag() { 162 + return c.MatchTag(shortName) 163 + } 164 + 165 + return false, nil 166 + } 167 + 168 + func (c *Constraint) MatchBranch(branch string) (bool, error) { 169 + return matchesPattern(branch, c.Branch) 170 + } 171 + 172 + func (c *Constraint) MatchTag(tag string) (bool, error) { 173 + return matchesPattern(tag, c.Tag) 132 174 } 133 175 134 176 func (c *Constraint) MatchEvent(event string) bool {
+284 -1
workflow/def_test.go
··· 6 6 "github.com/stretchr/testify/assert" 7 7 ) 8 8 9 - func TestUnmarshalWorkflow(t *testing.T) { 9 + func TestUnmarshalWorkflowWithBranch(t *testing.T) { 10 10 yamlData := ` 11 11 when: 12 12 - event: ["push", "pull_request"] ··· 38 38 39 39 assert.True(t, wf.CloneOpts.Skip, "Skip should be false") 40 40 } 41 + 42 + func TestUnmarshalWorkflowWithTags(t *testing.T) { 43 + yamlData := ` 44 + when: 45 + - event: ["push"] 46 + tag: ["v*", "release-*"]` 47 + 48 + wf, err := FromFile("test.yml", []byte(yamlData)) 49 + assert.NoError(t, err, "YAML should unmarshal without error") 50 + 51 + assert.Len(t, wf.When, 1, "Should have one constraint") 52 + assert.ElementsMatch(t, []string{"v*", "release-*"}, wf.When[0].Tag) 53 + assert.ElementsMatch(t, []string{"push"}, wf.When[0].Event) 54 + } 55 + 56 + func TestUnmarshalWorkflowWithBranchAndTag(t *testing.T) { 57 + yamlData := ` 58 + when: 59 + - event: ["push"] 60 + branch: ["main", "develop"] 61 + tag: ["v*"]` 62 + 63 + wf, err := FromFile("test.yml", []byte(yamlData)) 64 + assert.NoError(t, err, "YAML should unmarshal without error") 65 + 66 + assert.Len(t, wf.When, 1, "Should have one constraint") 67 + assert.ElementsMatch(t, []string{"main", "develop"}, wf.When[0].Branch) 68 + assert.ElementsMatch(t, []string{"v*"}, wf.When[0].Tag) 69 + } 70 + 71 + func TestMatchesPattern(t *testing.T) { 72 + tests := []struct { 73 + name string 74 + input string 75 + patterns []string 76 + expected bool 77 + }{ 78 + {"exact match", "main", []string{"main"}, true}, 79 + {"exact match in list", "develop", []string{"main", "develop"}, true}, 80 + {"no match", "feature", []string{"main", "develop"}, false}, 81 + {"wildcard prefix", "v1.0.0", []string{"v*"}, true}, 82 + {"wildcard suffix", "release-1.0", []string{"*-1.0"}, true}, 83 + {"wildcard middle", "feature-123-test", []string{"feature-*-test"}, true}, 84 + {"double star prefix", "release-1.0.0", []string{"release-**"}, true}, 85 + {"double star with slashes", "release/1.0/hotfix", []string{"release/**"}, true}, 86 + {"double star matches multiple levels", "foo/bar/baz/qux", []string{"foo/**"}, true}, 87 + {"double star no match", "feature/test", []string{"release/**"}, false}, 88 + {"no patterns matches nothing", "anything", []string{}, false}, 89 + {"pattern doesn't match", "v1.0.0", []string{"release-*"}, false}, 90 + {"complex pattern", "release/v1.2.3", []string{"release/*"}, true}, 91 + {"single star stops at slash", "release/1.0/hotfix", []string{"release/*"}, false}, 92 + } 93 + 94 + for _, tt := range tests { 95 + t.Run(tt.name, func(t *testing.T) { 96 + result, _ := matchesPattern(tt.input, tt.patterns) 97 + assert.Equal(t, tt.expected, result, "matchesPattern(%q, %v) should be %v", tt.input, tt.patterns, tt.expected) 98 + }) 99 + } 100 + } 101 + 102 + func TestConstraintMatchRef_Branches(t *testing.T) { 103 + tests := []struct { 104 + name string 105 + constraint Constraint 106 + ref string 107 + expected bool 108 + }{ 109 + { 110 + name: "exact branch match", 111 + constraint: Constraint{Branch: []string{"main"}}, 112 + ref: "refs/heads/main", 113 + expected: true, 114 + }, 115 + { 116 + name: "branch glob match", 117 + constraint: Constraint{Branch: []string{"feature-*"}}, 118 + ref: "refs/heads/feature-123", 119 + expected: true, 120 + }, 121 + { 122 + name: "branch no match", 123 + constraint: Constraint{Branch: []string{"main"}}, 124 + ref: "refs/heads/develop", 125 + expected: false, 126 + }, 127 + { 128 + name: "no constraints matches nothing", 129 + constraint: Constraint{}, 130 + ref: "refs/heads/anything", 131 + expected: false, 132 + }, 133 + } 134 + 135 + for _, tt := range tests { 136 + t.Run(tt.name, func(t *testing.T) { 137 + result, _ := tt.constraint.MatchRef(tt.ref) 138 + assert.Equal(t, tt.expected, result, "MatchRef should return %v for ref %q", tt.expected, tt.ref) 139 + }) 140 + } 141 + } 142 + 143 + func TestConstraintMatchRef_Tags(t *testing.T) { 144 + tests := []struct { 145 + name string 146 + constraint Constraint 147 + ref string 148 + expected bool 149 + }{ 150 + { 151 + name: "exact tag match", 152 + constraint: Constraint{Tag: []string{"v1.0.0"}}, 153 + ref: "refs/tags/v1.0.0", 154 + expected: true, 155 + }, 156 + { 157 + name: "tag glob match", 158 + constraint: Constraint{Tag: []string{"v*"}}, 159 + ref: "refs/tags/v1.2.3", 160 + expected: true, 161 + }, 162 + { 163 + name: "tag glob with pattern", 164 + constraint: Constraint{Tag: []string{"release-*"}}, 165 + ref: "refs/tags/release-2024", 166 + expected: true, 167 + }, 168 + { 169 + name: "tag no match", 170 + constraint: Constraint{Tag: []string{"v*"}}, 171 + ref: "refs/tags/release-1.0", 172 + expected: false, 173 + }, 174 + { 175 + name: "tag not matched when only branch constraint", 176 + constraint: Constraint{Branch: []string{"main"}}, 177 + ref: "refs/tags/v1.0.0", 178 + expected: false, 179 + }, 180 + } 181 + 182 + for _, tt := range tests { 183 + t.Run(tt.name, func(t *testing.T) { 184 + result, _ := tt.constraint.MatchRef(tt.ref) 185 + assert.Equal(t, tt.expected, result, "MatchRef should return %v for ref %q", tt.expected, tt.ref) 186 + }) 187 + } 188 + } 189 + 190 + func TestConstraintMatchRef_Combined(t *testing.T) { 191 + tests := []struct { 192 + name string 193 + constraint Constraint 194 + ref string 195 + expected bool 196 + }{ 197 + { 198 + name: "matches branch in combined constraint", 199 + constraint: Constraint{Branch: []string{"main"}, Tag: []string{"v*"}}, 200 + ref: "refs/heads/main", 201 + expected: true, 202 + }, 203 + { 204 + name: "matches tag in combined constraint", 205 + constraint: Constraint{Branch: []string{"main"}, Tag: []string{"v*"}}, 206 + ref: "refs/tags/v1.0.0", 207 + expected: true, 208 + }, 209 + { 210 + name: "no match in combined constraint", 211 + constraint: Constraint{Branch: []string{"main"}, Tag: []string{"v*"}}, 212 + ref: "refs/heads/develop", 213 + expected: false, 214 + }, 215 + { 216 + name: "glob patterns in combined constraint - branch", 217 + constraint: Constraint{Branch: []string{"release-*"}, Tag: []string{"v*"}}, 218 + ref: "refs/heads/release-2024", 219 + expected: true, 220 + }, 221 + { 222 + name: "glob patterns in combined constraint - tag", 223 + constraint: Constraint{Branch: []string{"release-*"}, Tag: []string{"v*"}}, 224 + ref: "refs/tags/v2.0.0", 225 + expected: true, 226 + }, 227 + } 228 + 229 + for _, tt := range tests { 230 + t.Run(tt.name, func(t *testing.T) { 231 + result, _ := tt.constraint.MatchRef(tt.ref) 232 + assert.Equal(t, tt.expected, result, "MatchRef should return %v for ref %q", tt.expected, tt.ref) 233 + }) 234 + } 235 + } 236 + 237 + func TestConstraintMatchBranch_GlobPatterns(t *testing.T) { 238 + tests := []struct { 239 + name string 240 + constraint Constraint 241 + branch string 242 + expected bool 243 + }{ 244 + { 245 + name: "exact match", 246 + constraint: Constraint{Branch: []string{"main"}}, 247 + branch: "main", 248 + expected: true, 249 + }, 250 + { 251 + name: "glob match", 252 + constraint: Constraint{Branch: []string{"feature-*"}}, 253 + branch: "feature-123", 254 + expected: true, 255 + }, 256 + { 257 + name: "no match", 258 + constraint: Constraint{Branch: []string{"main"}}, 259 + branch: "develop", 260 + expected: false, 261 + }, 262 + { 263 + name: "multiple patterns with match", 264 + constraint: Constraint{Branch: []string{"main", "release-*"}}, 265 + branch: "release-1.0", 266 + expected: true, 267 + }, 268 + } 269 + 270 + for _, tt := range tests { 271 + t.Run(tt.name, func(t *testing.T) { 272 + result, _ := tt.constraint.MatchBranch(tt.branch) 273 + assert.Equal(t, tt.expected, result, "MatchBranch should return %v for branch %q", tt.expected, tt.branch) 274 + }) 275 + } 276 + } 277 + 278 + func TestConstraintMatchTag_GlobPatterns(t *testing.T) { 279 + tests := []struct { 280 + name string 281 + constraint Constraint 282 + tag string 283 + expected bool 284 + }{ 285 + { 286 + name: "exact match", 287 + constraint: Constraint{Tag: []string{"v1.0.0"}}, 288 + tag: "v1.0.0", 289 + expected: true, 290 + }, 291 + { 292 + name: "glob match", 293 + constraint: Constraint{Tag: []string{"v*"}}, 294 + tag: "v2.3.4", 295 + expected: true, 296 + }, 297 + { 298 + name: "no match", 299 + constraint: Constraint{Tag: []string{"v*"}}, 300 + tag: "release-1.0", 301 + expected: false, 302 + }, 303 + { 304 + name: "multiple patterns with match", 305 + constraint: Constraint{Tag: []string{"v*", "release-*"}}, 306 + tag: "release-2024", 307 + expected: true, 308 + }, 309 + { 310 + name: "empty tag list matches nothing", 311 + constraint: Constraint{Tag: []string{}}, 312 + tag: "v1.0.0", 313 + expected: false, 314 + }, 315 + } 316 + 317 + for _, tt := range tests { 318 + t.Run(tt.name, func(t *testing.T) { 319 + result, _ := tt.constraint.MatchTag(tt.tag) 320 + assert.Equal(t, tt.expected, result, "MatchTag should return %v for tag %q", tt.expected, tt.tag) 321 + }) 322 + } 323 + }
+5 -4
xrpc/serviceauth/service_auth.go
··· 9 9 10 10 "github.com/bluesky-social/indigo/atproto/auth" 11 11 "tangled.org/core/idresolver" 12 + "tangled.org/core/log" 12 13 xrpcerr "tangled.org/core/xrpc/errors" 13 14 ) 14 15 ··· 22 23 23 24 func NewServiceAuth(logger *slog.Logger, resolver *idresolver.Resolver, audienceDid string) *ServiceAuth { 24 25 return &ServiceAuth{ 25 - logger: logger, 26 + logger: log.SubLogger(logger, "serviceauth"), 26 27 resolver: resolver, 27 28 audienceDid: audienceDid, 28 29 } ··· 30 31 31 32 func (sa *ServiceAuth) VerifyServiceAuth(next http.Handler) http.Handler { 32 33 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 33 - l := sa.logger.With("url", r.URL) 34 - 35 34 token := r.Header.Get("Authorization") 36 35 token = strings.TrimPrefix(token, "Bearer ") 37 36 ··· 42 41 43 42 did, err := s.Validate(r.Context(), token, nil) 44 43 if err != nil { 45 - l.Error("signature verification failed", "err", err) 44 + sa.logger.Error("signature verification failed", "err", err) 46 45 writeError(w, xrpcerr.AuthError(err), http.StatusForbidden) 47 46 return 48 47 } 48 + 49 + sa.logger.Debug("valid signature", ActorDid, did) 49 50 50 51 r = r.WithContext( 51 52 context.WithValue(r.Context(), ActorDid, did),