forked from tangled.org/core
this repo has no description tangled.org

Compare changes

Choose any two refs to compare.

+7171 -2308
+649 -8
api/tangled/cbor_gen.go
··· 6938 6938 } 6939 6939 6940 6940 cw := cbg.NewCborWriter(w) 6941 - fieldCount := 5 6941 + fieldCount := 7 6942 6942 6943 6943 if t.Body == nil { 6944 + fieldCount-- 6945 + } 6946 + 6947 + if t.Mentions == nil { 6948 + fieldCount-- 6949 + } 6950 + 6951 + if t.References == nil { 6944 6952 fieldCount-- 6945 6953 } 6946 6954 ··· 7045 7053 return err 7046 7054 } 7047 7055 7056 + // t.Mentions ([]string) (slice) 7057 + if t.Mentions != nil { 7058 + 7059 + if len("mentions") > 1000000 { 7060 + return xerrors.Errorf("Value in field \"mentions\" was too long") 7061 + } 7062 + 7063 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("mentions"))); err != nil { 7064 + return err 7065 + } 7066 + if _, err := cw.WriteString(string("mentions")); err != nil { 7067 + return err 7068 + } 7069 + 7070 + if len(t.Mentions) > 8192 { 7071 + return xerrors.Errorf("Slice value in field t.Mentions was too long") 7072 + } 7073 + 7074 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Mentions))); err != nil { 7075 + return err 7076 + } 7077 + for _, v := range t.Mentions { 7078 + if len(v) > 1000000 { 7079 + return xerrors.Errorf("Value in field v was too long") 7080 + } 7081 + 7082 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 7083 + return err 7084 + } 7085 + if _, err := cw.WriteString(string(v)); err != nil { 7086 + return err 7087 + } 7088 + 7089 + } 7090 + } 7091 + 7048 7092 // t.CreatedAt (string) (string) 7049 7093 if len("createdAt") > 1000000 { 7050 7094 return xerrors.Errorf("Value in field \"createdAt\" was too long") ··· 7067 7111 if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 7068 7112 return err 7069 7113 } 7114 + 7115 + // t.References ([]string) (slice) 7116 + if t.References != nil { 7117 + 7118 + if len("references") > 1000000 { 7119 + return xerrors.Errorf("Value in field \"references\" was too long") 7120 + } 7121 + 7122 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("references"))); err != nil { 7123 + return err 7124 + } 7125 + if _, err := cw.WriteString(string("references")); err != nil { 7126 + return err 7127 + } 7128 + 7129 + if len(t.References) > 8192 { 7130 + return xerrors.Errorf("Slice value in field t.References was too long") 7131 + } 7132 + 7133 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.References))); err != nil { 7134 + return err 7135 + } 7136 + for _, v := range t.References { 7137 + if len(v) > 1000000 { 7138 + return xerrors.Errorf("Value in field v was too long") 7139 + } 7140 + 7141 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 7142 + return err 7143 + } 7144 + if _, err := cw.WriteString(string(v)); err != nil { 7145 + return err 7146 + } 7147 + 7148 + } 7149 + } 7070 7150 return nil 7071 7151 } 7072 7152 ··· 7095 7175 7096 7176 n := extra 7097 7177 7098 - nameBuf := make([]byte, 9) 7178 + nameBuf := make([]byte, 10) 7099 7179 for i := uint64(0); i < n; i++ { 7100 7180 nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 7101 7181 if err != nil { ··· 7164 7244 } 7165 7245 7166 7246 t.Title = string(sval) 7247 + } 7248 + // t.Mentions ([]string) (slice) 7249 + case "mentions": 7250 + 7251 + maj, extra, err = cr.ReadHeader() 7252 + if err != nil { 7253 + return err 7254 + } 7255 + 7256 + if extra > 8192 { 7257 + return fmt.Errorf("t.Mentions: array too large (%d)", extra) 7258 + } 7259 + 7260 + if maj != cbg.MajArray { 7261 + return fmt.Errorf("expected cbor array") 7262 + } 7263 + 7264 + if extra > 0 { 7265 + t.Mentions = make([]string, extra) 7266 + } 7267 + 7268 + for i := 0; i < int(extra); i++ { 7269 + { 7270 + var maj byte 7271 + var extra uint64 7272 + var err error 7273 + _ = maj 7274 + _ = extra 7275 + _ = err 7276 + 7277 + { 7278 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 7279 + if err != nil { 7280 + return err 7281 + } 7282 + 7283 + t.Mentions[i] = string(sval) 7284 + } 7285 + 7286 + } 7167 7287 } 7168 7288 // t.CreatedAt (string) (string) 7169 7289 case "createdAt": ··· 7176 7296 7177 7297 t.CreatedAt = string(sval) 7178 7298 } 7299 + // t.References ([]string) (slice) 7300 + case "references": 7301 + 7302 + maj, extra, err = cr.ReadHeader() 7303 + if err != nil { 7304 + return err 7305 + } 7306 + 7307 + if extra > 8192 { 7308 + return fmt.Errorf("t.References: array too large (%d)", extra) 7309 + } 7310 + 7311 + if maj != cbg.MajArray { 7312 + return fmt.Errorf("expected cbor array") 7313 + } 7314 + 7315 + if extra > 0 { 7316 + t.References = make([]string, extra) 7317 + } 7318 + 7319 + for i := 0; i < int(extra); i++ { 7320 + { 7321 + var maj byte 7322 + var extra uint64 7323 + var err error 7324 + _ = maj 7325 + _ = extra 7326 + _ = err 7327 + 7328 + { 7329 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 7330 + if err != nil { 7331 + return err 7332 + } 7333 + 7334 + t.References[i] = string(sval) 7335 + } 7336 + 7337 + } 7338 + } 7179 7339 7180 7340 default: 7181 7341 // Field doesn't exist on this type, so ignore it ··· 7194 7354 } 7195 7355 7196 7356 cw := cbg.NewCborWriter(w) 7197 - fieldCount := 5 7357 + fieldCount := 7 7358 + 7359 + if t.Mentions == nil { 7360 + fieldCount-- 7361 + } 7362 + 7363 + if t.References == nil { 7364 + fieldCount-- 7365 + } 7198 7366 7199 7367 if t.ReplyTo == nil { 7200 7368 fieldCount-- ··· 7301 7469 } 7302 7470 } 7303 7471 7472 + // t.Mentions ([]string) (slice) 7473 + if t.Mentions != nil { 7474 + 7475 + if len("mentions") > 1000000 { 7476 + return xerrors.Errorf("Value in field \"mentions\" was too long") 7477 + } 7478 + 7479 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("mentions"))); err != nil { 7480 + return err 7481 + } 7482 + if _, err := cw.WriteString(string("mentions")); err != nil { 7483 + return err 7484 + } 7485 + 7486 + if len(t.Mentions) > 8192 { 7487 + return xerrors.Errorf("Slice value in field t.Mentions was too long") 7488 + } 7489 + 7490 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Mentions))); err != nil { 7491 + return err 7492 + } 7493 + for _, v := range t.Mentions { 7494 + if len(v) > 1000000 { 7495 + return xerrors.Errorf("Value in field v was too long") 7496 + } 7497 + 7498 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 7499 + return err 7500 + } 7501 + if _, err := cw.WriteString(string(v)); err != nil { 7502 + return err 7503 + } 7504 + 7505 + } 7506 + } 7507 + 7304 7508 // t.CreatedAt (string) (string) 7305 7509 if len("createdAt") > 1000000 { 7306 7510 return xerrors.Errorf("Value in field \"createdAt\" was too long") ··· 7323 7527 if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 7324 7528 return err 7325 7529 } 7530 + 7531 + // t.References ([]string) (slice) 7532 + if t.References != nil { 7533 + 7534 + if len("references") > 1000000 { 7535 + return xerrors.Errorf("Value in field \"references\" was too long") 7536 + } 7537 + 7538 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("references"))); err != nil { 7539 + return err 7540 + } 7541 + if _, err := cw.WriteString(string("references")); err != nil { 7542 + return err 7543 + } 7544 + 7545 + if len(t.References) > 8192 { 7546 + return xerrors.Errorf("Slice value in field t.References was too long") 7547 + } 7548 + 7549 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.References))); err != nil { 7550 + return err 7551 + } 7552 + for _, v := range t.References { 7553 + if len(v) > 1000000 { 7554 + return xerrors.Errorf("Value in field v was too long") 7555 + } 7556 + 7557 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 7558 + return err 7559 + } 7560 + if _, err := cw.WriteString(string(v)); err != nil { 7561 + return err 7562 + } 7563 + 7564 + } 7565 + } 7326 7566 return nil 7327 7567 } 7328 7568 ··· 7351 7591 7352 7592 n := extra 7353 7593 7354 - nameBuf := make([]byte, 9) 7594 + nameBuf := make([]byte, 10) 7355 7595 for i := uint64(0); i < n; i++ { 7356 7596 nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 7357 7597 if err != nil { ··· 7419 7659 } 7420 7660 7421 7661 t.ReplyTo = (*string)(&sval) 7662 + } 7663 + } 7664 + // t.Mentions ([]string) (slice) 7665 + case "mentions": 7666 + 7667 + maj, extra, err = cr.ReadHeader() 7668 + if err != nil { 7669 + return err 7670 + } 7671 + 7672 + if extra > 8192 { 7673 + return fmt.Errorf("t.Mentions: array too large (%d)", extra) 7674 + } 7675 + 7676 + if maj != cbg.MajArray { 7677 + return fmt.Errorf("expected cbor array") 7678 + } 7679 + 7680 + if extra > 0 { 7681 + t.Mentions = make([]string, extra) 7682 + } 7683 + 7684 + for i := 0; i < int(extra); i++ { 7685 + { 7686 + var maj byte 7687 + var extra uint64 7688 + var err error 7689 + _ = maj 7690 + _ = extra 7691 + _ = err 7692 + 7693 + { 7694 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 7695 + if err != nil { 7696 + return err 7697 + } 7698 + 7699 + t.Mentions[i] = string(sval) 7700 + } 7701 + 7422 7702 } 7423 7703 } 7424 7704 // t.CreatedAt (string) (string) ··· 7431 7711 } 7432 7712 7433 7713 t.CreatedAt = string(sval) 7714 + } 7715 + // t.References ([]string) (slice) 7716 + case "references": 7717 + 7718 + maj, extra, err = cr.ReadHeader() 7719 + if err != nil { 7720 + return err 7721 + } 7722 + 7723 + if extra > 8192 { 7724 + return fmt.Errorf("t.References: array too large (%d)", extra) 7725 + } 7726 + 7727 + if maj != cbg.MajArray { 7728 + return fmt.Errorf("expected cbor array") 7729 + } 7730 + 7731 + if extra > 0 { 7732 + t.References = make([]string, extra) 7733 + } 7734 + 7735 + for i := 0; i < int(extra); i++ { 7736 + { 7737 + var maj byte 7738 + var extra uint64 7739 + var err error 7740 + _ = maj 7741 + _ = extra 7742 + _ = err 7743 + 7744 + { 7745 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 7746 + if err != nil { 7747 + return err 7748 + } 7749 + 7750 + t.References[i] = string(sval) 7751 + } 7752 + 7753 + } 7434 7754 } 7435 7755 7436 7756 default: ··· 7614 7934 } 7615 7935 7616 7936 cw := cbg.NewCborWriter(w) 7617 - fieldCount := 7 7937 + fieldCount := 9 7618 7938 7619 7939 if t.Body == nil { 7940 + fieldCount-- 7941 + } 7942 + 7943 + if t.Mentions == nil { 7944 + fieldCount-- 7945 + } 7946 + 7947 + if t.References == nil { 7620 7948 fieldCount-- 7621 7949 } 7622 7950 ··· 7760 8088 return err 7761 8089 } 7762 8090 8091 + // t.Mentions ([]string) (slice) 8092 + if t.Mentions != nil { 8093 + 8094 + if len("mentions") > 1000000 { 8095 + return xerrors.Errorf("Value in field \"mentions\" was too long") 8096 + } 8097 + 8098 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("mentions"))); err != nil { 8099 + return err 8100 + } 8101 + if _, err := cw.WriteString(string("mentions")); err != nil { 8102 + return err 8103 + } 8104 + 8105 + if len(t.Mentions) > 8192 { 8106 + return xerrors.Errorf("Slice value in field t.Mentions was too long") 8107 + } 8108 + 8109 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Mentions))); err != nil { 8110 + return err 8111 + } 8112 + for _, v := range t.Mentions { 8113 + if len(v) > 1000000 { 8114 + return xerrors.Errorf("Value in field v was too long") 8115 + } 8116 + 8117 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 8118 + return err 8119 + } 8120 + if _, err := cw.WriteString(string(v)); err != nil { 8121 + return err 8122 + } 8123 + 8124 + } 8125 + } 8126 + 7763 8127 // t.CreatedAt (string) (string) 7764 8128 if len("createdAt") > 1000000 { 7765 8129 return xerrors.Errorf("Value in field \"createdAt\" was too long") ··· 7782 8146 if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 7783 8147 return err 7784 8148 } 8149 + 8150 + // t.References ([]string) (slice) 8151 + if t.References != nil { 8152 + 8153 + if len("references") > 1000000 { 8154 + return xerrors.Errorf("Value in field \"references\" was too long") 8155 + } 8156 + 8157 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("references"))); err != nil { 8158 + return err 8159 + } 8160 + if _, err := cw.WriteString(string("references")); err != nil { 8161 + return err 8162 + } 8163 + 8164 + if len(t.References) > 8192 { 8165 + return xerrors.Errorf("Slice value in field t.References was too long") 8166 + } 8167 + 8168 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.References))); err != nil { 8169 + return err 8170 + } 8171 + for _, v := range t.References { 8172 + if len(v) > 1000000 { 8173 + return xerrors.Errorf("Value in field v was too long") 8174 + } 8175 + 8176 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 8177 + return err 8178 + } 8179 + if _, err := cw.WriteString(string(v)); err != nil { 8180 + return err 8181 + } 8182 + 8183 + } 8184 + } 7785 8185 return nil 7786 8186 } 7787 8187 ··· 7810 8210 7811 8211 n := extra 7812 8212 7813 - nameBuf := make([]byte, 9) 8213 + nameBuf := make([]byte, 10) 7814 8214 for i := uint64(0); i < n; i++ { 7815 8215 nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 7816 8216 if err != nil { ··· 7919 8319 } 7920 8320 } 7921 8321 8322 + } 8323 + // t.Mentions ([]string) (slice) 8324 + case "mentions": 8325 + 8326 + maj, extra, err = cr.ReadHeader() 8327 + if err != nil { 8328 + return err 8329 + } 8330 + 8331 + if extra > 8192 { 8332 + return fmt.Errorf("t.Mentions: array too large (%d)", extra) 8333 + } 8334 + 8335 + if maj != cbg.MajArray { 8336 + return fmt.Errorf("expected cbor array") 8337 + } 8338 + 8339 + if extra > 0 { 8340 + t.Mentions = make([]string, extra) 8341 + } 8342 + 8343 + for i := 0; i < int(extra); i++ { 8344 + { 8345 + var maj byte 8346 + var extra uint64 8347 + var err error 8348 + _ = maj 8349 + _ = extra 8350 + _ = err 8351 + 8352 + { 8353 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8354 + if err != nil { 8355 + return err 8356 + } 8357 + 8358 + t.Mentions[i] = string(sval) 8359 + } 8360 + 8361 + } 7922 8362 } 7923 8363 // t.CreatedAt (string) (string) 7924 8364 case "createdAt": ··· 7931 8371 7932 8372 t.CreatedAt = string(sval) 7933 8373 } 8374 + // t.References ([]string) (slice) 8375 + case "references": 8376 + 8377 + maj, extra, err = cr.ReadHeader() 8378 + if err != nil { 8379 + return err 8380 + } 8381 + 8382 + if extra > 8192 { 8383 + return fmt.Errorf("t.References: array too large (%d)", extra) 8384 + } 8385 + 8386 + if maj != cbg.MajArray { 8387 + return fmt.Errorf("expected cbor array") 8388 + } 8389 + 8390 + if extra > 0 { 8391 + t.References = make([]string, extra) 8392 + } 8393 + 8394 + for i := 0; i < int(extra); i++ { 8395 + { 8396 + var maj byte 8397 + var extra uint64 8398 + var err error 8399 + _ = maj 8400 + _ = extra 8401 + _ = err 8402 + 8403 + { 8404 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8405 + if err != nil { 8406 + return err 8407 + } 8408 + 8409 + t.References[i] = string(sval) 8410 + } 8411 + 8412 + } 8413 + } 7934 8414 7935 8415 default: 7936 8416 // Field doesn't exist on this type, so ignore it ··· 7949 8429 } 7950 8430 7951 8431 cw := cbg.NewCborWriter(w) 8432 + fieldCount := 6 7952 8433 7953 - if _, err := cw.Write([]byte{164}); err != nil { 8434 + if t.Mentions == nil { 8435 + fieldCount-- 8436 + } 8437 + 8438 + if t.References == nil { 8439 + fieldCount-- 8440 + } 8441 + 8442 + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 7954 8443 return err 7955 8444 } 7956 8445 ··· 8019 8508 return err 8020 8509 } 8021 8510 8511 + // t.Mentions ([]string) (slice) 8512 + if t.Mentions != nil { 8513 + 8514 + if len("mentions") > 1000000 { 8515 + return xerrors.Errorf("Value in field \"mentions\" was too long") 8516 + } 8517 + 8518 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("mentions"))); err != nil { 8519 + return err 8520 + } 8521 + if _, err := cw.WriteString(string("mentions")); err != nil { 8522 + return err 8523 + } 8524 + 8525 + if len(t.Mentions) > 8192 { 8526 + return xerrors.Errorf("Slice value in field t.Mentions was too long") 8527 + } 8528 + 8529 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Mentions))); err != nil { 8530 + return err 8531 + } 8532 + for _, v := range t.Mentions { 8533 + if len(v) > 1000000 { 8534 + return xerrors.Errorf("Value in field v was too long") 8535 + } 8536 + 8537 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 8538 + return err 8539 + } 8540 + if _, err := cw.WriteString(string(v)); err != nil { 8541 + return err 8542 + } 8543 + 8544 + } 8545 + } 8546 + 8022 8547 // t.CreatedAt (string) (string) 8023 8548 if len("createdAt") > 1000000 { 8024 8549 return xerrors.Errorf("Value in field \"createdAt\" was too long") ··· 8040 8565 } 8041 8566 if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 8042 8567 return err 8568 + } 8569 + 8570 + // t.References ([]string) (slice) 8571 + if t.References != nil { 8572 + 8573 + if len("references") > 1000000 { 8574 + return xerrors.Errorf("Value in field \"references\" was too long") 8575 + } 8576 + 8577 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("references"))); err != nil { 8578 + return err 8579 + } 8580 + if _, err := cw.WriteString(string("references")); err != nil { 8581 + return err 8582 + } 8583 + 8584 + if len(t.References) > 8192 { 8585 + return xerrors.Errorf("Slice value in field t.References was too long") 8586 + } 8587 + 8588 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.References))); err != nil { 8589 + return err 8590 + } 8591 + for _, v := range t.References { 8592 + if len(v) > 1000000 { 8593 + return xerrors.Errorf("Value in field v was too long") 8594 + } 8595 + 8596 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 8597 + return err 8598 + } 8599 + if _, err := cw.WriteString(string(v)); err != nil { 8600 + return err 8601 + } 8602 + 8603 + } 8043 8604 } 8044 8605 return nil 8045 8606 } ··· 8069 8630 8070 8631 n := extra 8071 8632 8072 - nameBuf := make([]byte, 9) 8633 + nameBuf := make([]byte, 10) 8073 8634 for i := uint64(0); i < n; i++ { 8074 8635 nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 8075 8636 if err != nil { ··· 8118 8679 8119 8680 t.LexiconTypeID = string(sval) 8120 8681 } 8682 + // t.Mentions ([]string) (slice) 8683 + case "mentions": 8684 + 8685 + maj, extra, err = cr.ReadHeader() 8686 + if err != nil { 8687 + return err 8688 + } 8689 + 8690 + if extra > 8192 { 8691 + return fmt.Errorf("t.Mentions: array too large (%d)", extra) 8692 + } 8693 + 8694 + if maj != cbg.MajArray { 8695 + return fmt.Errorf("expected cbor array") 8696 + } 8697 + 8698 + if extra > 0 { 8699 + t.Mentions = make([]string, extra) 8700 + } 8701 + 8702 + for i := 0; i < int(extra); i++ { 8703 + { 8704 + var maj byte 8705 + var extra uint64 8706 + var err error 8707 + _ = maj 8708 + _ = extra 8709 + _ = err 8710 + 8711 + { 8712 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8713 + if err != nil { 8714 + return err 8715 + } 8716 + 8717 + t.Mentions[i] = string(sval) 8718 + } 8719 + 8720 + } 8721 + } 8121 8722 // t.CreatedAt (string) (string) 8122 8723 case "createdAt": 8123 8724 ··· 8128 8729 } 8129 8730 8130 8731 t.CreatedAt = string(sval) 8732 + } 8733 + // t.References ([]string) (slice) 8734 + case "references": 8735 + 8736 + maj, extra, err = cr.ReadHeader() 8737 + if err != nil { 8738 + return err 8739 + } 8740 + 8741 + if extra > 8192 { 8742 + return fmt.Errorf("t.References: array too large (%d)", extra) 8743 + } 8744 + 8745 + if maj != cbg.MajArray { 8746 + return fmt.Errorf("expected cbor array") 8747 + } 8748 + 8749 + if extra > 0 { 8750 + t.References = make([]string, extra) 8751 + } 8752 + 8753 + for i := 0; i < int(extra); i++ { 8754 + { 8755 + var maj byte 8756 + var extra uint64 8757 + var err error 8758 + _ = maj 8759 + _ = extra 8760 + _ = err 8761 + 8762 + { 8763 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 8764 + if err != nil { 8765 + return err 8766 + } 8767 + 8768 + t.References[i] = string(sval) 8769 + } 8770 + 8771 + } 8131 8772 } 8132 8773 8133 8774 default:
+7 -5
api/tangled/issuecomment.go
··· 17 17 } // 18 18 // RECORDTYPE: RepoIssueComment 19 19 type RepoIssueComment struct { 20 - LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue.comment" cborgen:"$type,const=sh.tangled.repo.issue.comment"` 21 - Body string `json:"body" cborgen:"body"` 22 - CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 - Issue string `json:"issue" cborgen:"issue"` 24 - ReplyTo *string `json:"replyTo,omitempty" cborgen:"replyTo,omitempty"` 20 + LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue.comment" cborgen:"$type,const=sh.tangled.repo.issue.comment"` 21 + Body string `json:"body" cborgen:"body"` 22 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 + Issue string `json:"issue" cborgen:"issue"` 24 + Mentions []string `json:"mentions,omitempty" cborgen:"mentions,omitempty"` 25 + References []string `json:"references,omitempty" cborgen:"references,omitempty"` 26 + ReplyTo *string `json:"replyTo,omitempty" cborgen:"replyTo,omitempty"` 25 27 }
+34
api/tangled/pipelinecancelPipeline.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.pipeline.cancelPipeline 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + PipelineCancelPipelineNSID = "sh.tangled.pipeline.cancelPipeline" 15 + ) 16 + 17 + // PipelineCancelPipeline_Input is the input argument to a sh.tangled.pipeline.cancelPipeline call. 18 + type PipelineCancelPipeline_Input struct { 19 + // pipeline: pipeline at-uri 20 + Pipeline string `json:"pipeline" cborgen:"pipeline"` 21 + // repo: repo at-uri, spindle can't resolve repo from pipeline at-uri yet 22 + Repo string `json:"repo" cborgen:"repo"` 23 + // workflow: workflow name 24 + Workflow string `json:"workflow" cborgen:"workflow"` 25 + } 26 + 27 + // PipelineCancelPipeline calls the XRPC method "sh.tangled.pipeline.cancelPipeline". 28 + func PipelineCancelPipeline(ctx context.Context, c util.LexClient, input *PipelineCancelPipeline_Input) error { 29 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.pipeline.cancelPipeline", nil, input, nil); err != nil { 30 + return err 31 + } 32 + 33 + return nil 34 + }
+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 }
+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"`
+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 - }
+3 -2
appview/db/artifact.go
··· 8 8 "github.com/go-git/go-git/v5/plumbing" 9 9 "github.com/ipfs/go-cid" 10 10 "tangled.org/core/appview/models" 11 + "tangled.org/core/orm" 11 12 ) 12 13 13 14 func AddArtifact(e Execer, artifact models.Artifact) error { ··· 37 38 return err 38 39 } 39 40 40 - func GetArtifact(e Execer, filters ...filter) ([]models.Artifact, error) { 41 + func GetArtifact(e Execer, filters ...orm.Filter) ([]models.Artifact, error) { 41 42 var artifacts []models.Artifact 42 43 43 44 var conditions []string ··· 109 110 return artifacts, nil 110 111 } 111 112 112 - func DeleteArtifact(e Execer, filters ...filter) error { 113 + func DeleteArtifact(e Execer, filters ...orm.Filter) error { 113 114 var conditions []string 114 115 var args []any 115 116 for _, filter := range filters {
+4 -3
appview/db/collaborators.go
··· 6 6 "time" 7 7 8 8 "tangled.org/core/appview/models" 9 + "tangled.org/core/orm" 9 10 ) 10 11 11 12 func AddCollaborator(e Execer, c models.Collaborator) error { ··· 16 17 return err 17 18 } 18 19 19 - func DeleteCollaborator(e Execer, filters ...filter) error { 20 + func DeleteCollaborator(e Execer, filters ...orm.Filter) error { 20 21 var conditions []string 21 22 var args []any 22 23 for _, filter := range filters { ··· 58 59 return nil, nil 59 60 } 60 61 61 - return GetRepos(e, 0, FilterIn("at_uri", repoAts)) 62 + return GetRepos(e, 0, orm.FilterIn("at_uri", repoAts)) 62 63 } 63 64 64 - func GetCollaborators(e Execer, filters ...filter) ([]models.Collaborator, error) { 65 + func GetCollaborators(e Execer, filters ...orm.Filter) ([]models.Collaborator, error) { 65 66 var collaborators []models.Collaborator 66 67 var conditions []string 67 68 var args []any
+33 -137
appview/db/db.go
··· 3 3 import ( 4 4 "context" 5 5 "database/sql" 6 - "fmt" 7 6 "log/slog" 8 - "reflect" 9 7 "strings" 10 8 11 9 _ "github.com/mattn/go-sqlite3" 12 10 "tangled.org/core/log" 11 + "tangled.org/core/orm" 13 12 ) 14 13 15 14 type DB struct { ··· 561 560 email_notifications integer not null default 0 562 561 ); 563 562 563 + create table if not exists reference_links ( 564 + id integer primary key autoincrement, 565 + from_at text not null, 566 + to_at text not null, 567 + unique (from_at, to_at) 568 + ); 569 + 564 570 create table if not exists migrations ( 565 571 id integer primary key autoincrement, 566 572 name text unique ··· 569 575 -- indexes for better performance 570 576 create index if not exists idx_notifications_recipient_created on notifications(recipient_did, created desc); 571 577 create index if not exists idx_notifications_recipient_read on notifications(recipient_did, read); 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); 572 580 `) 573 581 if err != nil { 574 582 return nil, err 575 583 } 576 584 577 585 // run migrations 578 - runMigration(conn, logger, "add-description-to-repos", func(tx *sql.Tx) error { 586 + orm.RunMigration(conn, logger, "add-description-to-repos", func(tx *sql.Tx) error { 579 587 tx.Exec(` 580 588 alter table repos add column description text check (length(description) <= 200); 581 589 `) 582 590 return nil 583 591 }) 584 592 585 - runMigration(conn, logger, "add-rkey-to-pubkeys", func(tx *sql.Tx) error { 593 + orm.RunMigration(conn, logger, "add-rkey-to-pubkeys", func(tx *sql.Tx) error { 586 594 // add unconstrained column 587 595 _, err := tx.Exec(` 588 596 alter table public_keys ··· 605 613 return nil 606 614 }) 607 615 608 - runMigration(conn, logger, "add-rkey-to-comments", func(tx *sql.Tx) error { 616 + orm.RunMigration(conn, logger, "add-rkey-to-comments", func(tx *sql.Tx) error { 609 617 _, err := tx.Exec(` 610 618 alter table comments drop column comment_at; 611 619 alter table comments add column rkey text; ··· 613 621 return err 614 622 }) 615 623 616 - runMigration(conn, logger, "add-deleted-and-edited-to-issue-comments", func(tx *sql.Tx) error { 624 + orm.RunMigration(conn, logger, "add-deleted-and-edited-to-issue-comments", func(tx *sql.Tx) error { 617 625 _, err := tx.Exec(` 618 626 alter table comments add column deleted text; -- timestamp 619 627 alter table comments add column edited text; -- timestamp ··· 621 629 return err 622 630 }) 623 631 624 - runMigration(conn, logger, "add-source-info-to-pulls-and-submissions", func(tx *sql.Tx) error { 632 + orm.RunMigration(conn, logger, "add-source-info-to-pulls-and-submissions", func(tx *sql.Tx) error { 625 633 _, err := tx.Exec(` 626 634 alter table pulls add column source_branch text; 627 635 alter table pulls add column source_repo_at text; ··· 630 638 return err 631 639 }) 632 640 633 - runMigration(conn, logger, "add-source-to-repos", func(tx *sql.Tx) error { 641 + orm.RunMigration(conn, logger, "add-source-to-repos", func(tx *sql.Tx) error { 634 642 _, err := tx.Exec(` 635 643 alter table repos add column source text; 636 644 `) ··· 642 650 // 643 651 // [0]: https://sqlite.org/pragma.html#pragma_foreign_keys 644 652 conn.ExecContext(ctx, "pragma foreign_keys = off;") 645 - runMigration(conn, logger, "recreate-pulls-column-for-stacking-support", func(tx *sql.Tx) error { 653 + orm.RunMigration(conn, logger, "recreate-pulls-column-for-stacking-support", func(tx *sql.Tx) error { 646 654 _, err := tx.Exec(` 647 655 create table pulls_new ( 648 656 -- identifiers ··· 699 707 }) 700 708 conn.ExecContext(ctx, "pragma foreign_keys = on;") 701 709 702 - runMigration(conn, logger, "add-spindle-to-repos", func(tx *sql.Tx) error { 710 + orm.RunMigration(conn, logger, "add-spindle-to-repos", func(tx *sql.Tx) error { 703 711 tx.Exec(` 704 712 alter table repos add column spindle text; 705 713 `) ··· 709 717 // drop all knot secrets, add unique constraint to knots 710 718 // 711 719 // knots will henceforth use service auth for signed requests 712 - runMigration(conn, logger, "no-more-secrets", func(tx *sql.Tx) error { 720 + orm.RunMigration(conn, logger, "no-more-secrets", func(tx *sql.Tx) error { 713 721 _, err := tx.Exec(` 714 722 create table registrations_new ( 715 723 id integer primary key autoincrement, ··· 732 740 }) 733 741 734 742 // recreate and add rkey + created columns with default constraint 735 - runMigration(conn, logger, "rework-collaborators-table", func(tx *sql.Tx) error { 743 + orm.RunMigration(conn, logger, "rework-collaborators-table", func(tx *sql.Tx) error { 736 744 // create new table 737 745 // - repo_at instead of repo integer 738 746 // - rkey field ··· 786 794 return err 787 795 }) 788 796 789 - runMigration(conn, logger, "add-rkey-to-issues", func(tx *sql.Tx) error { 797 + orm.RunMigration(conn, logger, "add-rkey-to-issues", func(tx *sql.Tx) error { 790 798 _, err := tx.Exec(` 791 799 alter table issues add column rkey text not null default ''; 792 800 ··· 798 806 }) 799 807 800 808 // repurpose the read-only column to "needs-upgrade" 801 - runMigration(conn, logger, "rename-registrations-read-only-to-needs-upgrade", func(tx *sql.Tx) error { 809 + orm.RunMigration(conn, logger, "rename-registrations-read-only-to-needs-upgrade", func(tx *sql.Tx) error { 802 810 _, err := tx.Exec(` 803 811 alter table registrations rename column read_only to needs_upgrade; 804 812 `) ··· 806 814 }) 807 815 808 816 // require all knots to upgrade after the release of total xrpc 809 - runMigration(conn, logger, "migrate-knots-to-total-xrpc", func(tx *sql.Tx) error { 817 + orm.RunMigration(conn, logger, "migrate-knots-to-total-xrpc", func(tx *sql.Tx) error { 810 818 _, err := tx.Exec(` 811 819 update registrations set needs_upgrade = 1; 812 820 `) ··· 814 822 }) 815 823 816 824 // require all knots to upgrade after the release of total xrpc 817 - runMigration(conn, logger, "migrate-spindles-to-xrpc-owner", func(tx *sql.Tx) error { 825 + orm.RunMigration(conn, logger, "migrate-spindles-to-xrpc-owner", func(tx *sql.Tx) error { 818 826 _, err := tx.Exec(` 819 827 alter table spindles add column needs_upgrade integer not null default 0; 820 828 `) ··· 832 840 // 833 841 // disable foreign-keys for the next migration 834 842 conn.ExecContext(ctx, "pragma foreign_keys = off;") 835 - runMigration(conn, logger, "remove-issue-at-from-issues", func(tx *sql.Tx) error { 843 + orm.RunMigration(conn, logger, "remove-issue-at-from-issues", func(tx *sql.Tx) error { 836 844 _, err := tx.Exec(` 837 845 create table if not exists issues_new ( 838 846 -- identifiers ··· 902 910 // - new columns 903 911 // * column "reply_to" which can be any other comment 904 912 // * column "at-uri" which is a generated column 905 - runMigration(conn, logger, "rework-issue-comments", func(tx *sql.Tx) error { 913 + orm.RunMigration(conn, logger, "rework-issue-comments", func(tx *sql.Tx) error { 906 914 _, err := tx.Exec(` 907 915 create table if not exists issue_comments ( 908 916 -- identifiers ··· 962 970 // 963 971 // disable foreign-keys for the next migration 964 972 conn.ExecContext(ctx, "pragma foreign_keys = off;") 965 - runMigration(conn, logger, "add-at-uri-to-pulls", func(tx *sql.Tx) error { 973 + orm.RunMigration(conn, logger, "add-at-uri-to-pulls", func(tx *sql.Tx) error { 966 974 _, err := tx.Exec(` 967 975 create table if not exists pulls_new ( 968 976 -- identifiers ··· 1043 1051 // 1044 1052 // disable foreign-keys for the next migration 1045 1053 conn.ExecContext(ctx, "pragma foreign_keys = off;") 1046 - runMigration(conn, logger, "remove-repo-at-pull-id-from-pull-submissions", func(tx *sql.Tx) error { 1054 + orm.RunMigration(conn, logger, "remove-repo-at-pull-id-from-pull-submissions", func(tx *sql.Tx) error { 1047 1055 _, err := tx.Exec(` 1048 1056 create table if not exists pull_submissions_new ( 1049 1057 -- identifiers ··· 1097 1105 1098 1106 // knots may report the combined patch for a comparison, we can store that on the appview side 1099 1107 // (but not on the pds record), because calculating the combined patch requires a git index 1100 - runMigration(conn, logger, "add-combined-column-submissions", func(tx *sql.Tx) error { 1108 + orm.RunMigration(conn, logger, "add-combined-column-submissions", func(tx *sql.Tx) error { 1101 1109 _, err := tx.Exec(` 1102 1110 alter table pull_submissions add column combined text; 1103 1111 `) 1104 1112 return err 1105 1113 }) 1106 1114 1107 - runMigration(conn, logger, "add-pronouns-profile", func(tx *sql.Tx) error { 1115 + orm.RunMigration(conn, logger, "add-pronouns-profile", func(tx *sql.Tx) error { 1108 1116 _, err := tx.Exec(` 1109 1117 alter table profile add column pronouns text; 1110 1118 `) 1111 1119 return err 1112 1120 }) 1113 1121 1114 - runMigration(conn, logger, "add-meta-column-repos", func(tx *sql.Tx) error { 1122 + orm.RunMigration(conn, logger, "add-meta-column-repos", func(tx *sql.Tx) error { 1115 1123 _, err := tx.Exec(` 1116 1124 alter table repos add column website text; 1117 1125 alter table repos add column topics text; ··· 1119 1127 return err 1120 1128 }) 1121 1129 1122 - runMigration(conn, logger, "add-usermentioned-preference", func(tx *sql.Tx) error { 1130 + orm.RunMigration(conn, logger, "add-usermentioned-preference", func(tx *sql.Tx) error { 1123 1131 _, err := tx.Exec(` 1124 1132 alter table notification_preferences add column user_mentioned integer not null default 1; 1125 1133 `) ··· 1127 1135 }) 1128 1136 1129 1137 // remove the foreign key constraints from stars. 1130 - runMigration(conn, logger, "generalize-stars-subject", func(tx *sql.Tx) error { 1138 + orm.RunMigration(conn, logger, "generalize-stars-subject", func(tx *sql.Tx) error { 1131 1139 _, err := tx.Exec(` 1132 1140 create table stars_new ( 1133 1141 id integer primary key autoincrement, ··· 1171 1179 }, nil 1172 1180 } 1173 1181 1174 - type migrationFn = func(*sql.Tx) error 1175 - 1176 - func runMigration(c *sql.Conn, logger *slog.Logger, name string, migrationFn migrationFn) error { 1177 - logger = logger.With("migration", name) 1178 - 1179 - tx, err := c.BeginTx(context.Background(), nil) 1180 - if err != nil { 1181 - return err 1182 - } 1183 - defer tx.Rollback() 1184 - 1185 - var exists bool 1186 - err = tx.QueryRow("select exists (select 1 from migrations where name = ?)", name).Scan(&exists) 1187 - if err != nil { 1188 - return err 1189 - } 1190 - 1191 - if !exists { 1192 - // run migration 1193 - err = migrationFn(tx) 1194 - if err != nil { 1195 - logger.Error("failed to run migration", "err", err) 1196 - return err 1197 - } 1198 - 1199 - // mark migration as complete 1200 - _, err = tx.Exec("insert into migrations (name) values (?)", name) 1201 - if err != nil { 1202 - logger.Error("failed to mark migration as complete", "err", err) 1203 - return err 1204 - } 1205 - 1206 - // commit the transaction 1207 - if err := tx.Commit(); err != nil { 1208 - return err 1209 - } 1210 - 1211 - logger.Info("migration applied successfully") 1212 - } else { 1213 - logger.Warn("skipped migration, already applied") 1214 - } 1215 - 1216 - return nil 1217 - } 1218 - 1219 1182 func (d *DB) Close() error { 1220 1183 return d.DB.Close() 1221 1184 } 1222 - 1223 - type filter struct { 1224 - key string 1225 - arg any 1226 - cmp string 1227 - } 1228 - 1229 - func newFilter(key, cmp string, arg any) filter { 1230 - return filter{ 1231 - key: key, 1232 - arg: arg, 1233 - cmp: cmp, 1234 - } 1235 - } 1236 - 1237 - func FilterEq(key string, arg any) filter { return newFilter(key, "=", arg) } 1238 - func FilterNotEq(key string, arg any) filter { return newFilter(key, "<>", arg) } 1239 - func FilterGte(key string, arg any) filter { return newFilter(key, ">=", arg) } 1240 - func FilterLte(key string, arg any) filter { return newFilter(key, "<=", arg) } 1241 - func FilterIs(key string, arg any) filter { return newFilter(key, "is", arg) } 1242 - func FilterIsNot(key string, arg any) filter { return newFilter(key, "is not", arg) } 1243 - func FilterIn(key string, arg any) filter { return newFilter(key, "in", arg) } 1244 - func FilterLike(key string, arg any) filter { return newFilter(key, "like", arg) } 1245 - func FilterNotLike(key string, arg any) filter { return newFilter(key, "not like", arg) } 1246 - func FilterContains(key string, arg any) filter { 1247 - return newFilter(key, "like", fmt.Sprintf("%%%v%%", arg)) 1248 - } 1249 - 1250 - func (f filter) Condition() string { 1251 - rv := reflect.ValueOf(f.arg) 1252 - kind := rv.Kind() 1253 - 1254 - // if we have `FilterIn(k, [1, 2, 3])`, compile it down to `k in (?, ?, ?)` 1255 - if (kind == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8) || kind == reflect.Array { 1256 - if rv.Len() == 0 { 1257 - // always false 1258 - return "1 = 0" 1259 - } 1260 - 1261 - placeholders := make([]string, rv.Len()) 1262 - for i := range placeholders { 1263 - placeholders[i] = "?" 1264 - } 1265 - 1266 - return fmt.Sprintf("%s %s (%s)", f.key, f.cmp, strings.Join(placeholders, ", ")) 1267 - } 1268 - 1269 - return fmt.Sprintf("%s %s ?", f.key, f.cmp) 1270 - } 1271 - 1272 - func (f filter) Arg() []any { 1273 - rv := reflect.ValueOf(f.arg) 1274 - kind := rv.Kind() 1275 - if (kind == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8) || kind == reflect.Array { 1276 - if rv.Len() == 0 { 1277 - return nil 1278 - } 1279 - 1280 - out := make([]any, rv.Len()) 1281 - for i := range rv.Len() { 1282 - out[i] = rv.Index(i).Interface() 1283 - } 1284 - return out 1285 - } 1286 - 1287 - return []any{f.arg} 1288 - }
+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) {
+93 -36
appview/db/issues.go
··· 10 10 "time" 11 11 12 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 + "tangled.org/core/api/tangled" 13 14 "tangled.org/core/appview/models" 14 15 "tangled.org/core/appview/pagination" 16 + "tangled.org/core/orm" 15 17 ) 16 18 17 19 func PutIssue(tx *sql.Tx, issue *models.Issue) error { ··· 26 28 27 29 issues, err := GetIssues( 28 30 tx, 29 - FilterEq("did", issue.Did), 30 - FilterEq("rkey", issue.Rkey), 31 + orm.FilterEq("did", issue.Did), 32 + orm.FilterEq("rkey", issue.Rkey), 31 33 ) 32 34 switch { 33 35 case err != nil: ··· 69 71 returning rowid, issue_id 70 72 `, issue.RepoAt, issue.Did, issue.Rkey, newIssueId, issue.Title, issue.Body) 71 73 72 - return row.Scan(&issue.Id, &issue.IssueId) 74 + err = row.Scan(&issue.Id, &issue.IssueId) 75 + if err != nil { 76 + return fmt.Errorf("scan row: %w", err) 77 + } 78 + 79 + if err := putReferences(tx, issue.AtUri(), issue.References); err != nil { 80 + return fmt.Errorf("put reference_links: %w", err) 81 + } 82 + return nil 73 83 } 74 84 75 85 func updateIssue(tx *sql.Tx, issue *models.Issue) error { ··· 79 89 set title = ?, body = ?, edited = ? 80 90 where did = ? and rkey = ? 81 91 `, issue.Title, issue.Body, time.Now().Format(time.RFC3339), issue.Did, issue.Rkey) 82 - return err 92 + if err != nil { 93 + return err 94 + } 95 + 96 + if err := putReferences(tx, issue.AtUri(), issue.References); err != nil { 97 + return fmt.Errorf("put reference_links: %w", err) 98 + } 99 + return nil 83 100 } 84 101 85 - func GetIssuesPaginated(e Execer, page pagination.Page, filters ...filter) ([]models.Issue, error) { 102 + func GetIssuesPaginated(e Execer, page pagination.Page, filters ...orm.Filter) ([]models.Issue, error) { 86 103 issueMap := make(map[string]*models.Issue) // at-uri -> issue 87 104 88 105 var conditions []string ··· 98 115 whereClause = " where " + strings.Join(conditions, " and ") 99 116 } 100 117 101 - pLower := FilterGte("row_num", page.Offset+1) 102 - pUpper := FilterLte("row_num", page.Offset+page.Limit) 118 + pLower := orm.FilterGte("row_num", page.Offset+1) 119 + pUpper := orm.FilterLte("row_num", page.Offset+page.Limit) 103 120 104 121 pageClause := "" 105 122 if page.Limit > 0 { ··· 189 206 repoAts = append(repoAts, string(issue.RepoAt)) 190 207 } 191 208 192 - repos, err := GetRepos(e, 0, FilterIn("at_uri", repoAts)) 209 + repos, err := GetRepos(e, 0, orm.FilterIn("at_uri", repoAts)) 193 210 if err != nil { 194 211 return nil, fmt.Errorf("failed to build repo mappings: %w", err) 195 212 } ··· 212 229 // collect comments 213 230 issueAts := slices.Collect(maps.Keys(issueMap)) 214 231 215 - comments, err := GetIssueComments(e, FilterIn("issue_at", issueAts)) 232 + comments, err := GetIssueComments(e, orm.FilterIn("issue_at", issueAts)) 216 233 if err != nil { 217 234 return nil, fmt.Errorf("failed to query comments: %w", err) 218 235 } ··· 224 241 } 225 242 226 243 // collect allLabels for each issue 227 - allLabels, err := GetLabels(e, FilterIn("subject", issueAts)) 244 + allLabels, err := GetLabels(e, orm.FilterIn("subject", issueAts)) 228 245 if err != nil { 229 246 return nil, fmt.Errorf("failed to query labels: %w", err) 230 247 } ··· 234 251 } 235 252 } 236 253 254 + // collect references for each issue 255 + allReferencs, err := GetReferencesAll(e, orm.FilterIn("from_at", issueAts)) 256 + if err != nil { 257 + return nil, fmt.Errorf("failed to query reference_links: %w", err) 258 + } 259 + for issueAt, references := range allReferencs { 260 + if issue, ok := issueMap[issueAt.String()]; ok { 261 + issue.References = references 262 + } 263 + } 264 + 237 265 var issues []models.Issue 238 266 for _, i := range issueMap { 239 267 issues = append(issues, *i) ··· 250 278 issues, err := GetIssuesPaginated( 251 279 e, 252 280 pagination.Page{}, 253 - FilterEq("repo_at", repoAt), 254 - FilterEq("issue_id", issueId), 281 + orm.FilterEq("repo_at", repoAt), 282 + orm.FilterEq("issue_id", issueId), 255 283 ) 256 284 if err != nil { 257 285 return nil, err ··· 263 291 return &issues[0], nil 264 292 } 265 293 266 - func GetIssues(e Execer, filters ...filter) ([]models.Issue, error) { 294 + func GetIssues(e Execer, filters ...orm.Filter) ([]models.Issue, error) { 267 295 return GetIssuesPaginated(e, pagination.Page{}, filters...) 268 296 } 269 297 ··· 271 299 func GetIssueIDs(e Execer, opts models.IssueSearchOptions) ([]int64, error) { 272 300 var ids []int64 273 301 274 - var filters []filter 302 + var filters []orm.Filter 275 303 openValue := 0 276 304 if opts.IsOpen { 277 305 openValue = 1 278 306 } 279 - filters = append(filters, FilterEq("open", openValue)) 307 + filters = append(filters, orm.FilterEq("open", openValue)) 280 308 if opts.RepoAt != "" { 281 - filters = append(filters, FilterEq("repo_at", opts.RepoAt)) 309 + filters = append(filters, orm.FilterEq("repo_at", opts.RepoAt)) 282 310 } 283 311 284 312 var conditions []string ··· 323 351 return ids, nil 324 352 } 325 353 326 - func AddIssueComment(e Execer, c models.IssueComment) (int64, error) { 327 - result, err := e.Exec( 354 + func AddIssueComment(tx *sql.Tx, c models.IssueComment) (int64, error) { 355 + result, err := tx.Exec( 328 356 `insert into issue_comments ( 329 357 did, 330 358 rkey, ··· 363 391 return 0, err 364 392 } 365 393 394 + if err := putReferences(tx, c.AtUri(), c.References); err != nil { 395 + return 0, fmt.Errorf("put reference_links: %w", err) 396 + } 397 + 366 398 return id, nil 367 399 } 368 400 369 - func DeleteIssueComments(e Execer, filters ...filter) error { 401 + func DeleteIssueComments(e Execer, filters ...orm.Filter) error { 370 402 var conditions []string 371 403 var args []any 372 404 for _, filter := range filters { ··· 385 417 return err 386 418 } 387 419 388 - func GetIssueComments(e Execer, filters ...filter) ([]models.IssueComment, error) { 389 - var comments []models.IssueComment 420 + func GetIssueComments(e Execer, filters ...orm.Filter) ([]models.IssueComment, error) { 421 + commentMap := make(map[string]*models.IssueComment) 390 422 391 423 var conditions []string 392 424 var args []any ··· 420 452 if err != nil { 421 453 return nil, err 422 454 } 455 + defer rows.Close() 423 456 424 457 for rows.Next() { 425 458 var comment models.IssueComment ··· 465 498 comment.ReplyTo = &replyTo.V 466 499 } 467 500 468 - comments = append(comments, comment) 501 + atUri := comment.AtUri().String() 502 + commentMap[atUri] = &comment 469 503 } 470 504 471 505 if err = rows.Err(); err != nil { 472 506 return nil, err 473 507 } 474 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 + 475 530 return comments, nil 476 531 } 477 532 478 - func DeleteIssues(e Execer, filters ...filter) error { 479 - var conditions []string 480 - var args []any 481 - for _, filter := range filters { 482 - conditions = append(conditions, filter.Condition()) 483 - args = append(args, filter.Arg()...) 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) 484 542 } 485 543 486 - whereClause := "" 487 - if conditions != nil { 488 - 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) 489 548 } 490 549 491 - query := fmt.Sprintf(`delete from issues %s`, whereClause) 492 - _, err := e.Exec(query, args...) 493 - return err 550 + return nil 494 551 } 495 552 496 - func CloseIssues(e Execer, filters ...filter) error { 553 + func CloseIssues(e Execer, filters ...orm.Filter) error { 497 554 var conditions []string 498 555 var args []any 499 556 for _, filter := range filters { ··· 511 568 return err 512 569 } 513 570 514 - func ReopenIssues(e Execer, filters ...filter) error { 571 + func ReopenIssues(e Execer, filters ...orm.Filter) error { 515 572 var conditions []string 516 573 var args []any 517 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)
+14 -13
appview/db/notifications.go
··· 11 11 "github.com/bluesky-social/indigo/atproto/syntax" 12 12 "tangled.org/core/appview/models" 13 13 "tangled.org/core/appview/pagination" 14 + "tangled.org/core/orm" 14 15 ) 15 16 16 17 func CreateNotification(e Execer, notification *models.Notification) error { ··· 44 45 } 45 46 46 47 // GetNotificationsPaginated retrieves notifications with filters and pagination 47 - func GetNotificationsPaginated(e Execer, page pagination.Page, filters ...filter) ([]*models.Notification, error) { 48 + func GetNotificationsPaginated(e Execer, page pagination.Page, filters ...orm.Filter) ([]*models.Notification, error) { 48 49 var conditions []string 49 50 var args []any 50 51 ··· 113 114 } 114 115 115 116 // GetNotificationsWithEntities retrieves notifications with their related entities 116 - func GetNotificationsWithEntities(e Execer, page pagination.Page, filters ...filter) ([]*models.NotificationWithEntity, error) { 117 + func GetNotificationsWithEntities(e Execer, page pagination.Page, filters ...orm.Filter) ([]*models.NotificationWithEntity, error) { 117 118 var conditions []string 118 119 var args []any 119 120 ··· 256 257 } 257 258 258 259 // GetNotifications retrieves notifications with filters 259 - func GetNotifications(e Execer, filters ...filter) ([]*models.Notification, error) { 260 + func GetNotifications(e Execer, filters ...orm.Filter) ([]*models.Notification, error) { 260 261 return GetNotificationsPaginated(e, pagination.FirstPage(), filters...) 261 262 } 262 263 263 - func CountNotifications(e Execer, filters ...filter) (int64, error) { 264 + func CountNotifications(e Execer, filters ...orm.Filter) (int64, error) { 264 265 var conditions []string 265 266 var args []any 266 267 for _, filter := range filters { ··· 285 286 } 286 287 287 288 func MarkNotificationRead(e Execer, notificationID int64, userDID string) error { 288 - idFilter := FilterEq("id", notificationID) 289 - recipientFilter := FilterEq("recipient_did", userDID) 289 + idFilter := orm.FilterEq("id", notificationID) 290 + recipientFilter := orm.FilterEq("recipient_did", userDID) 290 291 291 292 query := fmt.Sprintf(` 292 293 UPDATE notifications ··· 314 315 } 315 316 316 317 func MarkAllNotificationsRead(e Execer, userDID string) error { 317 - recipientFilter := FilterEq("recipient_did", userDID) 318 - readFilter := FilterEq("read", 0) 318 + recipientFilter := orm.FilterEq("recipient_did", userDID) 319 + readFilter := orm.FilterEq("read", 0) 319 320 320 321 query := fmt.Sprintf(` 321 322 UPDATE notifications ··· 334 335 } 335 336 336 337 func DeleteNotification(e Execer, notificationID int64, userDID string) error { 337 - idFilter := FilterEq("id", notificationID) 338 - recipientFilter := FilterEq("recipient_did", userDID) 338 + idFilter := orm.FilterEq("id", notificationID) 339 + recipientFilter := orm.FilterEq("recipient_did", userDID) 339 340 340 341 query := fmt.Sprintf(` 341 342 DELETE FROM notifications ··· 362 363 } 363 364 364 365 func GetNotificationPreference(e Execer, userDid string) (*models.NotificationPreferences, error) { 365 - prefs, err := GetNotificationPreferences(e, FilterEq("user_did", userDid)) 366 + prefs, err := GetNotificationPreferences(e, orm.FilterEq("user_did", userDid)) 366 367 if err != nil { 367 368 return nil, err 368 369 } ··· 375 376 return p, nil 376 377 } 377 378 378 - func GetNotificationPreferences(e Execer, filters ...filter) (map[syntax.DID]*models.NotificationPreferences, error) { 379 + func GetNotificationPreferences(e Execer, filters ...orm.Filter) (map[syntax.DID]*models.NotificationPreferences, error) { 379 380 prefsMap := make(map[syntax.DID]*models.NotificationPreferences) 380 381 381 382 var conditions []string ··· 483 484 484 485 func (d *DB) ClearOldNotifications(ctx context.Context, olderThan time.Duration) error { 485 486 cutoff := time.Now().Add(-olderThan) 486 - createdFilter := FilterLte("created", cutoff) 487 + createdFilter := orm.FilterLte("created", cutoff) 487 488 488 489 query := fmt.Sprintf(` 489 490 DELETE FROM notifications
+12 -11
appview/db/pipeline.go
··· 6 6 "strings" 7 7 "time" 8 8 9 + "github.com/bluesky-social/indigo/atproto/syntax" 9 10 "tangled.org/core/appview/models" 11 + "tangled.org/core/orm" 10 12 ) 11 13 12 - func GetPipelines(e Execer, filters ...filter) ([]models.Pipeline, error) { 14 + func GetPipelines(e Execer, filters ...orm.Filter) ([]models.Pipeline, error) { 13 15 var pipelines []models.Pipeline 14 16 15 17 var conditions []string ··· 168 170 169 171 // this is a mega query, but the most useful one: 170 172 // get N pipelines, for each one get the latest status of its N workflows 171 - func GetPipelineStatuses(e Execer, limit int, filters ...filter) ([]models.Pipeline, error) { 173 + func GetPipelineStatuses(e Execer, limit int, filters ...orm.Filter) ([]models.Pipeline, error) { 172 174 var conditions []string 173 175 var args []any 174 176 for _, filter := range filters { 175 - filter.key = "p." + filter.key // the table is aliased in the query to `p` 177 + filter.Key = "p." + filter.Key // the table is aliased in the query to `p` 176 178 conditions = append(conditions, filter.Condition()) 177 179 args = append(args, filter.Arg()...) 178 180 } ··· 215 217 } 216 218 defer rows.Close() 217 219 218 - pipelines := make(map[string]models.Pipeline) 220 + pipelines := make(map[syntax.ATURI]models.Pipeline) 219 221 for rows.Next() { 220 222 var p models.Pipeline 221 223 var t models.Trigger ··· 252 254 p.Trigger = &t 253 255 p.Statuses = make(map[string]models.WorkflowStatus) 254 256 255 - k := fmt.Sprintf("%s/%s", p.Knot, p.Rkey) 256 - pipelines[k] = p 257 + pipelines[p.AtUri()] = p 257 258 } 258 259 259 260 // get all statuses ··· 264 265 conditions = nil 265 266 args = nil 266 267 for _, p := range pipelines { 267 - knotFilter := FilterEq("pipeline_knot", p.Knot) 268 - rkeyFilter := FilterEq("pipeline_rkey", p.Rkey) 268 + knotFilter := orm.FilterEq("pipeline_knot", p.Knot) 269 + rkeyFilter := orm.FilterEq("pipeline_rkey", p.Rkey) 269 270 conditions = append(conditions, fmt.Sprintf("(%s and %s)", knotFilter.Condition(), rkeyFilter.Condition())) 270 271 args = append(args, p.Knot) 271 272 args = append(args, p.Rkey) ··· 313 314 return nil, fmt.Errorf("invalid status created timestamp %q: %w", created, err) 314 315 } 315 316 316 - key := fmt.Sprintf("%s/%s", ps.PipelineKnot, ps.PipelineRkey) 317 + pipelineAt := ps.PipelineAt() 317 318 318 319 // extract 319 - pipeline, ok := pipelines[key] 320 + pipeline, ok := pipelines[pipelineAt] 320 321 if !ok { 321 322 continue 322 323 } ··· 330 331 331 332 // reassign 332 333 pipeline.Statuses[ps.Workflow] = statuses 333 - pipelines[key] = pipeline 334 + pipelines[pipelineAt] = pipeline 334 335 } 335 336 336 337 var all []models.Pipeline
+11 -5
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 } ··· 199 200 return tx.Commit() 200 201 } 201 202 202 - func GetProfiles(e Execer, filters ...filter) (map[string]*models.Profile, error) { 203 + func GetProfiles(e Execer, filters ...orm.Filter) (map[string]*models.Profile, error) { 203 204 var conditions []string 204 205 var args []any 205 206 for _, filter := range filters { ··· 229 230 if err != nil { 230 231 return nil, err 231 232 } 233 + defer rows.Close() 232 234 233 235 profileMap := make(map[string]*models.Profile) 234 236 for rows.Next() { ··· 269 271 if err != nil { 270 272 return nil, err 271 273 } 274 + defer rows.Close() 275 + 272 276 idxs := make(map[string]int) 273 277 for did := range profileMap { 274 278 idxs[did] = 0 ··· 289 293 if err != nil { 290 294 return nil, err 291 295 } 296 + defer rows.Close() 297 + 292 298 idxs = make(map[string]int) 293 299 for did := range profileMap { 294 300 idxs[did] = 0 ··· 441 447 } 442 448 443 449 // ensure all pinned repos are either own repos or collaborating repos 444 - repos, err := GetRepos(e, 0, FilterEq("did", profile.Did)) 450 + repos, err := GetRepos(e, 0, orm.FilterEq("did", profile.Did)) 445 451 if err != nil { 446 452 log.Printf("getting repos for %s: %s", profile.Did, err) 447 453 }
+69 -24
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 { ··· 93 94 insert into pull_submissions (pull_at, round_number, patch, combined, source_rev) 94 95 values (?, ?, ?, ?, ?) 95 96 `, pull.AtUri(), 0, pull.Submissions[0].Patch, pull.Submissions[0].Combined, pull.Submissions[0].SourceRev) 96 - return err 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) { ··· 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 ··· 221 230 for _, p := range pulls { 222 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 284 303 func GetPullIDs(e Execer, opts models.PullSearchOptions) ([]int64, error) { 285 304 var ids []int64 286 305 287 - var filters []filter 288 - filters = append(filters, FilterEq("state", opts.State)) 306 + var filters []orm.Filter 307 + filters = append(filters, orm.FilterEq("state", opts.State)) 289 308 if opts.RepoAt != "" { 290 - filters = append(filters, FilterEq("repo_at", opts.RepoAt)) 309 + filters = append(filters, orm.FilterEq("repo_at", opts.RepoAt)) 291 310 } 292 311 293 312 var conditions []string ··· 343 362 } 344 363 345 364 func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*models.Pull, error) { 346 - pulls, err := GetPullsWithLimit(e, 1, FilterEq("repo_at", repoAt), FilterEq("pull_id", pullId)) 365 + pulls, err := GetPullsWithLimit(e, 1, orm.FilterEq("repo_at", repoAt), orm.FilterEq("pull_id", pullId)) 347 366 if err != nil { 348 367 return nil, err 349 368 } ··· 355 374 } 356 375 357 376 // mapping from pull -> pull submissions 358 - 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) { 359 378 var conditions []string 360 379 var args []any 361 380 for _, filter := range filters { ··· 430 449 431 450 // Get comments for all submissions using GetPullComments 432 451 submissionIds := slices.Collect(maps.Keys(submissionMap)) 433 - comments, err := GetPullComments(e, FilterIn("submission_id", submissionIds)) 452 + comments, err := GetPullComments(e, orm.FilterIn("submission_id", submissionIds)) 434 453 if err != nil { 435 - return nil, err 454 + return nil, fmt.Errorf("failed to get pull comments: %w", err) 436 455 } 437 456 for _, comment := range comments { 438 457 if submission, ok := submissionMap[comment.SubmissionId]; ok { ··· 456 475 return m, nil 457 476 } 458 477 459 - func GetPullComments(e Execer, filters ...filter) ([]models.PullComment, error) { 478 + func GetPullComments(e Execer, filters ...orm.Filter) ([]models.PullComment, error) { 460 479 var conditions []string 461 480 var args []any 462 481 for _, filter := range filters { ··· 492 511 } 493 512 defer rows.Close() 494 513 495 - var comments []models.PullComment 514 + commentMap := make(map[string]*models.PullComment) 496 515 for rows.Next() { 497 516 var comment models.PullComment 498 517 var createdAt string ··· 514 533 comment.Created = t 515 534 } 516 535 517 - comments = append(comments, comment) 536 + atUri := comment.AtUri().String() 537 + commentMap[atUri] = &comment 518 538 } 519 539 520 540 if err := rows.Err(); err != nil { 521 541 return nil, err 522 542 } 523 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 + 524 565 return comments, nil 525 566 } 526 567 ··· 600 641 return pulls, nil 601 642 } 602 643 603 - func NewPullComment(e Execer, comment *models.PullComment) (int64, error) { 644 + func NewPullComment(tx *sql.Tx, comment *models.PullComment) (int64, error) { 604 645 query := `insert into pull_comments (owner_did, repo_at, submission_id, comment_at, pull_id, body) values (?, ?, ?, ?, ?, ?)` 605 - res, err := e.Exec( 646 + res, err := tx.Exec( 606 647 query, 607 648 comment.OwnerDid, 608 649 comment.RepoAt, ··· 618 659 i, err := res.LastInsertId() 619 660 if err != nil { 620 661 return 0, err 662 + } 663 + 664 + if err := putReferences(tx, comment.AtUri(), comment.References); err != nil { 665 + return 0, fmt.Errorf("put reference_links: %w", err) 621 666 } 622 667 623 668 return i, nil ··· 664 709 return err 665 710 } 666 711 667 - func SetPullParentChangeId(e Execer, parentChangeId string, filters ...filter) error { 712 + func SetPullParentChangeId(e Execer, parentChangeId string, filters ...orm.Filter) error { 668 713 var conditions []string 669 714 var args []any 670 715 ··· 688 733 689 734 // Only used when stacking to update contents in the event of a rebase (the interdiff should be empty). 690 735 // otherwise submissions are immutable 691 - func UpdatePull(e Execer, newPatch, sourceRev string, filters ...filter) error { 736 + func UpdatePull(e Execer, newPatch, sourceRev string, filters ...orm.Filter) error { 692 737 var conditions []string 693 738 var args []any 694 739 ··· 746 791 func GetStack(e Execer, stackId string) (models.Stack, error) { 747 792 unorderedPulls, err := GetPulls( 748 793 e, 749 - FilterEq("stack_id", stackId), 750 - FilterNotEq("state", models.PullDeleted), 794 + orm.FilterEq("stack_id", stackId), 795 + orm.FilterNotEq("state", models.PullDeleted), 751 796 ) 752 797 if err != nil { 753 798 return nil, err ··· 791 836 func GetAbandonedPulls(e Execer, stackId string) ([]*models.Pull, error) { 792 837 pulls, err := GetPulls( 793 838 e, 794 - FilterEq("stack_id", stackId), 795 - FilterEq("state", models.PullDeleted), 839 + orm.FilterEq("stack_id", stackId), 840 + orm.FilterEq("state", models.PullDeleted), 796 841 ) 797 842 if err != nil { 798 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 {
+28 -34
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 ··· 83 56 limitClause, 84 57 ) 85 58 rows, err := e.Query(repoQuery, args...) 86 - 87 59 if err != nil { 88 60 return nil, fmt.Errorf("failed to execute repo query: %w ", err) 89 61 } 62 + defer rows.Close() 90 63 91 64 for rows.Next() { 92 65 var repo models.Repo ··· 155 128 if err != nil { 156 129 return nil, fmt.Errorf("failed to execute labels query: %w ", err) 157 130 } 131 + defer rows.Close() 132 + 158 133 for rows.Next() { 159 134 var repoat, labelat string 160 135 if err := rows.Scan(&repoat, &labelat); err != nil { ··· 192 167 if err != nil { 193 168 return nil, fmt.Errorf("failed to execute lang query: %w ", err) 194 169 } 170 + defer rows.Close() 171 + 195 172 for rows.Next() { 196 173 var repoat, lang string 197 174 if err := rows.Scan(&repoat, &lang); err != nil { ··· 218 195 if err != nil { 219 196 return nil, fmt.Errorf("failed to execute star-count query: %w ", err) 220 197 } 198 + defer rows.Close() 199 + 221 200 for rows.Next() { 222 201 var repoat string 223 202 var count int ··· 247 226 if err != nil { 248 227 return nil, fmt.Errorf("failed to execute issue-count query: %w ", err) 249 228 } 229 + defer rows.Close() 230 + 250 231 for rows.Next() { 251 232 var repoat string 252 233 var open, closed int ··· 288 269 if err != nil { 289 270 return nil, fmt.Errorf("failed to execute pulls-count query: %w ", err) 290 271 } 272 + defer rows.Close() 273 + 291 274 for rows.Next() { 292 275 var repoat string 293 276 var open, merged, closed, deleted int ··· 322 305 } 323 306 324 307 // helper to get exactly one repo 325 - func GetRepo(e Execer, filters ...filter) (*models.Repo, error) { 308 + func GetRepo(e Execer, filters ...orm.Filter) (*models.Repo, error) { 326 309 repos, err := GetRepos(e, 0, filters...) 327 310 if err != nil { 328 311 return nil, err ··· 339 322 return &repos[0], nil 340 323 } 341 324 342 - func CountRepos(e Execer, filters ...filter) (int64, error) { 325 + func CountRepos(e Execer, filters ...orm.Filter) (int64, error) { 343 326 var conditions []string 344 327 var args []any 345 328 for _, filter := range filters { ··· 439 422 return nullableSource.String, nil 440 423 } 441 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 + 442 436 func GetForksByDid(e Execer, did string) ([]models.Repo, error) { 443 437 var repos []models.Repo 444 438 ··· 559 553 return err 560 554 } 561 555 562 - func UnsubscribeLabel(e Execer, filters ...filter) error { 556 + func UnsubscribeLabel(e Execer, filters ...orm.Filter) error { 563 557 var conditions []string 564 558 var args []any 565 559 for _, filter := range filters { ··· 577 571 return err 578 572 } 579 573 580 - func GetRepoLabels(e Execer, filters ...filter) ([]models.RepoLabel, error) { 574 + func GetRepoLabels(e Execer, filters ...orm.Filter) ([]models.RepoLabel, error) { 581 575 var conditions []string 582 576 var args []any 583 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
+6 -4
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 { ··· 133 134 134 135 // GetRepoStars return a list of stars each holding target repository. 135 136 // If there isn't known repo with starred at-uri, those stars will be ignored. 136 - func GetRepoStars(e Execer, limit int, filters ...filter) ([]models.RepoStar, error) { 137 + func GetRepoStars(e Execer, limit int, filters ...orm.Filter) ([]models.RepoStar, error) { 137 138 var conditions []string 138 139 var args []any 139 140 for _, filter := range filters { ··· 164 165 if err != nil { 165 166 return nil, err 166 167 } 168 + defer rows.Close() 167 169 168 170 starMap := make(map[string][]models.Star) 169 171 for rows.Next() { ··· 195 197 return nil, nil 196 198 } 197 199 198 - repos, err := GetRepos(e, 0, FilterIn("at_uri", args)) 200 + repos, err := GetRepos(e, 0, orm.FilterIn("at_uri", args)) 199 201 if err != nil { 200 202 return nil, err 201 203 } ··· 225 227 return repoStars, nil 226 228 } 227 229 228 - func CountStars(e Execer, filters ...filter) (int64, error) { 230 + func CountStars(e Execer, filters ...orm.Filter) (int64, error) { 229 231 var conditions []string 230 232 var args []any 231 233 for _, filter := range filters { ··· 298 300 } 299 301 300 302 // get full repo data 301 - repos, err := GetRepos(e, 0, FilterIn("at_uri", repoUris)) 303 + repos, err := GetRepos(e, 0, orm.FilterIn("at_uri", repoUris)) 302 304 if err != nil { 303 305 return nil, err 304 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 {
+9 -8
appview/db/timeline.go
··· 5 5 6 6 "github.com/bluesky-social/indigo/atproto/syntax" 7 7 "tangled.org/core/appview/models" 8 + "tangled.org/core/orm" 8 9 ) 9 10 10 11 // TODO: this gathers heterogenous events from different sources and aggregates ··· 84 85 } 85 86 86 87 func getTimelineRepos(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) { 87 - filters := make([]filter, 0) 88 + filters := make([]orm.Filter, 0) 88 89 if userIsFollowing != nil { 89 - filters = append(filters, FilterIn("did", userIsFollowing)) 90 + filters = append(filters, orm.FilterIn("did", userIsFollowing)) 90 91 } 91 92 92 93 repos, err := GetRepos(e, limit, filters...) ··· 104 105 105 106 var origRepos []models.Repo 106 107 if args != nil { 107 - origRepos, err = GetRepos(e, 0, FilterIn("at_uri", args)) 108 + origRepos, err = GetRepos(e, 0, orm.FilterIn("at_uri", args)) 108 109 } 109 110 if err != nil { 110 111 return nil, err ··· 144 145 } 145 146 146 147 func getTimelineStars(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) { 147 - filters := make([]filter, 0) 148 + filters := make([]orm.Filter, 0) 148 149 if userIsFollowing != nil { 149 - filters = append(filters, FilterIn("did", userIsFollowing)) 150 + filters = append(filters, orm.FilterIn("did", userIsFollowing)) 150 151 } 151 152 152 153 stars, err := GetRepoStars(e, limit, filters...) ··· 180 181 } 181 182 182 183 func getTimelineFollows(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) { 183 - filters := make([]filter, 0) 184 + filters := make([]orm.Filter, 0) 184 185 if userIsFollowing != nil { 185 - filters = append(filters, FilterIn("user_did", userIsFollowing)) 186 + filters = append(filters, orm.FilterIn("user_did", userIsFollowing)) 186 187 } 187 188 188 189 follows, err := GetFollows(e, limit, filters...) ··· 199 200 return nil, nil 200 201 } 201 202 202 - profiles, err := GetProfiles(e, FilterIn("did", subjects)) 203 + profiles, err := GetProfiles(e, orm.FilterIn("did", subjects)) 203 204 if err != nil { 204 205 return nil, err 205 206 }
+7 -12
appview/email/email.go
··· 3 3 import ( 4 4 "fmt" 5 5 "net" 6 - "regexp" 6 + "net/mail" 7 7 "strings" 8 8 9 9 "github.com/resend/resend-go/v2" ··· 34 34 } 35 35 36 36 func IsValidEmail(email string) bool { 37 - // Basic length check 38 - if len(email) < 3 || len(email) > 254 { 37 + // Reject whitespace (ParseAddress normalizes it away) 38 + if strings.ContainsAny(email, " \t\n\r") { 39 39 return false 40 40 } 41 41 42 - // Regular expression for email validation (RFC 5322 compliant) 43 - pattern := `^[a-zA-Z0-9.!#$%&'*+/=?^_\x60{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$` 44 - 45 - // Compile regex 46 - regex := regexp.MustCompile(pattern) 47 - 48 - // Check if email matches regex pattern 49 - if !regex.MatchString(email) { 42 + // Use stdlib RFC 5322 parser 43 + addr, err := mail.ParseAddress(email) 44 + if err != nil { 50 45 return false 51 46 } 52 47 53 48 // Split email into local and domain parts 54 - parts := strings.Split(email, "@") 49 + parts := strings.Split(addr.Address, "@") 55 50 domain := parts[1] 56 51 57 52 mx, err := net.LookupMX(domain)
+53
appview/email/email_test.go
··· 1 + package email 2 + 3 + import ( 4 + "testing" 5 + ) 6 + 7 + func TestIsValidEmail(t *testing.T) { 8 + tests := []struct { 9 + name string 10 + email string 11 + want bool 12 + }{ 13 + // Valid emails using RFC 2606 reserved domains 14 + {"standard email", "user@example.com", true}, 15 + {"single char local", "a@example.com", true}, 16 + {"dot in middle", "first.last@example.com", true}, 17 + {"multiple dots", "a.b.c@example.com", true}, 18 + {"plus tag", "user+tag@example.com", true}, 19 + {"numbers", "user123@example.com", true}, 20 + {"example.org", "user@example.org", true}, 21 + {"example.net", "user@example.net", true}, 22 + 23 + // Invalid format - rejected by mail.ParseAddress 24 + {"empty string", "", false}, 25 + {"no at sign", "userexample.com", false}, 26 + {"no domain", "user@", false}, 27 + {"no local part", "@example.com", false}, 28 + {"double at", "user@@example.com", false}, 29 + {"just at sign", "@", false}, 30 + {"leading dot", ".user@example.com", false}, 31 + {"trailing dot", "user.@example.com", false}, 32 + {"consecutive dots", "user..name@example.com", false}, 33 + 34 + // Whitespace - rejected before parsing 35 + {"space in local", "user @example.com", false}, 36 + {"space in domain", "user@ example.com", false}, 37 + {"tab", "user\t@example.com", false}, 38 + {"newline", "user\n@example.com", false}, 39 + 40 + // MX lookup - using RFC 2606 reserved TLDs (guaranteed no MX) 41 + {"invalid TLD", "user@example.invalid", false}, 42 + {"test TLD", "user@mail.test", false}, 43 + } 44 + 45 + for _, tt := range tests { 46 + t.Run(tt.name, func(t *testing.T) { 47 + got := IsValidEmail(tt.email) 48 + if got != tt.want { 49 + t.Errorf("IsValidEmail(%q) = %v, want %v", tt.email, got, tt.want) 50 + } 51 + }) 52 + } 53 + }
+47 -29
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 ··· 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 { ··· 350 351 351 352 err = db.UpsertProfile(tx, &profile) 352 353 case jmodels.CommitOperationDelete: 353 - 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)) 354 355 } 355 356 356 357 if err != nil { ··· 424 425 // get record from db first 425 426 members, err := db.GetSpindleMembers( 426 427 ddb, 427 - db.FilterEq("did", did), 428 - db.FilterEq("rkey", rkey), 428 + orm.FilterEq("did", did), 429 + orm.FilterEq("rkey", rkey), 429 430 ) 430 431 if err != nil || len(members) != 1 { 431 432 return fmt.Errorf("failed to get member: %w, len(members) = %d", err, len(members)) ··· 440 441 // remove record by rkey && update enforcer 441 442 if err = db.RemoveSpindleMember( 442 443 tx, 443 - db.FilterEq("did", did), 444 - db.FilterEq("rkey", rkey), 444 + orm.FilterEq("did", did), 445 + orm.FilterEq("rkey", rkey), 445 446 ); err != nil { 446 447 return fmt.Errorf("failed to remove from db: %w", err) 447 448 } ··· 523 524 // get record from db first 524 525 spindles, err := db.GetSpindles( 525 526 ddb, 526 - db.FilterEq("owner", did), 527 - db.FilterEq("instance", instance), 527 + orm.FilterEq("owner", did), 528 + orm.FilterEq("instance", instance), 528 529 ) 529 530 if err != nil || len(spindles) != 1 { 530 531 return fmt.Errorf("failed to get spindles: %w, len(spindles) = %d", err, len(spindles)) ··· 543 544 // remove spindle members first 544 545 err = db.RemoveSpindleMember( 545 546 tx, 546 - db.FilterEq("owner", did), 547 - db.FilterEq("instance", instance), 547 + orm.FilterEq("owner", did), 548 + orm.FilterEq("instance", instance), 548 549 ) 549 550 if err != nil { 550 551 return err ··· 552 553 553 554 err = db.DeleteSpindle( 554 555 tx, 555 - db.FilterEq("owner", did), 556 - db.FilterEq("instance", instance), 556 + orm.FilterEq("owner", did), 557 + orm.FilterEq("instance", instance), 557 558 ) 558 559 if err != nil { 559 560 return err ··· 621 622 case jmodels.CommitOperationDelete: 622 623 if err := db.DeleteString( 623 624 ddb, 624 - db.FilterEq("did", did), 625 - db.FilterEq("rkey", rkey), 625 + orm.FilterEq("did", did), 626 + orm.FilterEq("rkey", rkey), 626 627 ); err != nil { 627 628 l.Error("failed to delete", "err", err) 628 629 return fmt.Errorf("failed to delete string record: %w", err) ··· 740 741 // get record from db first 741 742 registrations, err := db.GetRegistrations( 742 743 ddb, 743 - db.FilterEq("domain", domain), 744 - db.FilterEq("did", did), 744 + orm.FilterEq("domain", domain), 745 + orm.FilterEq("did", did), 745 746 ) 746 747 if err != nil { 747 748 return fmt.Errorf("failed to get registration: %w", err) ··· 762 763 763 764 err = db.DeleteKnot( 764 765 tx, 765 - db.FilterEq("did", did), 766 - db.FilterEq("domain", domain), 766 + orm.FilterEq("did", did), 767 + orm.FilterEq("domain", domain), 767 768 ) 768 769 if err != nil { 769 770 return err ··· 841 842 return nil 842 843 843 844 case jmodels.CommitOperationDelete: 845 + tx, err := ddb.BeginTx(ctx, nil) 846 + if err != nil { 847 + l.Error("failed to begin transaction", "err", err) 848 + return err 849 + } 850 + defer tx.Rollback() 851 + 844 852 if err := db.DeleteIssues( 845 - ddb, 846 - db.FilterEq("did", did), 847 - db.FilterEq("rkey", rkey), 853 + tx, 854 + did, 855 + rkey, 848 856 ); err != nil { 849 857 l.Error("failed to delete", "err", err) 850 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 851 863 } 852 864 853 865 return nil ··· 888 900 return fmt.Errorf("failed to validate comment: %w", err) 889 901 } 890 902 891 - _, 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) 892 910 if err != nil { 893 911 return fmt.Errorf("failed to create issue comment: %w", err) 894 912 } 895 913 896 - return nil 914 + return tx.Commit() 897 915 898 916 case jmodels.CommitOperationDelete: 899 917 if err := db.DeleteIssueComments( 900 918 ddb, 901 - db.FilterEq("did", did), 902 - db.FilterEq("rkey", rkey), 919 + orm.FilterEq("did", did), 920 + orm.FilterEq("rkey", rkey), 903 921 ); err != nil { 904 922 return fmt.Errorf("failed to delete issue comment record: %w", err) 905 923 } ··· 952 970 case jmodels.CommitOperationDelete: 953 971 if err := db.DeleteLabelDefinition( 954 972 ddb, 955 - db.FilterEq("did", did), 956 - db.FilterEq("rkey", rkey), 973 + orm.FilterEq("did", did), 974 + orm.FilterEq("rkey", rkey), 957 975 ); err != nil { 958 976 return fmt.Errorf("failed to delete labeldef record: %w", err) 959 977 } ··· 993 1011 var repo *models.Repo 994 1012 switch collection { 995 1013 case tangled.RepoIssueNSID: 996 - i, err := db.GetIssues(ddb, db.FilterEq("at_uri", subject)) 1014 + i, err := db.GetIssues(ddb, orm.FilterEq("at_uri", subject)) 997 1015 if err != nil || len(i) != 1 { 998 1016 return fmt.Errorf("failed to find subject: %w || subject count %d", err, len(i)) 999 1017 } ··· 1002 1020 return fmt.Errorf("unsupport label subject: %s", collection) 1003 1021 } 1004 1022 1005 - actx, err := db.NewLabelApplicationCtx(ddb, db.FilterIn("at_uri", repo.Labels)) 1023 + actx, err := db.NewLabelApplicationCtx(ddb, orm.FilterIn("at_uri", repo.Labels)) 1006 1024 if err != nil { 1007 1025 return fmt.Errorf("failed to build label application ctx: %w", err) 1008 1026 }
+143 -135
appview/issues/issues.go
··· 7 7 "fmt" 8 8 "log/slog" 9 9 "net/http" 10 - "slices" 11 10 "time" 12 11 13 12 comatproto "github.com/bluesky-social/indigo/api/atproto" ··· 20 19 "tangled.org/core/appview/config" 21 20 "tangled.org/core/appview/db" 22 21 issues_indexer "tangled.org/core/appview/indexer/issues" 22 + "tangled.org/core/appview/mentions" 23 23 "tangled.org/core/appview/models" 24 24 "tangled.org/core/appview/notify" 25 25 "tangled.org/core/appview/oauth" 26 26 "tangled.org/core/appview/pages" 27 - "tangled.org/core/appview/pages/markup" 27 + "tangled.org/core/appview/pages/repoinfo" 28 28 "tangled.org/core/appview/pagination" 29 29 "tangled.org/core/appview/reporesolver" 30 30 "tangled.org/core/appview/validator" 31 31 "tangled.org/core/idresolver" 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 45 - indexer *issues_indexer.Indexer 38 + oauth *oauth.OAuth 39 + repoResolver *reporesolver.RepoResolver 40 + enforcer *rbac.Enforcer 41 + pages *pages.Pages 42 + idResolver *idresolver.Resolver 43 + mentionsResolver *mentions.Resolver 44 + db *db.DB 45 + config *config.Config 46 + notifier notify.Notifier 47 + logger *slog.Logger 48 + validator *validator.Validator 49 + indexer *issues_indexer.Indexer 46 50 } 47 51 48 52 func New( 49 53 oauth *oauth.OAuth, 50 54 repoResolver *reporesolver.RepoResolver, 55 + enforcer *rbac.Enforcer, 51 56 pages *pages.Pages, 52 57 idResolver *idresolver.Resolver, 58 + mentionsResolver *mentions.Resolver, 53 59 db *db.DB, 54 60 config *config.Config, 55 61 notifier notify.Notifier, ··· 58 64 logger *slog.Logger, 59 65 ) *Issues { 60 66 return &Issues{ 61 - oauth: oauth, 62 - repoResolver: repoResolver, 63 - pages: pages, 64 - idResolver: idResolver, 65 - db: db, 66 - config: config, 67 - notifier: notifier, 68 - logger: logger, 69 - validator: validator, 70 - indexer: indexer, 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, 71 79 } 72 80 } 73 81 ··· 97 105 userReactions = db.GetReactionStatusMap(rp.db, user.Did, issue.AtUri()) 98 106 } 99 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 + 100 115 labelDefs, err := db.GetLabelDefinitions( 101 116 rp.db, 102 - db.FilterIn("at_uri", f.Repo.Labels), 103 - db.FilterContains("scope", tangled.RepoIssueNSID), 117 + orm.FilterIn("at_uri", f.Labels), 118 + orm.FilterContains("scope", tangled.RepoIssueNSID), 104 119 ) 105 120 if err != nil { 106 121 l.Error("failed to fetch labels", "err", err) ··· 115 130 116 131 rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{ 117 132 LoggedInUser: user, 118 - RepoInfo: f.RepoInfo(user), 133 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 119 134 Issue: issue, 120 135 CommentList: issue.CommentList(), 136 + Backlinks: backlinks, 121 137 OrderedReactionKinds: models.OrderedReactionKinds, 122 138 Reactions: reactionMap, 123 139 UserReacted: userReactions, ··· 128 144 func (rp *Issues) EditIssue(w http.ResponseWriter, r *http.Request) { 129 145 l := rp.logger.With("handler", "EditIssue") 130 146 user := rp.oauth.GetUser(r) 131 - f, err := rp.repoResolver.Resolve(r) 132 - if err != nil { 133 - l.Error("failed to get repo and knot", "err", err) 134 - return 135 - } 136 147 137 148 issue, ok := r.Context().Value("issue").(*models.Issue) 138 149 if !ok { ··· 145 156 case http.MethodGet: 146 157 rp.pages.EditIssueFragment(w, pages.EditIssueParams{ 147 158 LoggedInUser: user, 148 - RepoInfo: f.RepoInfo(user), 159 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 149 160 Issue: issue, 150 161 }) 151 162 case http.MethodPost: ··· 153 164 newIssue := issue 154 165 newIssue.Title = r.FormValue("title") 155 166 newIssue.Body = r.FormValue("body") 167 + newIssue.Mentions, newIssue.References = rp.mentionsResolver.Resolve(r.Context(), newIssue.Body) 156 168 157 169 if err := rp.validator.ValidateIssue(newIssue); err != nil { 158 170 l.Error("validation error", "err", err) ··· 222 234 l := rp.logger.With("handler", "DeleteIssue") 223 235 noticeId := "issue-actions-error" 224 236 225 - user := rp.oauth.GetUser(r) 226 - 227 237 f, err := rp.repoResolver.Resolve(r) 228 238 if err != nil { 229 239 l.Error("failed to get repo and knot", "err", err) ··· 238 248 } 239 249 l = l.With("did", issue.Did, "rkey", issue.Rkey) 240 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 + 241 259 // delete from PDS 242 260 client, err := rp.oauth.AuthorizedClient(r) 243 261 if err != nil { ··· 258 276 } 259 277 260 278 // delete from db 261 - 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 { 262 280 l.Error("failed to delete issue", "err", err) 263 281 rp.pages.Notice(w, noticeId, "Failed to delete issue.") 264 282 return 265 283 } 284 + tx.Commit() 266 285 267 286 rp.notifier.DeleteIssue(r.Context(), issue) 268 287 269 288 // return to all issues page 270 - rp.pages.HxRedirect(w, "/"+f.RepoInfo(user).FullName()+"/issues") 289 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 290 + rp.pages.HxRedirect(w, "/"+ownerSlashRepo+"/issues") 271 291 } 272 292 273 293 func (rp *Issues) CloseIssue(w http.ResponseWriter, r *http.Request) { ··· 286 306 return 287 307 } 288 308 289 - collaborators, err := f.Collaborators(r.Context()) 290 - if err != nil { 291 - l.Error("failed to fetch repo collaborators", "err", err) 292 - } 293 - isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 294 - return user.Did == collab.Did 295 - }) 309 + roles := repoinfo.RolesInRepo{Roles: rp.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())} 310 + isRepoOwner := roles.IsOwner() 311 + isCollaborator := roles.IsCollaborator() 296 312 isIssueOwner := user.Did == issue.Did 297 313 298 314 // TODO: make this more granular 299 - if isIssueOwner || isCollaborator { 315 + if isIssueOwner || isRepoOwner || isCollaborator { 300 316 err = db.CloseIssues( 301 317 rp.db, 302 - db.FilterEq("id", issue.Id), 318 + orm.FilterEq("id", issue.Id), 303 319 ) 304 320 if err != nil { 305 321 l.Error("failed to close issue", "err", err) ··· 312 328 // notify about the issue closure 313 329 rp.notifier.NewIssueState(r.Context(), syntax.DID(user.Did), issue) 314 330 315 - 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)) 316 333 return 317 334 } else { 318 335 l.Error("user is not permitted to close issue") ··· 337 354 return 338 355 } 339 356 340 - collaborators, err := f.Collaborators(r.Context()) 341 - if err != nil { 342 - l.Error("failed to fetch repo collaborators", "err", err) 343 - } 344 - isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 345 - return user.Did == collab.Did 346 - }) 357 + roles := repoinfo.RolesInRepo{Roles: rp.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())} 358 + isRepoOwner := roles.IsOwner() 359 + isCollaborator := roles.IsCollaborator() 347 360 isIssueOwner := user.Did == issue.Did 348 361 349 - if isCollaborator || isIssueOwner { 362 + if isCollaborator || isRepoOwner || isIssueOwner { 350 363 err := db.ReopenIssues( 351 364 rp.db, 352 - db.FilterEq("id", issue.Id), 365 + orm.FilterEq("id", issue.Id), 353 366 ) 354 367 if err != nil { 355 368 l.Error("failed to reopen issue", "err", err) ··· 362 375 // notify about the issue reopen 363 376 rp.notifier.NewIssueState(r.Context(), syntax.DID(user.Did), issue) 364 377 365 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 378 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 379 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId)) 366 380 return 367 381 } else { 368 382 l.Error("user is not the owner of the repo") ··· 399 413 replyTo = &replyToUri 400 414 } 401 415 416 + mentions, references := rp.mentionsResolver.Resolve(r.Context(), body) 417 + 402 418 comment := models.IssueComment{ 403 - Did: user.Did, 404 - Rkey: tid.TID(), 405 - IssueAt: issue.AtUri().String(), 406 - ReplyTo: replyTo, 407 - Body: body, 408 - Created: time.Now(), 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, 409 427 } 410 428 if err = rp.validator.ValidateIssueComment(&comment); err != nil { 411 429 l.Error("failed to validate comment", "err", err) ··· 442 460 } 443 461 }() 444 462 445 - 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) 446 472 if err != nil { 447 473 l.Error("failed to create comment", "err", err) 448 474 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 475 + return 476 + } 477 + err = tx.Commit() 478 + if err != nil { 479 + l.Error("failed to commit transaction", "err", err) 480 + rp.pages.Notice(w, "issue-comment", "Failed to create comment, try again later.") 449 481 return 450 482 } 451 483 ··· 455 487 // notify about the new comment 456 488 comment.Id = commentId 457 489 458 - rawMentions := markup.FindUserMentions(comment.Body) 459 - idents := rp.idResolver.ResolveIdents(r.Context(), rawMentions) 460 - l.Debug("parsed mentions", "raw", rawMentions, "idents", idents) 461 - var mentions []syntax.DID 462 - for _, ident := range idents { 463 - if ident != nil && !ident.Handle.IsInvalidHandle() { 464 - mentions = append(mentions, ident.DID) 465 - } 466 - } 467 490 rp.notifier.NewIssueComment(r.Context(), &comment, mentions) 468 491 469 - 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)) 470 494 } 471 495 472 496 func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) { 473 497 l := rp.logger.With("handler", "IssueComment") 474 498 user := rp.oauth.GetUser(r) 475 - f, err := rp.repoResolver.Resolve(r) 476 - if err != nil { 477 - l.Error("failed to get repo and knot", "err", err) 478 - return 479 - } 480 499 481 500 issue, ok := r.Context().Value("issue").(*models.Issue) 482 501 if !ok { ··· 488 507 commentId := chi.URLParam(r, "commentId") 489 508 comments, err := db.GetIssueComments( 490 509 rp.db, 491 - db.FilterEq("id", commentId), 510 + orm.FilterEq("id", commentId), 492 511 ) 493 512 if err != nil { 494 513 l.Error("failed to fetch comment", "id", commentId) ··· 504 523 505 524 rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 506 525 LoggedInUser: user, 507 - RepoInfo: f.RepoInfo(user), 526 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 508 527 Issue: issue, 509 528 Comment: &comment, 510 529 }) ··· 513 532 func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) { 514 533 l := rp.logger.With("handler", "EditIssueComment") 515 534 user := rp.oauth.GetUser(r) 516 - f, err := rp.repoResolver.Resolve(r) 517 - if err != nil { 518 - l.Error("failed to get repo and knot", "err", err) 519 - return 520 - } 521 535 522 536 issue, ok := r.Context().Value("issue").(*models.Issue) 523 537 if !ok { ··· 529 543 commentId := chi.URLParam(r, "commentId") 530 544 comments, err := db.GetIssueComments( 531 545 rp.db, 532 - db.FilterEq("id", commentId), 546 + orm.FilterEq("id", commentId), 533 547 ) 534 548 if err != nil { 535 549 l.Error("failed to fetch comment", "id", commentId) ··· 553 567 case http.MethodGet: 554 568 rp.pages.EditIssueCommentFragment(w, pages.EditIssueCommentParams{ 555 569 LoggedInUser: user, 556 - RepoInfo: f.RepoInfo(user), 570 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 557 571 Issue: issue, 558 572 Comment: &comment, 559 573 }) ··· 571 585 newComment := comment 572 586 newComment.Body = newBody 573 587 newComment.Edited = &now 588 + newComment.Mentions, newComment.References = rp.mentionsResolver.Resolve(r.Context(), newBody) 589 + 574 590 record := newComment.AsRecord() 575 591 576 - _, err = db.AddIssueComment(rp.db, newComment) 592 + tx, err := rp.db.Begin() 593 + if err != nil { 594 + l.Error("failed to start transaction", "err", err) 595 + rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 596 + return 597 + } 598 + defer tx.Rollback() 599 + 600 + _, err = db.AddIssueComment(tx, newComment) 577 601 if err != nil { 578 602 l.Error("failed to perferom update-description query", "err", err) 579 603 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 580 604 return 581 605 } 606 + tx.Commit() 582 607 583 608 // rkey is optional, it was introduced later 584 609 if newComment.Rkey != "" { ··· 607 632 // return new comment body with htmx 608 633 rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 609 634 LoggedInUser: user, 610 - RepoInfo: f.RepoInfo(user), 635 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 611 636 Issue: issue, 612 637 Comment: &newComment, 613 638 }) ··· 617 642 func (rp *Issues) ReplyIssueCommentPlaceholder(w http.ResponseWriter, r *http.Request) { 618 643 l := rp.logger.With("handler", "ReplyIssueCommentPlaceholder") 619 644 user := rp.oauth.GetUser(r) 620 - f, err := rp.repoResolver.Resolve(r) 621 - if err != nil { 622 - l.Error("failed to get repo and knot", "err", err) 623 - return 624 - } 625 645 626 646 issue, ok := r.Context().Value("issue").(*models.Issue) 627 647 if !ok { ··· 633 653 commentId := chi.URLParam(r, "commentId") 634 654 comments, err := db.GetIssueComments( 635 655 rp.db, 636 - db.FilterEq("id", commentId), 656 + orm.FilterEq("id", commentId), 637 657 ) 638 658 if err != nil { 639 659 l.Error("failed to fetch comment", "id", commentId) ··· 649 669 650 670 rp.pages.ReplyIssueCommentPlaceholderFragment(w, pages.ReplyIssueCommentPlaceholderParams{ 651 671 LoggedInUser: user, 652 - RepoInfo: f.RepoInfo(user), 672 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 653 673 Issue: issue, 654 674 Comment: &comment, 655 675 }) ··· 658 678 func (rp *Issues) ReplyIssueComment(w http.ResponseWriter, r *http.Request) { 659 679 l := rp.logger.With("handler", "ReplyIssueComment") 660 680 user := rp.oauth.GetUser(r) 661 - f, err := rp.repoResolver.Resolve(r) 662 - if err != nil { 663 - l.Error("failed to get repo and knot", "err", err) 664 - return 665 - } 666 681 667 682 issue, ok := r.Context().Value("issue").(*models.Issue) 668 683 if !ok { ··· 674 689 commentId := chi.URLParam(r, "commentId") 675 690 comments, err := db.GetIssueComments( 676 691 rp.db, 677 - db.FilterEq("id", commentId), 692 + orm.FilterEq("id", commentId), 678 693 ) 679 694 if err != nil { 680 695 l.Error("failed to fetch comment", "id", commentId) ··· 690 705 691 706 rp.pages.ReplyIssueCommentFragment(w, pages.ReplyIssueCommentParams{ 692 707 LoggedInUser: user, 693 - RepoInfo: f.RepoInfo(user), 708 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 694 709 Issue: issue, 695 710 Comment: &comment, 696 711 }) ··· 699 714 func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { 700 715 l := rp.logger.With("handler", "DeleteIssueComment") 701 716 user := rp.oauth.GetUser(r) 702 - f, err := rp.repoResolver.Resolve(r) 703 - if err != nil { 704 - l.Error("failed to get repo and knot", "err", err) 705 - return 706 - } 707 717 708 718 issue, ok := r.Context().Value("issue").(*models.Issue) 709 719 if !ok { ··· 715 725 commentId := chi.URLParam(r, "commentId") 716 726 comments, err := db.GetIssueComments( 717 727 rp.db, 718 - db.FilterEq("id", commentId), 728 + orm.FilterEq("id", commentId), 719 729 ) 720 730 if err != nil { 721 731 l.Error("failed to fetch comment", "id", commentId) ··· 742 752 743 753 // optimistic deletion 744 754 deleted := time.Now() 745 - err = db.DeleteIssueComments(rp.db, db.FilterEq("id", comment.Id)) 755 + err = db.DeleteIssueComments(rp.db, orm.FilterEq("id", comment.Id)) 746 756 if err != nil { 747 757 l.Error("failed to delete comment", "err", err) 748 758 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment") ··· 774 784 // htmx fragment of comment after deletion 775 785 rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 776 786 LoggedInUser: user, 777 - RepoInfo: f.RepoInfo(user), 787 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 778 788 Issue: issue, 779 789 Comment: &comment, 780 790 }) ··· 831 841 832 842 issues, err = db.GetIssues( 833 843 rp.db, 834 - db.FilterIn("id", res.Hits), 844 + orm.FilterIn("id", res.Hits), 835 845 ) 836 846 if err != nil { 837 847 l.Error("failed to get issues", "err", err) ··· 847 857 issues, err = db.GetIssuesPaginated( 848 858 rp.db, 849 859 page, 850 - db.FilterEq("repo_at", f.RepoAt()), 851 - db.FilterEq("open", openInt), 860 + orm.FilterEq("repo_at", f.RepoAt()), 861 + orm.FilterEq("open", openInt), 852 862 ) 853 863 if err != nil { 854 864 l.Error("failed to get issues", "err", err) ··· 859 869 860 870 labelDefs, err := db.GetLabelDefinitions( 861 871 rp.db, 862 - db.FilterIn("at_uri", f.Repo.Labels), 863 - db.FilterContains("scope", tangled.RepoIssueNSID), 872 + orm.FilterIn("at_uri", f.Labels), 873 + orm.FilterContains("scope", tangled.RepoIssueNSID), 864 874 ) 865 875 if err != nil { 866 876 l.Error("failed to fetch labels", "err", err) ··· 875 885 876 886 rp.pages.RepoIssues(w, pages.RepoIssuesParams{ 877 887 LoggedInUser: rp.oauth.GetUser(r), 878 - RepoInfo: f.RepoInfo(user), 888 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 879 889 Issues: issues, 880 890 IssueCount: totalIssues, 881 891 LabelDefs: defs, ··· 899 909 case http.MethodGet: 900 910 rp.pages.RepoNewIssue(w, pages.RepoNewIssueParams{ 901 911 LoggedInUser: user, 902 - RepoInfo: f.RepoInfo(user), 912 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 903 913 }) 904 914 case http.MethodPost: 915 + body := r.FormValue("body") 916 + mentions, references := rp.mentionsResolver.Resolve(r.Context(), body) 917 + 905 918 issue := &models.Issue{ 906 - RepoAt: f.RepoAt(), 907 - Rkey: tid.TID(), 908 - Title: r.FormValue("title"), 909 - Body: r.FormValue("body"), 910 - Open: true, 911 - Did: user.Did, 912 - Created: time.Now(), 913 - Repo: &f.Repo, 919 + RepoAt: f.RepoAt(), 920 + Rkey: tid.TID(), 921 + Title: r.FormValue("title"), 922 + Body: body, 923 + Open: true, 924 + Did: user.Did, 925 + Created: time.Now(), 926 + Mentions: mentions, 927 + References: references, 928 + Repo: f, 914 929 } 915 930 916 931 if err := rp.validator.ValidateIssue(issue); err != nil { ··· 978 993 // everything is successful, do not rollback the atproto record 979 994 atUri = "" 980 995 981 - rawMentions := markup.FindUserMentions(issue.Body) 982 - idents := rp.idResolver.ResolveIdents(r.Context(), rawMentions) 983 - l.Debug("parsed mentions", "raw", rawMentions, "idents", idents) 984 - var mentions []syntax.DID 985 - for _, ident := range idents { 986 - if ident != nil && !ident.Handle.IsInvalidHandle() { 987 - mentions = append(mentions, ident.DID) 988 - } 989 - } 990 996 rp.notifier.NewIssue(r.Context(), issue, mentions) 991 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 997 + 998 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 999 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", ownerSlashRepo, issue.IssueId)) 992 1000 return 993 1001 } 994 1002 }
+3 -3
appview/issues/opengraph.go
··· 232 232 233 233 // Get owner handle for avatar 234 234 var ownerHandle string 235 - owner, err := rp.idResolver.ResolveIdent(r.Context(), f.Repo.Did) 235 + owner, err := rp.idResolver.ResolveIdent(r.Context(), f.Did) 236 236 if err != nil { 237 - ownerHandle = f.Repo.Did 237 + ownerHandle = f.Did 238 238 } else { 239 239 ownerHandle = "@" + owner.Handle.String() 240 240 } 241 241 242 - card, err := rp.drawIssueSummaryCard(issue, &f.Repo, commentCount, ownerHandle) 242 + card, err := rp.drawIssueSummaryCard(issue, f, commentCount, ownerHandle) 243 243 if err != nil { 244 244 log.Println("failed to draw issue summary card", err) 245 245 http.Error(w, "failed to draw issue summary card", http.StatusInternalServerError)
+37 -19
appview/knots/knots.go
··· 21 21 "tangled.org/core/appview/xrpcclient" 22 22 "tangled.org/core/eventconsumer" 23 23 "tangled.org/core/idresolver" 24 + "tangled.org/core/orm" 24 25 "tangled.org/core/rbac" 25 26 "tangled.org/core/tid" 26 27 ··· 39 40 Knotstream *eventconsumer.Consumer 40 41 } 41 42 43 + type tab = map[string]any 44 + 45 + var ( 46 + knotsTabs []tab = []tab{ 47 + {"Name": "profile", "Icon": "user"}, 48 + {"Name": "keys", "Icon": "key"}, 49 + {"Name": "emails", "Icon": "mail"}, 50 + {"Name": "notifications", "Icon": "bell"}, 51 + {"Name": "knots", "Icon": "volleyball"}, 52 + {"Name": "spindles", "Icon": "spool"}, 53 + } 54 + ) 55 + 42 56 func (k *Knots) Router() http.Handler { 43 57 r := chi.NewRouter() 44 58 ··· 59 73 user := k.OAuth.GetUser(r) 60 74 registrations, err := db.GetRegistrations( 61 75 k.Db, 62 - db.FilterEq("did", user.Did), 76 + orm.FilterEq("did", user.Did), 63 77 ) 64 78 if err != nil { 65 79 k.Logger.Error("failed to fetch knot registrations", "err", err) ··· 70 84 k.Pages.Knots(w, pages.KnotsParams{ 71 85 LoggedInUser: user, 72 86 Registrations: registrations, 87 + Tabs: knotsTabs, 88 + Tab: "knots", 73 89 }) 74 90 } 75 91 ··· 87 103 88 104 registrations, err := db.GetRegistrations( 89 105 k.Db, 90 - db.FilterEq("did", user.Did), 91 - db.FilterEq("domain", domain), 106 + orm.FilterEq("did", user.Did), 107 + orm.FilterEq("domain", domain), 92 108 ) 93 109 if err != nil { 94 110 l.Error("failed to get registrations", "err", err) ··· 112 128 repos, err := db.GetRepos( 113 129 k.Db, 114 130 0, 115 - db.FilterEq("knot", domain), 131 + orm.FilterEq("knot", domain), 116 132 ) 117 133 if err != nil { 118 134 l.Error("failed to get knot repos", "err", err) ··· 132 148 Members: members, 133 149 Repos: repoMap, 134 150 IsOwner: true, 151 + Tabs: knotsTabs, 152 + Tab: "knots", 135 153 }) 136 154 } 137 155 ··· 276 294 // get record from db first 277 295 registrations, err := db.GetRegistrations( 278 296 k.Db, 279 - db.FilterEq("did", user.Did), 280 - db.FilterEq("domain", domain), 297 + orm.FilterEq("did", user.Did), 298 + orm.FilterEq("domain", domain), 281 299 ) 282 300 if err != nil { 283 301 l.Error("failed to get registration", "err", err) ··· 304 322 305 323 err = db.DeleteKnot( 306 324 tx, 307 - db.FilterEq("did", user.Did), 308 - db.FilterEq("domain", domain), 325 + orm.FilterEq("did", user.Did), 326 + orm.FilterEq("domain", domain), 309 327 ) 310 328 if err != nil { 311 329 l.Error("failed to delete registration", "err", err) ··· 385 403 // get record from db first 386 404 registrations, err := db.GetRegistrations( 387 405 k.Db, 388 - db.FilterEq("did", user.Did), 389 - db.FilterEq("domain", domain), 406 + orm.FilterEq("did", user.Did), 407 + orm.FilterEq("domain", domain), 390 408 ) 391 409 if err != nil { 392 410 l.Error("failed to get registration", "err", err) ··· 476 494 // Get updated registration to show 477 495 registrations, err = db.GetRegistrations( 478 496 k.Db, 479 - db.FilterEq("did", user.Did), 480 - db.FilterEq("domain", domain), 497 + orm.FilterEq("did", user.Did), 498 + orm.FilterEq("domain", domain), 481 499 ) 482 500 if err != nil { 483 501 l.Error("failed to get registration", "err", err) ··· 512 530 513 531 registrations, err := db.GetRegistrations( 514 532 k.Db, 515 - db.FilterEq("did", user.Did), 516 - db.FilterEq("domain", domain), 517 - db.FilterIsNot("registered", "null"), 533 + orm.FilterEq("did", user.Did), 534 + orm.FilterEq("domain", domain), 535 + orm.FilterIsNot("registered", "null"), 518 536 ) 519 537 if err != nil { 520 538 l.Error("failed to get registration", "err", err) ··· 596 614 } 597 615 598 616 // success 599 - k.Pages.HxRedirect(w, fmt.Sprintf("/knots/%s", domain)) 617 + k.Pages.HxRedirect(w, fmt.Sprintf("/settings/knots/%s", domain)) 600 618 } 601 619 602 620 func (k *Knots) removeMember(w http.ResponseWriter, r *http.Request) { ··· 620 638 621 639 registrations, err := db.GetRegistrations( 622 640 k.Db, 623 - db.FilterEq("did", user.Did), 624 - db.FilterEq("domain", domain), 625 - db.FilterIsNot("registered", "null"), 641 + orm.FilterEq("did", user.Did), 642 + orm.FilterEq("domain", domain), 643 + orm.FilterIsNot("registered", "null"), 626 644 ) 627 645 if err != nil { 628 646 l.Error("failed to get registration", "err", err)
+5 -4
appview/labels/labels.go
··· 16 16 "tangled.org/core/appview/oauth" 17 17 "tangled.org/core/appview/pages" 18 18 "tangled.org/core/appview/validator" 19 + "tangled.org/core/orm" 19 20 "tangled.org/core/rbac" 20 21 "tangled.org/core/tid" 21 22 ··· 88 89 repoAt := r.Form.Get("repo") 89 90 subjectUri := r.Form.Get("subject") 90 91 91 - repo, err := db.GetRepo(l.db, db.FilterEq("at_uri", repoAt)) 92 + repo, err := db.GetRepo(l.db, orm.FilterEq("at_uri", repoAt)) 92 93 if err != nil { 93 94 fail("Failed to get repository.", err) 94 95 return 95 96 } 96 97 97 98 // find all the labels that this repo subscribes to 98 - repoLabels, err := db.GetRepoLabels(l.db, db.FilterEq("repo_at", repoAt)) 99 + repoLabels, err := db.GetRepoLabels(l.db, orm.FilterEq("repo_at", repoAt)) 99 100 if err != nil { 100 101 fail("Failed to get labels for this repository.", err) 101 102 return ··· 106 107 labelAts = append(labelAts, rl.LabelAt.String()) 107 108 } 108 109 109 - actx, err := db.NewLabelApplicationCtx(l.db, db.FilterIn("at_uri", labelAts)) 110 + actx, err := db.NewLabelApplicationCtx(l.db, orm.FilterIn("at_uri", labelAts)) 110 111 if err != nil { 111 112 fail("Invalid form data.", err) 112 113 return 113 114 } 114 115 115 116 // calculate the start state by applying already known labels 116 - existingOps, err := db.GetLabelOps(l.db, db.FilterEq("subject", subjectUri)) 117 + existingOps, err := db.GetLabelOps(l.db, orm.FilterEq("subject", subjectUri)) 117 118 if err != nil { 118 119 fail("Invalid form data.", err) 119 120 return
+67
appview/mentions/resolver.go
··· 1 + package mentions 2 + 3 + import ( 4 + "context" 5 + "log/slog" 6 + 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 + "tangled.org/core/appview/config" 9 + "tangled.org/core/appview/db" 10 + "tangled.org/core/appview/models" 11 + "tangled.org/core/appview/pages/markup" 12 + "tangled.org/core/idresolver" 13 + ) 14 + 15 + type Resolver struct { 16 + config *config.Config 17 + idResolver *idresolver.Resolver 18 + execer db.Execer 19 + logger *slog.Logger 20 + } 21 + 22 + func New( 23 + config *config.Config, 24 + idResolver *idresolver.Resolver, 25 + execer db.Execer, 26 + logger *slog.Logger, 27 + ) *Resolver { 28 + return &Resolver{ 29 + config, 30 + idResolver, 31 + execer, 32 + logger, 33 + } 34 + } 35 + 36 + func (r *Resolver) Resolve(ctx context.Context, source string) ([]syntax.DID, []syntax.ATURI) { 37 + l := r.logger.With("method", "Resolve") 38 + 39 + rawMentions, rawRefs := markup.FindReferences(r.config.Core.AppviewHost, source) 40 + l.Debug("found possible references", "mentions", rawMentions, "refs", rawRefs) 41 + 42 + idents := r.idResolver.ResolveIdents(ctx, rawMentions) 43 + var mentions []syntax.DID 44 + for _, ident := range idents { 45 + if ident != nil && !ident.Handle.IsInvalidHandle() { 46 + mentions = append(mentions, ident.DID) 47 + } 48 + } 49 + l.Debug("found mentions", "mentions", mentions) 50 + 51 + var resolvedRefs []models.ReferenceLink 52 + for _, rawRef := range rawRefs { 53 + ident, err := r.idResolver.ResolveIdent(ctx, rawRef.Handle) 54 + if err != nil || ident == nil || ident.Handle.IsInvalidHandle() { 55 + continue 56 + } 57 + rawRef.Handle = string(ident.DID) 58 + resolvedRefs = append(resolvedRefs, rawRef) 59 + } 60 + aturiRefs, err := db.ValidateReferenceLinks(r.execer, resolvedRefs) 61 + if err != nil { 62 + l.Error("failed running query", "err", err) 63 + } 64 + l.Debug("found references", "refs", aturiRefs) 65 + 66 + return mentions, aturiRefs 67 + }
+5 -4
appview/middleware/middleware.go
··· 18 18 "tangled.org/core/appview/pagination" 19 19 "tangled.org/core/appview/reporesolver" 20 20 "tangled.org/core/idresolver" 21 + "tangled.org/core/orm" 21 22 "tangled.org/core/rbac" 22 23 ) 23 24 ··· 164 165 ok, err := mw.enforcer.E.Enforce(actor.Did, f.Knot, f.DidSlashRepo(), requiredPerm) 165 166 if err != nil || !ok { 166 167 // we need a logged in user 167 - log.Printf("%s does not have perms of a %s in repo %s", actor.Did, requiredPerm, f.OwnerSlashRepo()) 168 + log.Printf("%s does not have perms of a %s in repo %s", actor.Did, requiredPerm, f.DidSlashRepo()) 168 169 http.Error(w, "Forbiden", http.StatusUnauthorized) 169 170 return 170 171 } ··· 217 218 218 219 repo, err := db.GetRepo( 219 220 mw.db, 220 - db.FilterEq("did", id.DID.String()), 221 - db.FilterEq("name", repoName), 221 + orm.FilterEq("did", id.DID.String()), 222 + orm.FilterEq("name", repoName), 222 223 ) 223 224 if err != nil { 224 225 log.Println("failed to resolve repo", "err", err) ··· 327 328 return 328 329 } 329 330 330 - fullName := f.OwnerHandle() + "/" + f.Name 331 + fullName := reporesolver.GetBaseRepoPath(r, f) 331 332 332 333 if r.Header.Get("User-Agent") == "Go-http-client/1.1" { 333 334 if r.URL.Query().Get("go-get") == "1" {
+70 -34
appview/models/issue.go
··· 10 10 ) 11 11 12 12 type Issue struct { 13 - Id int64 14 - Did string 15 - Rkey string 16 - RepoAt syntax.ATURI 17 - IssueId int 18 - Created time.Time 19 - Edited *time.Time 20 - Deleted *time.Time 21 - Title string 22 - Body string 23 - Open bool 13 + Id int64 14 + Did string 15 + Rkey string 16 + RepoAt syntax.ATURI 17 + IssueId int 18 + Created time.Time 19 + Edited *time.Time 20 + Deleted *time.Time 21 + Title string 22 + Body string 23 + Open bool 24 + Mentions []syntax.DID 25 + References []syntax.ATURI 24 26 25 27 // optionally, populate this when querying for reverse mappings 26 28 // like comment counts, parent repo etc. ··· 34 36 } 35 37 36 38 func (i *Issue) AsRecord() tangled.RepoIssue { 39 + mentions := make([]string, len(i.Mentions)) 40 + for i, did := range i.Mentions { 41 + mentions[i] = string(did) 42 + } 43 + references := make([]string, len(i.References)) 44 + for i, uri := range i.References { 45 + references[i] = string(uri) 46 + } 37 47 return tangled.RepoIssue{ 38 - Repo: i.RepoAt.String(), 39 - Title: i.Title, 40 - Body: &i.Body, 41 - CreatedAt: i.Created.Format(time.RFC3339), 48 + Repo: i.RepoAt.String(), 49 + Title: i.Title, 50 + Body: &i.Body, 51 + Mentions: mentions, 52 + References: references, 53 + CreatedAt: i.Created.Format(time.RFC3339), 42 54 } 43 55 } 44 56 ··· 161 173 } 162 174 163 175 type IssueComment struct { 164 - Id int64 165 - Did string 166 - Rkey string 167 - IssueAt string 168 - ReplyTo *string 169 - Body string 170 - Created time.Time 171 - Edited *time.Time 172 - Deleted *time.Time 176 + Id int64 177 + Did string 178 + Rkey string 179 + IssueAt string 180 + ReplyTo *string 181 + Body string 182 + Created time.Time 183 + Edited *time.Time 184 + Deleted *time.Time 185 + Mentions []syntax.DID 186 + References []syntax.ATURI 173 187 } 174 188 175 189 func (i *IssueComment) AtUri() syntax.ATURI { ··· 177 191 } 178 192 179 193 func (i *IssueComment) AsRecord() tangled.RepoIssueComment { 194 + mentions := make([]string, len(i.Mentions)) 195 + for i, did := range i.Mentions { 196 + mentions[i] = string(did) 197 + } 198 + references := make([]string, len(i.References)) 199 + for i, uri := range i.References { 200 + references[i] = string(uri) 201 + } 180 202 return tangled.RepoIssueComment{ 181 - Body: i.Body, 182 - Issue: i.IssueAt, 183 - CreatedAt: i.Created.Format(time.RFC3339), 184 - ReplyTo: i.ReplyTo, 203 + Body: i.Body, 204 + Issue: i.IssueAt, 205 + CreatedAt: i.Created.Format(time.RFC3339), 206 + ReplyTo: i.ReplyTo, 207 + Mentions: mentions, 208 + References: references, 185 209 } 186 210 } 187 211 ··· 205 229 return nil, err 206 230 } 207 231 232 + i := record 233 + mentions := make([]syntax.DID, len(record.Mentions)) 234 + for i, did := range record.Mentions { 235 + mentions[i] = syntax.DID(did) 236 + } 237 + references := make([]syntax.ATURI, len(record.References)) 238 + for i, uri := range i.References { 239 + references[i] = syntax.ATURI(uri) 240 + } 241 + 208 242 comment := IssueComment{ 209 - Did: ownerDid, 210 - Rkey: rkey, 211 - Body: record.Body, 212 - IssueAt: record.Issue, 213 - ReplyTo: record.ReplyTo, 214 - Created: created, 243 + Did: ownerDid, 244 + Rkey: rkey, 245 + Body: record.Body, 246 + IssueAt: record.Issue, 247 + ReplyTo: record.ReplyTo, 248 + Created: created, 249 + Mentions: mentions, 250 + References: references, 215 251 } 216 252 217 253 return &comment, nil
+10
appview/models/pipeline.go
··· 1 1 package models 2 2 3 3 import ( 4 + "fmt" 4 5 "slices" 5 6 "time" 6 7 7 8 "github.com/bluesky-social/indigo/atproto/syntax" 8 9 "github.com/go-git/go-git/v5/plumbing" 10 + "tangled.org/core/api/tangled" 9 11 spindle "tangled.org/core/spindle/models" 10 12 "tangled.org/core/workflow" 11 13 ) ··· 23 25 // populate when querying for reverse mappings 24 26 Trigger *Trigger 25 27 Statuses map[string]WorkflowStatus 28 + } 29 + 30 + func (p *Pipeline) AtUri() syntax.ATURI { 31 + return syntax.ATURI(fmt.Sprintf("at://did:web:%s/%s/%s", p.Knot, tangled.PipelineNSID, p.Rkey)) 26 32 } 27 33 28 34 type WorkflowStatus struct { ··· 128 134 Error *string 129 135 ExitCode int 130 136 } 137 + 138 + func (ps *PipelineStatus) PipelineAt() syntax.ATURI { 139 + return syntax.ATURI(fmt.Sprintf("at://did:web:%s/%s/%s", ps.PipelineKnot, tangled.PipelineNSID, ps.PipelineRkey)) 140 + }
+3 -1
appview/models/profile.go
··· 111 111 } 112 112 113 113 type ByMonth struct { 114 + Commits int 114 115 RepoEvents []RepoEvent 115 116 IssueEvents IssueEvents 116 117 PullEvents PullEvents ··· 119 120 func (b ByMonth) IsEmpty() bool { 120 121 return len(b.RepoEvents) == 0 && 121 122 len(b.IssueEvents.Items) == 0 && 122 - len(b.PullEvents.Items) == 0 123 + len(b.PullEvents.Items) == 0 && 124 + b.Commits == 0 123 125 } 124 126 125 127 type IssueEvents struct {
+41 -3
appview/models/pull.go
··· 66 66 TargetBranch string 67 67 State PullState 68 68 Submissions []*PullSubmission 69 + Mentions []syntax.DID 70 + References []syntax.ATURI 69 71 70 72 // stacking 71 73 StackId string // nullable string ··· 92 94 source.Repo = &s 93 95 } 94 96 } 97 + mentions := make([]string, len(p.Mentions)) 98 + for i, did := range p.Mentions { 99 + mentions[i] = string(did) 100 + } 101 + references := make([]string, len(p.References)) 102 + for i, uri := range p.References { 103 + references[i] = string(uri) 104 + } 95 105 96 106 record := tangled.RepoPull{ 97 - Title: p.Title, 98 - Body: &p.Body, 99 - CreatedAt: p.Created.Format(time.RFC3339), 107 + Title: p.Title, 108 + Body: &p.Body, 109 + Mentions: mentions, 110 + References: references, 111 + CreatedAt: p.Created.Format(time.RFC3339), 100 112 Target: &tangled.RepoPull_Target{ 101 113 Repo: p.RepoAt.String(), 102 114 Branch: p.TargetBranch, ··· 146 158 147 159 // content 148 160 Body string 161 + 162 + // meta 163 + Mentions []syntax.DID 164 + References []syntax.ATURI 149 165 150 166 // meta 151 167 Created time.Time 152 168 } 169 + 170 + func (p *PullComment) AtUri() syntax.ATURI { 171 + return syntax.ATURI(p.CommentAt) 172 + } 173 + 174 + // func (p *PullComment) AsRecord() tangled.RepoPullComment { 175 + // mentions := make([]string, len(p.Mentions)) 176 + // for i, did := range p.Mentions { 177 + // mentions[i] = string(did) 178 + // } 179 + // references := make([]string, len(p.References)) 180 + // for i, uri := range p.References { 181 + // references[i] = string(uri) 182 + // } 183 + // return tangled.RepoPullComment{ 184 + // Pull: p.PullAt, 185 + // Body: p.Body, 186 + // Mentions: mentions, 187 + // References: references, 188 + // CreatedAt: p.Created.Format(time.RFC3339), 189 + // } 190 + // } 153 191 154 192 func (p *Pull) LastRoundNumber() int { 155 193 return len(p.Submissions) - 1
+49
appview/models/reference.go
··· 1 + package models 2 + 3 + import "fmt" 4 + 5 + type RefKind int 6 + 7 + const ( 8 + RefKindIssue RefKind = iota 9 + RefKindPull 10 + ) 11 + 12 + func (k RefKind) String() string { 13 + if k == RefKindIssue { 14 + return "issues" 15 + } else { 16 + return "pulls" 17 + } 18 + } 19 + 20 + // /@alice.com/cool-proj/issues/123 21 + // /@alice.com/cool-proj/issues/123#comment-321 22 + type ReferenceLink struct { 23 + Handle string 24 + Repo string 25 + Kind RefKind 26 + SubjectId int 27 + CommentId *int 28 + } 29 + 30 + func (l ReferenceLink) String() string { 31 + comment := "" 32 + if l.CommentId != nil { 33 + comment = fmt.Sprintf("#comment-%d", *l.CommentId) 34 + } 35 + return fmt.Sprintf("/%s/%s/%s/%d%s", 36 + l.Handle, 37 + l.Repo, 38 + l.Kind.String(), 39 + l.SubjectId, 40 + comment, 41 + ) 42 + } 43 + 44 + type RichReferenceLink struct { 45 + ReferenceLink 46 + Title string 47 + // reusing PullState for both issue & PR 48 + State PullState 49 + }
+5 -4
appview/notifications/notifications.go
··· 11 11 "tangled.org/core/appview/oauth" 12 12 "tangled.org/core/appview/pages" 13 13 "tangled.org/core/appview/pagination" 14 + "tangled.org/core/orm" 14 15 ) 15 16 16 17 type Notifications struct { ··· 53 54 54 55 total, err := db.CountNotifications( 55 56 n.db, 56 - db.FilterEq("recipient_did", user.Did), 57 + orm.FilterEq("recipient_did", user.Did), 57 58 ) 58 59 if err != nil { 59 60 l.Error("failed to get total notifications", "err", err) ··· 64 65 notifications, err := db.GetNotificationsWithEntities( 65 66 n.db, 66 67 page, 67 - db.FilterEq("recipient_did", user.Did), 68 + orm.FilterEq("recipient_did", user.Did), 68 69 ) 69 70 if err != nil { 70 71 l.Error("failed to get notifications", "err", err) ··· 96 97 97 98 count, err := db.CountNotifications( 98 99 n.db, 99 - db.FilterEq("recipient_did", user.Did), 100 - db.FilterEq("read", 0), 100 + orm.FilterEq("recipient_did", user.Did), 101 + orm.FilterEq("read", 0), 101 102 ) 102 103 if err != nil { 103 104 http.Error(w, "Failed to get unread count", http.StatusInternalServerError)
+77 -66
appview/notify/db/db.go
··· 3 3 import ( 4 4 "context" 5 5 "log" 6 - "maps" 7 6 "slices" 8 7 9 8 "github.com/bluesky-social/indigo/atproto/syntax" ··· 12 11 "tangled.org/core/appview/models" 13 12 "tangled.org/core/appview/notify" 14 13 "tangled.org/core/idresolver" 14 + "tangled.org/core/orm" 15 + "tangled.org/core/sets" 15 16 ) 16 17 17 18 const ( 18 - maxMentions = 5 19 + maxMentions = 8 19 20 ) 20 21 21 22 type databaseNotifier struct { ··· 42 43 return 43 44 } 44 45 var err error 45 - 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))) 46 47 if err != nil { 47 48 log.Printf("NewStar: failed to get repos: %v", err) 48 49 return 49 50 } 50 51 51 52 actorDid := syntax.DID(star.Did) 52 - recipients := []syntax.DID{syntax.DID(repo.Did)} 53 + recipients := sets.Singleton(syntax.DID(repo.Did)) 53 54 eventType := models.NotificationTypeRepoStarred 54 55 entityType := "repo" 55 56 entityId := star.RepoAt.String() ··· 74 75 } 75 76 76 77 func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) { 77 - 78 - // build the recipients list 79 - // - owner of the repo 80 - // - collaborators in the repo 81 - var recipients []syntax.DID 82 - recipients = append(recipients, syntax.DID(issue.Repo.Did)) 83 - collaborators, err := db.GetCollaborators(n.db, db.FilterEq("repo_at", issue.Repo.RepoAt())) 78 + collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_at", issue.Repo.RepoAt())) 84 79 if err != nil { 85 80 log.Printf("failed to fetch collaborators: %v", err) 86 81 return 87 82 } 83 + 84 + // build the recipients list 85 + // - owner of the repo 86 + // - collaborators in the repo 87 + // - remove users already mentioned 88 + recipients := sets.Singleton(syntax.DID(issue.Repo.Did)) 88 89 for _, c := range collaborators { 89 - recipients = append(recipients, c.SubjectDid) 90 + recipients.Insert(c.SubjectDid) 91 + } 92 + for _, m := range mentions { 93 + recipients.Remove(m) 90 94 } 91 95 92 96 actorDid := syntax.DID(issue.Did) ··· 108 112 ) 109 113 n.notifyEvent( 110 114 actorDid, 111 - mentions, 115 + sets.Collect(slices.Values(mentions)), 112 116 models.NotificationTypeUserMentioned, 113 117 entityType, 114 118 entityId, ··· 119 123 } 120 124 121 125 func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { 122 - issues, err := db.GetIssues(n.db, db.FilterEq("at_uri", comment.IssueAt)) 126 + issues, err := db.GetIssues(n.db, orm.FilterEq("at_uri", comment.IssueAt)) 123 127 if err != nil { 124 128 log.Printf("NewIssueComment: failed to get issues: %v", err) 125 129 return ··· 130 134 } 131 135 issue := issues[0] 132 136 133 - var recipients []syntax.DID 134 - recipients = append(recipients, syntax.DID(issue.Repo.Did)) 137 + // built the recipients list: 138 + // - the owner of the repo 139 + // - | if the comment is a reply -> everybody on that thread 140 + // | if the comment is a top level -> just the issue owner 141 + // - remove mentioned users from the recipients list 142 + recipients := sets.Singleton(syntax.DID(issue.Repo.Did)) 135 143 136 144 if comment.IsReply() { 137 145 // if this comment is a reply, then notify everybody in that thread 138 146 parentAtUri := *comment.ReplyTo 139 - allThreads := issue.CommentList() 140 147 141 148 // find the parent thread, and add all DIDs from here to the recipient list 142 - for _, t := range allThreads { 149 + for _, t := range issue.CommentList() { 143 150 if t.Self.AtUri().String() == parentAtUri { 144 - recipients = append(recipients, t.Participants()...) 151 + for _, p := range t.Participants() { 152 + recipients.Insert(p) 153 + } 145 154 } 146 155 } 147 156 } else { 148 157 // not a reply, notify just the issue author 149 - recipients = append(recipients, syntax.DID(issue.Did)) 158 + recipients.Insert(syntax.DID(issue.Did)) 159 + } 160 + 161 + for _, m := range mentions { 162 + recipients.Remove(m) 150 163 } 151 164 152 165 actorDid := syntax.DID(comment.Did) ··· 168 181 ) 169 182 n.notifyEvent( 170 183 actorDid, 171 - mentions, 184 + sets.Collect(slices.Values(mentions)), 172 185 models.NotificationTypeUserMentioned, 173 186 entityType, 174 187 entityId, ··· 184 197 185 198 func (n *databaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) { 186 199 actorDid := syntax.DID(follow.UserDid) 187 - recipients := []syntax.DID{syntax.DID(follow.SubjectDid)} 200 + recipients := sets.Singleton(syntax.DID(follow.SubjectDid)) 188 201 eventType := models.NotificationTypeFollowed 189 202 entityType := "follow" 190 203 entityId := follow.UserDid ··· 207 220 } 208 221 209 222 func (n *databaseNotifier) NewPull(ctx context.Context, pull *models.Pull) { 210 - 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))) 211 224 if err != nil { 212 225 log.Printf("NewPull: failed to get repos: %v", err) 213 226 return 214 227 } 215 - 216 - // build the recipients list 217 - // - owner of the repo 218 - // - collaborators in the repo 219 - var recipients []syntax.DID 220 - recipients = append(recipients, syntax.DID(repo.Did)) 221 - collaborators, err := db.GetCollaborators(n.db, db.FilterEq("repo_at", repo.RepoAt())) 228 + collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_at", repo.RepoAt())) 222 229 if err != nil { 223 230 log.Printf("failed to fetch collaborators: %v", err) 224 231 return 225 232 } 233 + 234 + // build the recipients list 235 + // - owner of the repo 236 + // - collaborators in the repo 237 + recipients := sets.Singleton(syntax.DID(repo.Did)) 226 238 for _, c := range collaborators { 227 - recipients = append(recipients, c.SubjectDid) 239 + recipients.Insert(c.SubjectDid) 228 240 } 229 241 230 242 actorDid := syntax.DID(pull.OwnerDid) ··· 258 270 return 259 271 } 260 272 261 - 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)) 262 274 if err != nil { 263 275 log.Printf("NewPullComment: failed to get repos: %v", err) 264 276 return ··· 267 279 // build up the recipients list: 268 280 // - repo owner 269 281 // - all pull participants 270 - var recipients []syntax.DID 271 - recipients = append(recipients, syntax.DID(repo.Did)) 282 + // - remove those already mentioned 283 + recipients := sets.Singleton(syntax.DID(repo.Did)) 272 284 for _, p := range pull.Participants() { 273 - recipients = append(recipients, syntax.DID(p)) 285 + recipients.Insert(syntax.DID(p)) 286 + } 287 + for _, m := range mentions { 288 + recipients.Remove(m) 274 289 } 275 290 276 291 actorDid := syntax.DID(comment.OwnerDid) ··· 294 309 ) 295 310 n.notifyEvent( 296 311 actorDid, 297 - mentions, 312 + sets.Collect(slices.Values(mentions)), 298 313 models.NotificationTypeUserMentioned, 299 314 entityType, 300 315 entityId, ··· 321 336 } 322 337 323 338 func (n *databaseNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) { 324 - // build up the recipients list: 325 - // - repo owner 326 - // - repo collaborators 327 - // - all issue participants 328 - var recipients []syntax.DID 329 - recipients = append(recipients, syntax.DID(issue.Repo.Did)) 330 - collaborators, err := db.GetCollaborators(n.db, db.FilterEq("repo_at", issue.Repo.RepoAt())) 339 + collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_at", issue.Repo.RepoAt())) 331 340 if err != nil { 332 341 log.Printf("failed to fetch collaborators: %v", err) 333 342 return 334 343 } 344 + 345 + // build up the recipients list: 346 + // - repo owner 347 + // - repo collaborators 348 + // - all issue participants 349 + recipients := sets.Singleton(syntax.DID(issue.Repo.Did)) 335 350 for _, c := range collaborators { 336 - recipients = append(recipients, c.SubjectDid) 351 + recipients.Insert(c.SubjectDid) 337 352 } 338 353 for _, p := range issue.Participants() { 339 - recipients = append(recipients, syntax.DID(p)) 354 + recipients.Insert(syntax.DID(p)) 340 355 } 341 356 342 357 entityType := "pull" ··· 366 381 367 382 func (n *databaseNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) { 368 383 // Get repo details 369 - 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))) 370 385 if err != nil { 371 386 log.Printf("NewPullState: failed to get repos: %v", err) 372 387 return 373 388 } 374 389 375 - // build up the recipients list: 376 - // - repo owner 377 - // - all pull participants 378 - var recipients []syntax.DID 379 - recipients = append(recipients, syntax.DID(repo.Did)) 380 - collaborators, err := db.GetCollaborators(n.db, db.FilterEq("repo_at", repo.RepoAt())) 390 + collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_at", repo.RepoAt())) 381 391 if err != nil { 382 392 log.Printf("failed to fetch collaborators: %v", err) 383 393 return 384 394 } 395 + 396 + // build up the recipients list: 397 + // - repo owner 398 + // - all pull participants 399 + recipients := sets.Singleton(syntax.DID(repo.Did)) 385 400 for _, c := range collaborators { 386 - recipients = append(recipients, c.SubjectDid) 401 + recipients.Insert(c.SubjectDid) 387 402 } 388 403 for _, p := range pull.Participants() { 389 - recipients = append(recipients, syntax.DID(p)) 404 + recipients.Insert(syntax.DID(p)) 390 405 } 391 406 392 407 entityType := "pull" ··· 422 437 423 438 func (n *databaseNotifier) notifyEvent( 424 439 actorDid syntax.DID, 425 - recipients []syntax.DID, 440 + recipients sets.Set[syntax.DID], 426 441 eventType models.NotificationType, 427 442 entityType string, 428 443 entityId string, ··· 430 445 issueId *int64, 431 446 pullId *int64, 432 447 ) { 433 - if eventType == models.NotificationTypeUserMentioned && len(recipients) > maxMentions { 434 - recipients = recipients[:maxMentions] 448 + // if the user is attempting to mention >maxMentions users, this is probably spam, do not mention anybody 449 + if eventType == models.NotificationTypeUserMentioned && recipients.Len() > maxMentions { 450 + return 435 451 } 436 - recipientSet := make(map[syntax.DID]struct{}) 437 - for _, did := range recipients { 438 - // everybody except actor themselves 439 - if did != actorDid { 440 - recipientSet[did] = struct{}{} 441 - } 442 - } 452 + 453 + recipients.Remove(actorDid) 443 454 444 455 prefMap, err := db.GetNotificationPreferences( 445 456 n.db, 446 - db.FilterIn("user_did", slices.Collect(maps.Keys(recipientSet))), 457 + orm.FilterIn("user_did", slices.Collect(recipients.All())), 447 458 ) 448 459 if err != nil { 449 460 // failed to get prefs for users ··· 459 470 defer tx.Rollback() 460 471 461 472 // filter based on preferences 462 - for recipientDid := range recipientSet { 473 + for recipientDid := range recipients.All() { 463 474 prefs, ok := prefMap[recipientDid] 464 475 if !ok { 465 476 prefs = models.DefaultNotificationPreferences(recipientDid)
-1
appview/notify/merged_notifier.go
··· 39 39 v.Call(in) 40 40 }(n) 41 41 } 42 - wg.Wait() 43 42 } 44 43 45 44 func (m *mergedNotifier) NewRepo(ctx context.Context, repo *models.Repo) {
+3 -2
appview/oauth/handler.go
··· 16 16 "tangled.org/core/api/tangled" 17 17 "tangled.org/core/appview/db" 18 18 "tangled.org/core/consts" 19 + "tangled.org/core/orm" 19 20 "tangled.org/core/tid" 20 21 ) 21 22 ··· 97 98 // and create an sh.tangled.spindle.member record with that 98 99 spindleMembers, err := db.GetSpindleMembers( 99 100 o.Db, 100 - db.FilterEq("instance", "spindle.tangled.sh"), 101 - db.FilterEq("subject", did), 101 + orm.FilterEq("instance", "spindle.tangled.sh"), 102 + orm.FilterEq("subject", did), 102 103 ) 103 104 if err != nil { 104 105 l.Error("failed to get spindle members", "err", err)
+35 -10
appview/pages/funcmap.go
··· 22 22 chromahtml "github.com/alecthomas/chroma/v2/formatters/html" 23 23 "github.com/alecthomas/chroma/v2/lexers" 24 24 "github.com/alecthomas/chroma/v2/styles" 25 - "github.com/bluesky-social/indigo/atproto/syntax" 26 25 "github.com/dustin/go-humanize" 27 26 "github.com/go-enry/go-enry/v2" 28 27 "github.com/yuin/goldmark" 28 + emoji "github.com/yuin/goldmark-emoji" 29 29 "tangled.org/core/appview/filetree" 30 + "tangled.org/core/appview/models" 30 31 "tangled.org/core/appview/pages/markup" 31 32 "tangled.org/core/crypto" 32 33 ) ··· 72 73 73 74 return identity.Handle.String() 74 75 }, 76 + "ownerSlashRepo": func(repo *models.Repo) string { 77 + ownerId, err := p.resolver.ResolveIdent(context.Background(), repo.Did) 78 + if err != nil { 79 + return repo.DidSlashRepo() 80 + } 81 + handle := ownerId.Handle 82 + if handle != "" && !handle.IsInvalidHandle() { 83 + return string(handle) + "/" + repo.Name 84 + } 85 + return repo.DidSlashRepo() 86 + }, 75 87 "truncateAt30": func(s string) string { 76 88 if len(s) <= 30 { 77 89 return s ··· 141 153 142 154 return b 143 155 }, 144 - "didOrHandle": func(did, handle string) string { 145 - if handle != "" && handle != syntax.HandleInvalid.String() { 146 - return handle 147 - } else { 148 - return did 149 - } 150 - }, 151 156 "assoc": func(values ...string) ([][]string, error) { 152 157 if len(values)%2 != 0 { 153 158 return nil, fmt.Errorf("invalid assoc call, must have an even number of arguments") ··· 158 163 } 159 164 return pairs, nil 160 165 }, 161 - "append": func(s []string, values ...string) []string { 166 + "append": func(s []any, values ...any) []any { 162 167 s = append(s, values...) 163 168 return s 164 169 }, ··· 257 262 }, 258 263 "description": func(text string) template.HTML { 259 264 p.rctx.RendererType = markup.RendererTypeDefault 260 - htmlString := p.rctx.RenderMarkdownWith(text, goldmark.New()) 265 + htmlString := p.rctx.RenderMarkdownWith(text, goldmark.New( 266 + goldmark.WithExtensions( 267 + emoji.Emoji, 268 + ), 269 + )) 261 270 sanitized := p.rctx.SanitizeDescription(htmlString) 262 271 return template.HTML(sanitized) 263 272 }, ··· 379 388 } 380 389 } 381 390 391 + func (p *Pages) resolveDid(did string) string { 392 + identity, err := p.resolver.ResolveIdent(context.Background(), did) 393 + 394 + if err != nil { 395 + return did 396 + } 397 + 398 + if identity.Handle.IsInvalidHandle() { 399 + return "handle.invalid" 400 + } 401 + 402 + return identity.Handle.String() 403 + } 404 + 382 405 func (p *Pages) AvatarUrl(handle, size string) string { 383 406 handle = strings.TrimPrefix(handle, "@") 407 + 408 + handle = p.resolveDid(handle) 384 409 385 410 secret := p.avatar.SharedSecret 386 411 h := hmac.New(sha256.New, []byte(secret))
+2 -26
appview/pages/markup/markdown.go
··· 12 12 13 13 chromahtml "github.com/alecthomas/chroma/v2/formatters/html" 14 14 "github.com/alecthomas/chroma/v2/styles" 15 - treeblood "github.com/wyatt915/goldmark-treeblood" 16 15 "github.com/yuin/goldmark" 16 + "github.com/yuin/goldmark-emoji" 17 17 highlighting "github.com/yuin/goldmark-highlighting/v2" 18 18 "github.com/yuin/goldmark/ast" 19 19 "github.com/yuin/goldmark/extension" ··· 65 65 extension.NewFootnote( 66 66 extension.WithFootnoteIDPrefix([]byte("footnote")), 67 67 ), 68 - treeblood.MathML(), 69 68 callout.CalloutExtention, 70 69 textension.AtExt, 70 + emoji.Emoji, 71 71 ), 72 72 goldmark.WithParserOptions( 73 73 parser.WithAutoHeadingID(), ··· 302 302 } 303 303 304 304 return path.Join(rctx.CurrentDir, dst) 305 - } 306 - 307 - // FindUserMentions returns Set of user handles from given markup soruce. 308 - // It doesn't guarntee unique DIDs 309 - func FindUserMentions(source string) []string { 310 - var ( 311 - mentions []string 312 - mentionsSet = make(map[string]struct{}) 313 - md = NewMarkdown() 314 - sourceBytes = []byte(source) 315 - root = md.Parser().Parse(text.NewReader(sourceBytes)) 316 - ) 317 - ast.Walk(root, func(n ast.Node, entering bool) (ast.WalkStatus, error) { 318 - if entering && n.Kind() == textension.KindAt { 319 - handle := n.(*textension.AtNode).Handle 320 - mentionsSet[handle] = struct{}{} 321 - return ast.WalkSkipChildren, nil 322 - } 323 - return ast.WalkContinue, nil 324 - }) 325 - for handle := range mentionsSet { 326 - mentions = append(mentions, handle) 327 - } 328 - return mentions 329 305 } 330 306 331 307 func isAbsoluteUrl(link string) bool {
+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 + }
+21 -13
appview/pages/pages.go
··· 31 31 "github.com/bluesky-social/indigo/atproto/identity" 32 32 "github.com/bluesky-social/indigo/atproto/syntax" 33 33 "github.com/go-git/go-git/v5/plumbing" 34 - "github.com/go-git/go-git/v5/plumbing/object" 35 34 ) 36 35 37 36 //go:embed templates/* static legal ··· 407 406 type KnotsParams struct { 408 407 LoggedInUser *oauth.User 409 408 Registrations []models.Registration 409 + Tabs []map[string]any 410 + Tab string 410 411 } 411 412 412 413 func (p *Pages) Knots(w io.Writer, params KnotsParams) error { ··· 419 420 Members []string 420 421 Repos map[string][]models.Repo 421 422 IsOwner bool 423 + Tabs []map[string]any 424 + Tab string 422 425 } 423 426 424 427 func (p *Pages) Knot(w io.Writer, params KnotParams) error { ··· 436 439 type SpindlesParams struct { 437 440 LoggedInUser *oauth.User 438 441 Spindles []models.Spindle 442 + Tabs []map[string]any 443 + Tab string 439 444 } 440 445 441 446 func (p *Pages) Spindles(w io.Writer, params SpindlesParams) error { ··· 444 449 445 450 type SpindleListingParams struct { 446 451 models.Spindle 452 + Tabs []map[string]any 453 + Tab string 447 454 } 448 455 449 456 func (p *Pages) SpindleListing(w io.Writer, params SpindleListingParams) error { ··· 455 462 Spindle models.Spindle 456 463 Members []string 457 464 Repos map[string][]models.Repo 465 + Tabs []map[string]any 466 + Tab string 458 467 } 459 468 460 469 func (p *Pages) SpindleDashboard(w io.Writer, params SpindleDashboardParams) error { ··· 482 491 483 492 type ProfileCard struct { 484 493 UserDid string 485 - UserHandle string 486 494 FollowStatus models.FollowStatus 487 495 Punchcard *models.Punchcard 488 496 Profile *models.Profile ··· 632 640 } 633 641 634 642 func (p *Pages) StarBtnFragment(w io.Writer, params StarBtnFragmentParams) error { 635 - return p.executePlain("fragments/starBtn", w, params) 643 + return p.executePlain("fragments/starBtn-oob", w, params) 636 644 } 637 645 638 646 type RepoIndexParams struct { ··· 640 648 RepoInfo repoinfo.RepoInfo 641 649 Active string 642 650 TagMap map[string][]string 643 - CommitsTrunc []*object.Commit 651 + CommitsTrunc []types.Commit 644 652 TagsTrunc []*types.TagReference 645 653 BranchesTrunc []types.Branch 646 654 // ForkInfo *types.ForkInfo ··· 831 839 } 832 840 833 841 type Collaborator struct { 834 - Did string 835 - Handle string 836 - Role string 842 + Did string 843 + Role string 837 844 } 838 845 839 846 type RepoSettingsParams struct { ··· 926 933 Active string 927 934 Issue *models.Issue 928 935 CommentList []models.CommentListItem 936 + Backlinks []models.RichReferenceLink 929 937 LabelDefs map[string]*models.LabelDefinition 930 938 931 939 OrderedReactionKinds []models.ReactionKind ··· 1079 1087 Pull *models.Pull 1080 1088 Stack models.Stack 1081 1089 AbandonedPulls []*models.Pull 1090 + Backlinks []models.RichReferenceLink 1082 1091 BranchDeleteStatus *models.BranchDeleteStatus 1083 1092 MergeCheck types.MergeCheckResponse 1084 1093 ResubmitCheck ResubmitResult ··· 1250 1259 return p.executePlain("repo/fragments/compareAllowPull", w, params) 1251 1260 } 1252 1261 1253 - type RepoCompareDiffParams struct { 1254 - LoggedInUser *oauth.User 1255 - RepoInfo repoinfo.RepoInfo 1256 - Diff types.NiceDiff 1262 + type RepoCompareDiffFragmentParams struct { 1263 + Diff types.NiceDiff 1264 + DiffOpts types.DiffOpts 1257 1265 } 1258 1266 1259 - func (p *Pages) RepoCompareDiff(w io.Writer, params RepoCompareDiffParams) error { 1260 - 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}) 1261 1269 } 1262 1270 1263 1271 type LabelPanelParams struct {
+25 -22
appview/pages/repoinfo/repoinfo.go
··· 1 1 package repoinfo 2 2 3 3 import ( 4 + "fmt" 4 5 "path" 5 6 "slices" 6 7 7 8 "github.com/bluesky-social/indigo/atproto/syntax" 9 + "tangled.org/core/api/tangled" 8 10 "tangled.org/core/appview/models" 9 11 "tangled.org/core/appview/state/userutil" 10 12 ) 11 13 12 - func (r RepoInfo) Owner() string { 14 + func (r RepoInfo) owner() string { 13 15 if r.OwnerHandle != "" { 14 16 return r.OwnerHandle 15 17 } else { ··· 18 20 } 19 21 20 22 func (r RepoInfo) FullName() string { 21 - return path.Join(r.Owner(), r.Name) 23 + return path.Join(r.owner(), r.Name) 22 24 } 23 25 24 - func (r RepoInfo) OwnerWithoutAt() string { 26 + func (r RepoInfo) ownerWithoutAt() string { 25 27 if r.OwnerHandle != "" { 26 28 return r.OwnerHandle 27 29 } else { ··· 30 32 } 31 33 32 34 func (r RepoInfo) FullNameWithoutAt() string { 33 - return path.Join(r.OwnerWithoutAt(), r.Name) 35 + return path.Join(r.ownerWithoutAt(), r.Name) 34 36 } 35 37 36 38 func (r RepoInfo) GetTabs() [][]string { ··· 48 50 return tabs 49 51 } 50 52 53 + func (r RepoInfo) RepoAt() syntax.ATURI { 54 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", r.OwnerDid, tangled.RepoNSID, r.Rkey)) 55 + } 56 + 51 57 type RepoInfo struct { 52 - Name string 53 - Rkey string 54 - OwnerDid string 55 - OwnerHandle string 56 - Description string 57 - Website string 58 - Topics []string 59 - Knot string 60 - Spindle string 61 - RepoAt syntax.ATURI 62 - IsStarred bool 63 - Stats models.RepoStats 64 - Roles RolesInRepo 65 - Source *models.Repo 66 - SourceHandle string 67 - Ref string 68 - DisableFork bool 69 - CurrentDir string 58 + Name string 59 + Rkey string 60 + OwnerDid string 61 + OwnerHandle string 62 + Description string 63 + Website string 64 + Topics []string 65 + Knot string 66 + Spindle string 67 + IsStarred bool 68 + Stats models.RepoStats 69 + Roles RolesInRepo 70 + Source *models.Repo 71 + Ref string 72 + CurrentDir string 70 73 } 71 74 72 75 // each tab on a repo could have some metadata:
+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 }}
+1 -3
appview/pages/templates/fragments/starBtn.html
··· 1 1 {{ define "fragments/starBtn" }} 2 + {{/* NOTE: this fragment is always replaced with hx-swap-oob */}} 2 3 <button 3 4 id="starBtn" 4 5 class="btn disabled:opacity-50 disabled:cursor-not-allowed flex gap-2 items-center group" ··· 10 11 {{ end }} 11 12 12 13 hx-trigger="click" 13 - hx-target="this" 14 - hx-swap="outerHTML" 15 - hx-swap-oob='outerHTML:#starBtn[data-star-subject-at="{{ .SubjectAt }}"]' 16 14 hx-disabled-elt="#starBtn" 17 15 > 18 16 {{ if .IsStarred }}
+8
appview/pages/templates/fragments/tabSelector.html
··· 2 2 {{ $name := .Name }} 3 3 {{ $all := .Values }} 4 4 {{ $active := .Active }} 5 + {{ $include := .Include }} 5 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"> 6 7 {{ $activeTab := "bg-white dark:bg-gray-700 shadow-sm" }} 7 8 {{ $inactiveTab := "bg-gray-100 dark:bg-gray-800 shadow-inner" }} 8 9 {{ range $index, $value := $all }} 9 10 {{ $isActive := eq $value.Key $active }} 10 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 }} 11 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 }}"> 12 20 {{ if $value.Icon }} 13 21 {{ i $value.Icon "size-4" }}
+22
appview/pages/templates/fragments/tinyAvatarList.html
··· 1 + {{ define "fragments/tinyAvatarList" }} 2 + {{ $all := .all }} 3 + {{ $classes := .classes }} 4 + {{ $ps := take $all 5 }} 5 + <div class="inline-flex items-center -space-x-3"> 6 + {{ $c := "z-50 z-40 z-30 z-20 z-10" }} 7 + {{ range $i, $p := $ps }} 8 + <img 9 + src="{{ tinyAvatar . }}" 10 + alt="" 11 + class="rounded-full size-8 mr-1 border-2 border-gray-100 dark:border-gray-900 z-{{sub 5 $i}}0 {{ $classes }}" 12 + /> 13 + {{ end }} 14 + 15 + {{ if gt (len $all) 5 }} 16 + <span class="pl-4 text-gray-500 dark:text-gray-400 text-sm"> 17 + +{{ sub (len $all) 5 }} 18 + </span> 19 + {{ end }} 20 + </div> 21 + {{ end }} 22 +
+23 -7
appview/pages/templates/knots/dashboard.html
··· 1 - {{ define "title" }}{{ .Registration.Domain }} &middot; knots{{ end }} 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?"
+1 -1
appview/pages/templates/knots/fragments/addMemberModal.html
··· 22 22 23 23 {{ define "addKnotMemberPopover" }} 24 24 <form 25 - hx-post="/knots/{{ .Domain }}/add" 25 + hx-post="/settings/knots/{{ .Domain }}/add" 26 26 hx-indicator="#spinner" 27 27 hx-swap="none" 28 28 class="flex flex-col gap-2"
+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/knot-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 }}
-2
appview/pages/templates/layouts/fragments/topbar.html
··· 61 61 <a href="/{{ $user }}">profile</a> 62 62 <a href="/{{ $user }}?tab=repos">repositories</a> 63 63 <a href="/{{ $user }}?tab=strings">strings</a> 64 - <a href="/knots">knots</a> 65 - <a href="/spindles">spindles</a> 66 64 <a href="/settings">settings</a> 67 65 <a href="#" 68 66 hx-post="/logout"
+8 -7
appview/pages/templates/layouts/profilebase.html
··· 1 - {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }}{{ end }} 1 + {{ define "title" }}{{ resolve .Card.UserDid }}{{ end }} 2 2 3 3 {{ define "extrameta" }} 4 - {{ $avatarUrl := fullAvatar .Card.UserHandle }} 5 - <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" /> 4 + {{ $handle := resolve .Card.UserDid }} 5 + {{ $avatarUrl := fullAvatar $handle }} 6 + <meta property="og:title" content="{{ $handle }}" /> 6 7 <meta property="og:type" content="profile" /> 7 - <meta property="og:url" content="https://tangled.org/{{ or .Card.UserHandle .Card.UserDid }}?tab={{ .Active }}" /> 8 - <meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 8 + <meta property="og:url" content="https://tangled.org/{{ $handle }}?tab={{ .Active }}" /> 9 + <meta property="og:description" content="{{ or .Card.Profile.Description $handle }}" /> 9 10 <meta property="og:image" content="{{ $avatarUrl }}" /> 10 11 <meta property="og:image:width" content="512" /> 11 12 <meta property="og:image:height" content="512" /> 12 13 13 14 <meta name="twitter:card" content="summary" /> 14 - <meta name="twitter:title" content="{{ or .Card.UserHandle .Card.UserDid }}" /> 15 - <meta name="twitter:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 15 + <meta name="twitter:title" content="{{ $handle }}" /> 16 + <meta name="twitter:description" content="{{ or .Card.Profile.Description $handle }}" /> 16 17 <meta name="twitter:image" content="{{ $avatarUrl }}" /> 17 18 {{ end }} 18 19
+35 -10
appview/pages/templates/repo/commit.html
··· 25 25 </div> 26 26 27 27 <div class="flex flex-wrap items-center space-x-2"> 28 - <p class="flex flex-wrap items-center gap-2 text-sm text-gray-500 dark:text-gray-300"> 29 - {{ $did := index $.EmailToDid $commit.Author.Email }} 30 - 31 - {{ if $did }} 32 - {{ template "user/fragments/picHandleLink" $did }} 33 - {{ else }} 34 - <a href="mailto:{{ $commit.Author.Email }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ $commit.Author.Name }}</a> 35 - {{ end }} 28 + <p class="flex flex-wrap items-center gap-1 text-sm text-gray-500 dark:text-gray-300"> 29 + {{ template "attribution" . }} 36 30 37 31 <span class="px-1 select-none before:content-['\00B7']"></span> 38 - {{ template "repo/fragments/time" $commit.Author.When }} 32 + {{ template "repo/fragments/time" $commit.Committer.When }} 39 33 <span class="px-1 select-none before:content-['\00B7']"></span> 40 34 41 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> ··· 79 73 </section> 80 74 {{end}} 81 75 76 + {{ define "attribution" }} 77 + {{ $commit := .Diff.Commit }} 78 + {{ $showCommitter := true }} 79 + {{ if eq $commit.Author.Email $commit.Committer.Email }} 80 + {{ $showCommitter = false }} 81 + {{ end }} 82 + 83 + {{ if $showCommitter }} 84 + authored by {{ template "attributedUser" (list $commit.Author.Email $commit.Author.Name $.EmailToDid) }} 85 + {{ range $commit.CoAuthors }} 86 + {{ template "attributedUser" (list .Email .Name $.EmailToDid) }} 87 + {{ end }} 88 + and committed by {{ template "attributedUser" (list $commit.Committer.Email $commit.Committer.Name $.EmailToDid) }} 89 + {{ else }} 90 + {{ template "attributedUser" (list $commit.Author.Email $commit.Author.Name $.EmailToDid )}} 91 + {{ end }} 92 + {{ end }} 93 + 94 + {{ define "attributedUser" }} 95 + {{ $email := index . 0 }} 96 + {{ $name := index . 1 }} 97 + {{ $map := index . 2 }} 98 + {{ $did := index $map $email }} 99 + 100 + {{ if $did }} 101 + {{ template "user/fragments/picHandleLink" $did }} 102 + {{ else }} 103 + <a href="mailto:{{ $email }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ $name }}</a> 104 + {{ end }} 105 + {{ end }} 106 + 82 107 {{ define "topbarLayout" }} 83 108 <header class="col-span-full" style="z-index: 20;"> 84 109 {{ template "layouts/fragments/topbar" . }} ··· 111 136 {{ end }} 112 137 113 138 {{ define "contentAfter" }} 114 - {{ template "repo/fragments/diff" (list .RepoInfo.FullName .Diff .DiffOpts) }} 139 + {{ template "repo/fragments/diff" (list .Diff .DiffOpts) }} 115 140 {{end}} 116 141 117 142 {{ define "contentAfterLeft" }}
+1 -1
appview/pages/templates/repo/compare/compare.html
··· 42 42 {{ end }} 43 43 44 44 {{ define "contentAfter" }} 45 - {{ template "repo/fragments/diff" (list .RepoInfo.FullName .Diff .DiffOpts) }} 45 + {{ template "repo/fragments/diff" (list .Diff .DiffOpts) }} 46 46 {{end}} 47 47 48 48 {{ define "contentAfterLeft" }}
+2 -2
appview/pages/templates/repo/empty.html
··· 26 26 {{ else if (and .LoggedInUser (eq .LoggedInUser.Did .RepoInfo.OwnerDid)) }} 27 27 {{ $knot := .RepoInfo.Knot }} 28 28 {{ if eq $knot "knot1.tangled.sh" }} 29 - {{ $knot = "tangled.sh" }} 29 + {{ $knot = "tangled.org" }} 30 30 {{ end }} 31 31 <div class="w-full flex place-content-center"> 32 32 <div class="py-6 w-fit flex flex-col gap-4"> ··· 35 35 36 36 <p><span class="{{$bullet}}">1</span>First, generate a new <a href="https://git-scm.com/book/en/v2/Git-on-the-Server-Generating-Your-SSH-Public-Key" class="underline">SSH key pair</a>.</p> 37 37 <p><span class="{{$bullet}}">2</span>Then add the public key to your account from the <a href="/settings" class="underline">settings</a> page.</p> 38 - <p><span class="{{$bullet}}">3</span>Configure your remote to <code>git@{{ $knot | stripPort }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code></p> 38 + <p><span class="{{$bullet}}">3</span>Configure your remote to <code>git@{{ $knot | stripPort }}:{{ resolve .RepoInfo.OwnerDid }}/{{ .RepoInfo.Name }}</code></p> 39 39 <p><span class="{{$bullet}}">4</span>Push!</p> 40 40 </div> 41 41 </div>
+1 -1
appview/pages/templates/repo/fork.html
··· 34 34 {{ end }} 35 35 </div> 36 36 </div> 37 - <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> 38 38 </fieldset> 39 39 40 40 <div class="space-y-2">
+49
appview/pages/templates/repo/fragments/backlinks.html
··· 1 + {{ define "repo/fragments/backlinks" }} 2 + {{ if .Backlinks }} 3 + <div id="at-uri-panel" class="px-2 md:px-0"> 4 + <div> 5 + <span class="text-sm py-1 font-bold text-gray-500 dark:text-gray-400">Referenced by</span> 6 + </div> 7 + <ul> 8 + {{ range .Backlinks }} 9 + <li> 10 + {{ $repoOwner := resolve .Handle }} 11 + {{ $repoName := .Repo }} 12 + {{ $repoUrl := printf "%s/%s" $repoOwner $repoName }} 13 + <div class="flex flex-col"> 14 + <div class="flex gap-2 items-center"> 15 + {{ if .State.IsClosed }} 16 + <span class="text-gray-500 dark:text-gray-400"> 17 + {{ i "ban" "size-3" }} 18 + </span> 19 + {{ else if eq .Kind.String "issues" }} 20 + <span class="text-green-600 dark:text-green-500"> 21 + {{ i "circle-dot" "size-3" }} 22 + </span> 23 + {{ else if .State.IsOpen }} 24 + <span class="text-green-600 dark:text-green-500"> 25 + {{ i "git-pull-request" "size-3" }} 26 + </span> 27 + {{ else if .State.IsMerged }} 28 + <span class="text-purple-600 dark:text-purple-500"> 29 + {{ i "git-merge" "size-3" }} 30 + </span> 31 + {{ else }} 32 + <span class="text-gray-600 dark:text-gray-300"> 33 + {{ i "git-pull-request-closed" "size-3" }} 34 + </span> 35 + {{ end }} 36 + <a href="{{ . }}" class="line-clamp-1 text-sm"><span class="text-gray-500 dark:text-gray-400">#{{ .SubjectId }}</span> {{ .Title }}</a> 37 + </div> 38 + {{ if not (eq $.RepoInfo.FullName $repoUrl) }} 39 + <div> 40 + <span>on <a href="/{{ $repoUrl }}">{{ $repoUrl }}</a></span> 41 + </div> 42 + {{ end }} 43 + </div> 44 + </li> 45 + {{ end }} 46 + </ul> 47 + </div> 48 + {{ end }} 49 + {{ end }}
+3 -2
appview/pages/templates/repo/fragments/cloneDropdown.html
··· 43 43 44 44 <!-- SSH Clone --> 45 45 <div class="mb-3"> 46 + {{ $repoOwnerHandle := resolve .RepoInfo.OwnerDid }} 46 47 <label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">SSH</label> 47 48 <div class="flex items-center border border-gray-300 dark:border-gray-600 rounded"> 48 49 <code 49 50 class="flex-1 px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-l select-all cursor-pointer whitespace-nowrap overflow-x-auto" 50 51 onclick="window.getSelection().selectAllChildren(this)" 51 - data-url="git@{{ $knot | stripPort }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}" 52 - >git@{{ $knot | stripPort }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code> 52 + data-url="git@{{ $knot | stripPort }}:{{ $repoOwnerHandle }}/{{ .RepoInfo.Name }}" 53 + >git@{{ $knot | stripPort }}:{{ $repoOwnerHandle }}/{{ .RepoInfo.Name }}</code> 53 54 <button 54 55 onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))" 55 56 class="px-3 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 border-l border-gray-300 dark:border-gray-600"
+2 -3
appview/pages/templates/repo/fragments/diff.html
··· 1 1 {{ define "repo/fragments/diff" }} 2 - {{ $repo := index . 0 }} 3 - {{ $diff := index . 1 }} 4 - {{ $opts := index . 2 }} 2 + {{ $diff := index . 0 }} 3 + {{ $opts := index . 1 }} 5 4 6 5 {{ $commit := $diff.Commit }} 7 6 {{ $diff := $diff.Diff }}
+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" }}
+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 }}
+31 -9
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> ··· 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" ··· 228 228 <span 229 229 class="mx-1 before:content-['ยท'] before:select-none" 230 230 ></span> 231 - <span> 232 - {{ $did := index $.EmailToDid .Author.Email }} 233 - <a href="{{ if $did }}/{{ resolve $did }}{{ else }}mailto:{{ .Author.Email }}{{ end }}" 234 - class="text-gray-500 dark:text-gray-400 no-underline hover:underline" 235 - >{{ if $did }}{{ template "user/fragments/picHandleLink" $did }}{{ else }}{{ .Author.Name }}{{ end }}</a> 236 - </span> 231 + {{ template "attribution" (list . $.EmailToDid) }} 237 232 <div class="inline-block px-1 select-none after:content-['ยท']"></div> 238 233 {{ template "repo/fragments/time" .Committer.When }} 239 234 ··· 259 254 {{ end }} 260 255 </div> 261 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> 262 284 {{ end }} 263 285 264 286 {{ define "branchList" }}
+2 -2
appview/pages/templates/repo/issues/fragments/issueCommentHeader.html
··· 19 19 {{ end }} 20 20 21 21 {{ define "timestamp" }} 22 - <a href="#{{ .Comment.Id }}" 22 + <a href="#comment-{{ .Comment.Id }}" 23 23 class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-400 hover:underline no-underline" 24 - id="{{ .Comment.Id }}"> 24 + id="comment-{{ .Comment.Id }}"> 25 25 {{ if .Comment.Deleted }} 26 26 {{ template "repo/fragments/shortTimeAgo" .Comment.Deleted }} 27 27 {{ else if .Comment.Edited }}
+3
appview/pages/templates/repo/issues/issue.html
··· 20 20 "Subject" $.Issue.AtUri 21 21 "State" $.Issue.Labels) }} 22 22 {{ template "repo/fragments/participants" $.Issue.Participants }} 23 + {{ template "repo/fragments/backlinks" 24 + (dict "RepoInfo" $.RepoInfo 25 + "Backlinks" $.Backlinks) }} 23 26 {{ template "repo/fragments/externalLinkPanel" $.Issue.AtUri }} 24 27 </div> 25 28 </div>
+2 -1
appview/pages/templates/repo/issues/issues.html
··· 32 32 <input type="hidden" name="state" value="{{ if .FilteringByOpen }}open{{ else }}closed{{ end }}"> 33 33 <div class="flex-1 flex relative"> 34 34 <input 35 + id="search-q" 35 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" 36 37 type="text" 37 38 name="q" ··· 53 54 </button> 54 55 </form> 55 56 <div class="sm:row-start-1"> 56 - {{ template "fragments/tabSelector" (dict "Name" "state" "Values" $values "Active" $active) }} 57 + {{ template "fragments/tabSelector" (dict "Name" "state" "Values" $values "Active" $active "Include" "#search-q") }} 57 58 </div> 58 59 <a 59 60 href="/{{ .RepoInfo.FullName }}/issues/new"
+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 - {{ $did := index $.EmailToDid $commit.Author.Email }} 31 - {{ if $did }} 32 - {{ template "user/fragments/picHandleLink" $did }} 33 - {{ else }} 34 - <a href="mailto:{{ $commit.Author.Email }}" class="text-gray-700 dark:text-gray-300 no-underline hover:underline">{{ $commit.Author.Name }}</a> 35 - {{ end }} 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 - {{ $did := index $.EmailToDid $commit.Author.Email }} 157 - <a href="{{ if $did }}/{{ $did }}{{ else }}mailto:{{ $commit.Author.Email }}{{ end }}" 158 - class="text-gray-500 dark:text-gray-400 no-underline hover:underline"> 159 - {{ if $did }}{{ template "user/fragments/picHandleLink" $did }}{{ else }}{{ $commit.Author.Name }}{{ end }} 160 - </a> 161 - </span> 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" }}
+1 -1
appview/pages/templates/repo/new.html
··· 165 165 </div> 166 166 <p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> 167 167 A knot hosts repository data and handles Git operations. 168 - 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>. 169 169 </p> 170 170 </div> 171 171 {{ end }}
+10
appview/pages/templates/repo/pipelines/workflow.html
··· 12 12 {{ block "sidebar" . }} {{ end }} 13 13 </div> 14 14 <div class="col-span-1 md:col-span-3"> 15 + <div class="flex justify-end mb-2"> 16 + <button 17 + class="btn" 18 + hx-post="/{{ $.RepoInfo.FullName }}/pipelines/{{ .Pipeline.Id }}/workflow/{{ .Workflow }}/cancel" 19 + hx-swap="none" 20 + {{ if (index .Pipeline.Statuses .Workflow).Latest.Status.IsFinish -}} 21 + disabled 22 + {{- end }} 23 + >Cancel</button> 24 + </div> 15 25 {{ block "logs" . }} {{ end }} 16 26 </div> 17 27 </section>
+1 -1
appview/pages/templates/repo/pulls/patch.html
··· 54 54 {{ end }} 55 55 56 56 {{ define "contentAfter" }} 57 - {{ template "repo/fragments/diff" (list .RepoInfo.FullName .Diff .DiffOpts) }} 57 + {{ template "repo/fragments/diff" (list .Diff .DiffOpts) }} 58 58 {{end}} 59 59 60 60 {{ define "contentAfterLeft" }}
+3
appview/pages/templates/repo/pulls/pull.html
··· 21 21 "Subject" $.Pull.AtUri 22 22 "State" $.Pull.Labels) }} 23 23 {{ template "repo/fragments/participants" $.Pull.Participants }} 24 + {{ template "repo/fragments/backlinks" 25 + (dict "RepoInfo" $.RepoInfo 26 + "Backlinks" $.Backlinks) }} 24 27 {{ template "repo/fragments/externalLinkPanel" $.Pull.AtUri }} 25 28 </div> 26 29 </div>
+2 -1
appview/pages/templates/repo/pulls/pulls.html
··· 38 38 <input type="hidden" name="state" value="{{ .FilteringBy.String }}"> 39 39 <div class="flex-1 flex relative"> 40 40 <input 41 + id="search-q" 41 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" 42 43 type="text" 43 44 name="q" ··· 59 60 </button> 60 61 </form> 61 62 <div class="sm:row-start-1"> 62 - {{ template "fragments/tabSelector" (dict "Name" "state" "Values" $values "Active" $active) }} 63 + {{ template "fragments/tabSelector" (dict "Name" "state" "Values" $values "Active" $active "Include" "#search-q") }} 63 64 </div> 64 65 <a 65 66 href="/{{ .RepoInfo.FullName }}/pulls/new"
+5 -4
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>
+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?"
+1 -1
appview/pages/templates/spindles/fragments/addMemberModal.html
··· 22 22 23 23 {{ define "addSpindleMemberPopover" }} 24 24 <form 25 - hx-post="/spindles/{{ .Instance }}/add" 25 + hx-post="/settings/spindles/{{ .Instance }}/add" 26 26 hx-indicator="#spinner" 27 27 hx-swap="none" 28 28 class="flex flex-col gap-2"
+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">
+4 -4
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 - <div class="flex gap-2 text-base"> 20 + <div class="flex gap-2 items-stretch text-base"> 21 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"
+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 }}
+2 -2
appview/pages/templates/user/fragments/followCard.html
··· 6 6 <img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" alt="{{ $userIdent }}" /> 7 7 </div> 8 8 9 - <div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2 w-full"> 9 + <div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2 w-full min-w-0"> 10 10 <div class="flex-1 min-h-0 justify-around flex flex-col"> 11 11 <a href="/{{ $userIdent }}"> 12 12 <span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $userIdent | truncateAt30 }}</span> 13 13 </a> 14 14 {{ with .Profile }} 15 - <p class="text-sm pb-2 md:pb-2">{{.Description}}</p> 15 + <p class="text-sm pb-2 md:pb-2 break-words">{{.Description}}</p> 16 16 {{ end }} 17 17 <div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 18 18 <span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
+1 -1
appview/pages/templates/user/fragments/profileCard.html
··· 1 1 {{ define "user/fragments/profileCard" }} 2 - {{ $userIdent := didOrHandle .UserDid .UserHandle }} 2 + {{ $userIdent := resolve .UserDid }} 3 3 <div class="grid grid-cols-3 md:grid-cols-1 gap-1 items-center"> 4 4 <div id="avatar" class="col-span-1 flex justify-center items-center"> 5 5 <div class="w-3/4 aspect-square relative">
+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 }}
+1 -1
appview/pages/templates/user/settings/notifications.html
··· 151 151 </div> 152 152 </div> 153 153 <label class="flex items-center gap-2"> 154 - <input type="checkbox" name="mentioned" {{if .Preferences.UserMentioned}}checked{{end}}> 154 + <input type="checkbox" name="user_mentioned" {{if .Preferences.UserMentioned}}checked{{end}}> 155 155 </label> 156 156 </div> 157 157
+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">
+98 -22
appview/pipelines/pipelines.go
··· 4 4 "bytes" 5 5 "context" 6 6 "encoding/json" 7 + "fmt" 7 8 "log/slog" 8 9 "net/http" 9 10 "strings" 10 11 "time" 11 12 13 + "tangled.org/core/api/tangled" 12 14 "tangled.org/core/appview/config" 13 15 "tangled.org/core/appview/db" 16 + "tangled.org/core/appview/models" 14 17 "tangled.org/core/appview/oauth" 15 18 "tangled.org/core/appview/pages" 16 19 "tangled.org/core/appview/reporesolver" 17 20 "tangled.org/core/eventconsumer" 18 21 "tangled.org/core/idresolver" 22 + "tangled.org/core/orm" 19 23 "tangled.org/core/rbac" 20 24 spindlemodel "tangled.org/core/spindle/models" 21 25 ··· 40 44 r.Get("/", p.Index) 41 45 r.Get("/{pipeline}/workflow/{workflow}", p.Workflow) 42 46 r.Get("/{pipeline}/workflow/{workflow}/logs", p.Logs) 47 + r.Post("/{pipeline}/workflow/{workflow}/cancel", p.Cancel) 43 48 44 49 return r 45 50 } ··· 78 83 return 79 84 } 80 85 81 - repoInfo := f.RepoInfo(user) 82 - 83 86 ps, err := db.GetPipelineStatuses( 84 87 p.db, 85 88 30, 86 - db.FilterEq("repo_owner", repoInfo.OwnerDid), 87 - db.FilterEq("repo_name", repoInfo.Name), 88 - db.FilterEq("knot", repoInfo.Knot), 89 + orm.FilterEq("repo_owner", f.Did), 90 + orm.FilterEq("repo_name", f.Name), 91 + orm.FilterEq("knot", f.Knot), 89 92 ) 90 93 if err != nil { 91 94 l.Error("failed to query db", "err", err) ··· 94 97 95 98 p.pages.Pipelines(w, pages.PipelinesParams{ 96 99 LoggedInUser: user, 97 - RepoInfo: repoInfo, 100 + RepoInfo: p.repoResolver.GetRepoInfo(r, user), 98 101 Pipelines: ps, 99 102 }) 100 103 } ··· 109 112 return 110 113 } 111 114 112 - repoInfo := f.RepoInfo(user) 113 - 114 115 pipelineId := chi.URLParam(r, "pipeline") 115 116 if pipelineId == "" { 116 117 l.Error("empty pipeline ID") ··· 126 127 ps, err := db.GetPipelineStatuses( 127 128 p.db, 128 129 1, 129 - db.FilterEq("repo_owner", repoInfo.OwnerDid), 130 - db.FilterEq("repo_name", repoInfo.Name), 131 - db.FilterEq("knot", repoInfo.Knot), 132 - db.FilterEq("id", pipelineId), 130 + orm.FilterEq("repo_owner", f.Did), 131 + orm.FilterEq("repo_name", f.Name), 132 + orm.FilterEq("knot", f.Knot), 133 + orm.FilterEq("id", pipelineId), 133 134 ) 134 135 if err != nil { 135 136 l.Error("failed to query db", "err", err) ··· 145 146 146 147 p.pages.Workflow(w, pages.WorkflowParams{ 147 148 LoggedInUser: user, 148 - RepoInfo: repoInfo, 149 + RepoInfo: p.repoResolver.GetRepoInfo(r, user), 149 150 Pipeline: singlePipeline, 150 151 Workflow: workflow, 151 152 }) ··· 176 177 ctx, cancel := context.WithCancel(r.Context()) 177 178 defer cancel() 178 179 179 - user := p.oauth.GetUser(r) 180 180 f, err := p.repoResolver.Resolve(r) 181 181 if err != nil { 182 182 l.Error("failed to get repo and knot", "err", err) 183 183 http.Error(w, "bad repo/knot", http.StatusBadRequest) 184 184 return 185 185 } 186 - 187 - repoInfo := f.RepoInfo(user) 188 186 189 187 pipelineId := chi.URLParam(r, "pipeline") 190 188 workflow := chi.URLParam(r, "workflow") ··· 196 194 ps, err := db.GetPipelineStatuses( 197 195 p.db, 198 196 1, 199 - db.FilterEq("repo_owner", repoInfo.OwnerDid), 200 - db.FilterEq("repo_name", repoInfo.Name), 201 - db.FilterEq("knot", repoInfo.Knot), 202 - db.FilterEq("id", pipelineId), 197 + orm.FilterEq("repo_owner", f.Did), 198 + orm.FilterEq("repo_name", f.Name), 199 + orm.FilterEq("knot", f.Knot), 200 + orm.FilterEq("id", pipelineId), 203 201 ) 204 202 if err != nil || len(ps) != 1 { 205 203 l.Error("pipeline query failed", "err", err, "count", len(ps)) ··· 208 206 } 209 207 210 208 singlePipeline := ps[0] 211 - spindle := repoInfo.Spindle 212 - knot := repoInfo.Knot 209 + spindle := f.Spindle 210 + knot := f.Knot 213 211 rkey := singlePipeline.Rkey 214 212 215 213 if spindle == "" || knot == "" || rkey == "" { ··· 320 318 } 321 319 } 322 320 } 321 + } 322 + 323 + func (p *Pipelines) Cancel(w http.ResponseWriter, r *http.Request) { 324 + l := p.logger.With("handler", "Cancel") 325 + 326 + var ( 327 + pipelineId = chi.URLParam(r, "pipeline") 328 + workflow = chi.URLParam(r, "workflow") 329 + ) 330 + if pipelineId == "" || workflow == "" { 331 + http.Error(w, "missing pipeline ID or workflow", http.StatusBadRequest) 332 + return 333 + } 334 + 335 + f, err := p.repoResolver.Resolve(r) 336 + if err != nil { 337 + l.Error("failed to get repo and knot", "err", err) 338 + http.Error(w, "bad repo/knot", http.StatusBadRequest) 339 + return 340 + } 341 + 342 + pipeline, err := func() (models.Pipeline, error) { 343 + ps, err := db.GetPipelineStatuses( 344 + p.db, 345 + 1, 346 + orm.FilterEq("repo_owner", f.Did), 347 + orm.FilterEq("repo_name", f.Name), 348 + orm.FilterEq("knot", f.Knot), 349 + orm.FilterEq("id", pipelineId), 350 + ) 351 + if err != nil { 352 + return models.Pipeline{}, err 353 + } 354 + if len(ps) != 1 { 355 + return models.Pipeline{}, fmt.Errorf("wrong pipeline count %d", len(ps)) 356 + } 357 + return ps[0], nil 358 + }() 359 + if err != nil { 360 + l.Error("pipeline query failed", "err", err) 361 + http.Error(w, "pipeline not found", http.StatusNotFound) 362 + } 363 + var ( 364 + spindle = f.Spindle 365 + knot = f.Knot 366 + rkey = pipeline.Rkey 367 + ) 368 + 369 + if spindle == "" || knot == "" || rkey == "" { 370 + http.Error(w, "invalid repo info", http.StatusBadRequest) 371 + return 372 + } 373 + 374 + spindleClient, err := p.oauth.ServiceClient( 375 + r, 376 + oauth.WithService(f.Spindle), 377 + oauth.WithLxm(tangled.PipelineCancelPipelineNSID), 378 + oauth.WithExp(60), 379 + oauth.WithDev(p.config.Core.Dev), 380 + oauth.WithTimeout(time.Second*30), // workflow cleanup usually takes time 381 + ) 382 + 383 + err = tangled.PipelineCancelPipeline( 384 + r.Context(), 385 + spindleClient, 386 + &tangled.PipelineCancelPipeline_Input{ 387 + Repo: string(f.RepoAt()), 388 + Pipeline: pipeline.AtUri().String(), 389 + Workflow: workflow, 390 + }, 391 + ) 392 + errorId := "pipeline-action" 393 + if err != nil { 394 + l.Error("failed to cancel pipeline", "err", err) 395 + p.pages.Notice(w, errorId, "Failed to add secret.") 396 + return 397 + } 398 + l.Debug("canceled pipeline", "uri", pipeline.AtUri()) 323 399 } 324 400 325 401 // either a message or an error
+3 -2
appview/pulls/opengraph.go
··· 13 13 "tangled.org/core/appview/db" 14 14 "tangled.org/core/appview/models" 15 15 "tangled.org/core/appview/ogcard" 16 + "tangled.org/core/orm" 16 17 "tangled.org/core/patchutil" 17 18 "tangled.org/core/types" 18 19 ) ··· 276 277 } 277 278 278 279 // Get comment count from database 279 - comments, err := db.GetPullComments(s.db, db.FilterEq("pull_id", pull.ID)) 280 + comments, err := db.GetPullComments(s.db, orm.FilterEq("pull_id", pull.ID)) 280 281 if err != nil { 281 282 log.Printf("failed to get pull comments: %v", err) 282 283 } ··· 293 294 filesChanged = niceDiff.Stat.FilesChanged 294 295 } 295 296 296 - card, err := s.drawPullSummaryCard(pull, &f.Repo, commentCount, diffStats, filesChanged) 297 + card, err := s.drawPullSummaryCard(pull, f, commentCount, diffStats, filesChanged) 297 298 if err != nil { 298 299 log.Println("failed to draw pull summary card", err) 299 300 http.Error(w, "failed to draw pull summary card", http.StatusInternalServerError)
+146 -142
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" ··· 18 19 "tangled.org/core/appview/config" 19 20 "tangled.org/core/appview/db" 20 21 pulls_indexer "tangled.org/core/appview/indexer/pulls" 22 + "tangled.org/core/appview/mentions" 21 23 "tangled.org/core/appview/models" 22 24 "tangled.org/core/appview/notify" 23 25 "tangled.org/core/appview/oauth" 24 26 "tangled.org/core/appview/pages" 25 27 "tangled.org/core/appview/pages/markup" 28 + "tangled.org/core/appview/pages/repoinfo" 26 29 "tangled.org/core/appview/reporesolver" 27 30 "tangled.org/core/appview/validator" 28 31 "tangled.org/core/appview/xrpcclient" 29 32 "tangled.org/core/idresolver" 33 + "tangled.org/core/orm" 30 34 "tangled.org/core/patchutil" 31 35 "tangled.org/core/rbac" 32 36 "tangled.org/core/tid" ··· 41 45 ) 42 46 43 47 type Pulls struct { 44 - oauth *oauth.OAuth 45 - repoResolver *reporesolver.RepoResolver 46 - pages *pages.Pages 47 - idResolver *idresolver.Resolver 48 - db *db.DB 49 - config *config.Config 50 - notifier notify.Notifier 51 - enforcer *rbac.Enforcer 52 - logger *slog.Logger 53 - validator *validator.Validator 54 - indexer *pulls_indexer.Indexer 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 55 60 } 56 61 57 62 func New( ··· 59 64 repoResolver *reporesolver.RepoResolver, 60 65 pages *pages.Pages, 61 66 resolver *idresolver.Resolver, 67 + mentionsResolver *mentions.Resolver, 62 68 db *db.DB, 63 69 config *config.Config, 64 70 notifier notify.Notifier, ··· 68 74 logger *slog.Logger, 69 75 ) *Pulls { 70 76 return &Pulls{ 71 - oauth: oauth, 72 - repoResolver: repoResolver, 73 - pages: pages, 74 - idResolver: resolver, 75 - db: db, 76 - config: config, 77 - notifier: notifier, 78 - enforcer: enforcer, 79 - logger: logger, 80 - validator: validator, 81 - indexer: indexer, 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, 82 89 } 83 90 } 84 91 ··· 123 130 124 131 s.pages.PullActionsFragment(w, pages.PullActionsParams{ 125 132 LoggedInUser: user, 126 - RepoInfo: f.RepoInfo(user), 133 + RepoInfo: s.repoResolver.GetRepoInfo(r, user), 127 134 Pull: pull, 128 135 RoundNumber: roundNumber, 129 136 MergeCheck: mergeCheckResponse, ··· 150 157 return 151 158 } 152 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 + 153 167 // can be nil if this pull is not stacked 154 168 stack, _ := r.Context().Value("stack").(models.Stack) 155 169 abandonedPulls, _ := r.Context().Value("abandonedPulls").([]*models.Pull) ··· 160 174 if user != nil && user.Did == pull.OwnerDid { 161 175 resubmitResult = s.resubmitCheck(r, f, pull, stack) 162 176 } 163 - 164 - repoInfo := f.RepoInfo(user) 165 177 166 178 m := make(map[string]models.Pipeline) 167 179 ··· 179 191 ps, err := db.GetPipelineStatuses( 180 192 s.db, 181 193 len(shas), 182 - db.FilterEq("repo_owner", repoInfo.OwnerDid), 183 - db.FilterEq("repo_name", repoInfo.Name), 184 - db.FilterEq("knot", repoInfo.Knot), 185 - db.FilterIn("sha", shas), 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), 186 198 ) 187 199 if err != nil { 188 200 log.Printf("failed to fetch pipeline statuses: %s", err) ··· 206 218 207 219 labelDefs, err := db.GetLabelDefinitions( 208 220 s.db, 209 - db.FilterIn("at_uri", f.Repo.Labels), 210 - db.FilterContains("scope", tangled.RepoPullNSID), 221 + orm.FilterIn("at_uri", f.Labels), 222 + orm.FilterContains("scope", tangled.RepoPullNSID), 211 223 ) 212 224 if err != nil { 213 225 log.Println("failed to fetch labels", err) ··· 222 234 223 235 s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 224 236 LoggedInUser: user, 225 - RepoInfo: repoInfo, 237 + RepoInfo: s.repoResolver.GetRepoInfo(r, user), 226 238 Pull: pull, 227 239 Stack: stack, 228 240 AbandonedPulls: abandonedPulls, 241 + Backlinks: backlinks, 229 242 BranchDeleteStatus: branchDeleteStatus, 230 243 MergeCheck: mergeCheckResponse, 231 244 ResubmitCheck: resubmitResult, ··· 239 252 }) 240 253 } 241 254 242 - 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 { 243 256 if pull.State == models.PullMerged { 244 257 return types.MergeCheckResponse{} 245 258 } ··· 268 281 r.Context(), 269 282 &xrpcc, 270 283 &tangled.RepoMergeCheck_Input{ 271 - Did: f.OwnerDid(), 284 + Did: f.Did, 272 285 Name: f.Name, 273 286 Branch: pull.TargetBranch, 274 287 Patch: patch, ··· 306 319 return result 307 320 } 308 321 309 - func (s *Pulls) branchDeleteStatus(r *http.Request, f *reporesolver.ResolvedRepo, pull *models.Pull) *models.BranchDeleteStatus { 322 + func (s *Pulls) branchDeleteStatus(r *http.Request, repo *models.Repo, pull *models.Pull) *models.BranchDeleteStatus { 310 323 if pull.State != models.PullMerged { 311 324 return nil 312 325 } ··· 317 330 } 318 331 319 332 var branch string 320 - var repo *models.Repo 321 333 // check if the branch exists 322 334 // NOTE: appview could cache branches/tags etc. for every repo by listening for gitRefUpdates 323 335 if pull.IsBranchBased() { 324 336 branch = pull.PullSource.Branch 325 - repo = &f.Repo 326 337 } else if pull.IsForkBased() { 327 338 branch = pull.PullSource.Branch 328 339 repo = pull.PullSource.Repo ··· 361 372 } 362 373 } 363 374 364 - func (s *Pulls) resubmitCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *models.Pull, stack models.Stack) pages.ResubmitResult { 375 + func (s *Pulls) resubmitCheck(r *http.Request, repo *models.Repo, pull *models.Pull, stack models.Stack) pages.ResubmitResult { 365 376 if pull.State == models.PullMerged || pull.State == models.PullDeleted || pull.PullSource == nil { 366 377 return pages.Unknown 367 378 } ··· 381 392 repoName = sourceRepo.Name 382 393 } else { 383 394 // pulls within the same repo 384 - knot = f.Knot 385 - ownerDid = f.OwnerDid() 386 - repoName = f.Name 395 + knot = repo.Knot 396 + ownerDid = repo.Did 397 + repoName = repo.Name 387 398 } 388 399 389 400 scheme := "http" ··· 395 406 Host: host, 396 407 } 397 408 398 - repo := fmt.Sprintf("%s/%s", ownerDid, repoName) 399 - 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) 400 411 if err != nil { 401 412 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 402 413 log.Println("failed to call XRPC repo.branches", xrpcerr) ··· 424 435 425 436 func (s *Pulls) RepoPullPatch(w http.ResponseWriter, r *http.Request) { 426 437 user := s.oauth.GetUser(r) 427 - f, err := s.repoResolver.Resolve(r) 428 - if err != nil { 429 - log.Println("failed to get repo and knot", err) 430 - return 431 - } 432 438 433 439 var diffOpts types.DiffOpts 434 440 if d := r.URL.Query().Get("diff"); d == "split" { ··· 457 463 458 464 s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{ 459 465 LoggedInUser: user, 460 - RepoInfo: f.RepoInfo(user), 466 + RepoInfo: s.repoResolver.GetRepoInfo(r, user), 461 467 Pull: pull, 462 468 Stack: stack, 463 469 Round: roundIdInt, ··· 471 477 func (s *Pulls) RepoPullInterdiff(w http.ResponseWriter, r *http.Request) { 472 478 user := s.oauth.GetUser(r) 473 479 474 - f, err := s.repoResolver.Resolve(r) 475 - if err != nil { 476 - log.Println("failed to get repo and knot", err) 477 - return 478 - } 479 - 480 480 var diffOpts types.DiffOpts 481 481 if d := r.URL.Query().Get("diff"); d == "split" { 482 482 diffOpts.Split = true ··· 521 521 522 522 s.pages.RepoPullInterdiffPage(w, pages.RepoPullInterdiffParams{ 523 523 LoggedInUser: s.oauth.GetUser(r), 524 - RepoInfo: f.RepoInfo(user), 524 + RepoInfo: s.repoResolver.GetRepoInfo(r, user), 525 525 Pull: pull, 526 526 Round: roundIdInt, 527 527 Interdiff: interdiff, ··· 598 598 599 599 pulls, err := db.GetPulls( 600 600 s.db, 601 - db.FilterIn("id", ids), 601 + orm.FilterIn("id", ids), 602 602 ) 603 603 if err != nil { 604 604 log.Println("failed to get pulls", err) ··· 646 646 } 647 647 pulls = pulls[:n] 648 648 649 - repoInfo := f.RepoInfo(user) 650 649 ps, err := db.GetPipelineStatuses( 651 650 s.db, 652 651 len(shas), 653 - db.FilterEq("repo_owner", repoInfo.OwnerDid), 654 - db.FilterEq("repo_name", repoInfo.Name), 655 - db.FilterEq("knot", repoInfo.Knot), 656 - db.FilterIn("sha", shas), 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), 657 656 ) 658 657 if err != nil { 659 658 log.Printf("failed to fetch pipeline statuses: %s", err) ··· 666 665 667 666 labelDefs, err := db.GetLabelDefinitions( 668 667 s.db, 669 - db.FilterIn("at_uri", f.Repo.Labels), 670 - db.FilterContains("scope", tangled.RepoPullNSID), 668 + orm.FilterIn("at_uri", f.Labels), 669 + orm.FilterContains("scope", tangled.RepoPullNSID), 671 670 ) 672 671 if err != nil { 673 672 log.Println("failed to fetch labels", err) ··· 682 681 683 682 s.pages.RepoPulls(w, pages.RepoPullsParams{ 684 683 LoggedInUser: s.oauth.GetUser(r), 685 - RepoInfo: f.RepoInfo(user), 684 + RepoInfo: s.repoResolver.GetRepoInfo(r, user), 686 685 Pulls: pulls, 687 686 LabelDefs: defs, 688 687 FilteringBy: state, ··· 693 692 } 694 693 695 694 func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) { 696 - l := s.logger.With("handler", "PullComment") 697 695 user := s.oauth.GetUser(r) 698 696 f, err := s.repoResolver.Resolve(r) 699 697 if err != nil { ··· 720 718 case http.MethodGet: 721 719 s.pages.PullNewCommentFragment(w, pages.PullNewCommentParams{ 722 720 LoggedInUser: user, 723 - RepoInfo: f.RepoInfo(user), 721 + RepoInfo: s.repoResolver.GetRepoInfo(r, user), 724 722 Pull: pull, 725 723 RoundNumber: roundNumber, 726 724 }) ··· 731 729 s.pages.Notice(w, "pull", "Comment body is required") 732 730 return 733 731 } 732 + 733 + mentions, references := s.mentionsResolver.Resolve(r.Context(), body) 734 734 735 735 // Start a transaction 736 736 tx, err := s.db.BeginTx(r.Context(), nil) ··· 774 774 Body: body, 775 775 CommentAt: atResp.Uri, 776 776 SubmissionId: pull.Submissions[roundNumber].ID, 777 + Mentions: mentions, 778 + References: references, 777 779 } 778 780 779 781 // Create the pull comment in the database with the commentAt field ··· 791 793 return 792 794 } 793 795 794 - rawMentions := markup.FindUserMentions(comment.Body) 795 - idents := s.idResolver.ResolveIdents(r.Context(), rawMentions) 796 - l.Debug("parsed mentions", "raw", rawMentions, "idents", idents) 797 - var mentions []syntax.DID 798 - for _, ident := range idents { 799 - if ident != nil && !ident.Handle.IsInvalidHandle() { 800 - mentions = append(mentions, ident.DID) 801 - } 802 - } 803 796 s.notifier.NewPullComment(r.Context(), comment, mentions) 804 797 805 - 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)) 806 800 return 807 801 } 808 802 } ··· 826 820 Host: host, 827 821 } 828 822 829 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 823 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 830 824 xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 831 825 if err != nil { 832 826 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { ··· 853 847 854 848 s.pages.RepoNewPull(w, pages.RepoNewPullParams{ 855 849 LoggedInUser: user, 856 - RepoInfo: f.RepoInfo(user), 850 + RepoInfo: s.repoResolver.GetRepoInfo(r, user), 857 851 Branches: result.Branches, 858 852 Strategy: strategy, 859 853 SourceBranch: sourceBranch, ··· 876 870 } 877 871 878 872 // Determine PR type based on input parameters 879 - isPushAllowed := f.RepoInfo(user).Roles.IsPushAllowed() 873 + roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())} 874 + isPushAllowed := roles.IsPushAllowed() 880 875 isBranchBased := isPushAllowed && sourceBranch != "" && fromFork == "" 881 876 isForkBased := fromFork != "" && sourceBranch != "" 882 877 isPatchBased := patch != "" && !isBranchBased && !isForkBased ··· 974 969 func (s *Pulls) handleBranchBasedPull( 975 970 w http.ResponseWriter, 976 971 r *http.Request, 977 - f *reporesolver.ResolvedRepo, 972 + repo *models.Repo, 978 973 user *oauth.User, 979 974 title, 980 975 body, ··· 986 981 if !s.config.Core.Dev { 987 982 scheme = "https" 988 983 } 989 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 984 + host := fmt.Sprintf("%s://%s", scheme, repo.Knot) 990 985 xrpcc := &indigoxrpc.Client{ 991 986 Host: host, 992 987 } 993 988 994 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 995 - 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) 996 991 if err != nil { 997 992 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 998 993 log.Println("failed to call XRPC repo.compare", xrpcerr) ··· 1029 1024 Sha: comparison.Rev2, 1030 1025 } 1031 1026 1032 - s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, combined, sourceRev, pullSource, recordPullSource, isStacked) 1027 + s.createPullRequest(w, r, repo, user, title, body, targetBranch, patch, combined, sourceRev, pullSource, recordPullSource, isStacked) 1033 1028 } 1034 1029 1035 - func (s *Pulls) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, f *reporesolver.ResolvedRepo, user *oauth.User, title, body, targetBranch, patch string, isStacked bool) { 1030 + func (s *Pulls) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, repo *models.Repo, user *oauth.User, title, body, targetBranch, patch string, isStacked bool) { 1036 1031 if err := s.validator.ValidatePatch(&patch); err != nil { 1037 1032 s.logger.Error("patch validation failed", "err", err) 1038 1033 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 1039 1034 return 1040 1035 } 1041 1036 1042 - 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) 1043 1038 } 1044 1039 1045 - func (s *Pulls) handleForkBasedPull(w http.ResponseWriter, r *http.Request, f *reporesolver.ResolvedRepo, user *oauth.User, forkRepo string, title, body, targetBranch, sourceBranch string, isStacked bool) { 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) { 1046 1041 repoString := strings.SplitN(forkRepo, "/", 2) 1047 1042 forkOwnerDid := repoString[0] 1048 1043 repoName := repoString[1] ··· 1144 1139 Sha: sourceRev, 1145 1140 } 1146 1141 1147 - s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, combined, sourceRev, pullSource, recordPullSource, isStacked) 1142 + s.createPullRequest(w, r, repo, user, title, body, targetBranch, patch, combined, sourceRev, pullSource, recordPullSource, isStacked) 1148 1143 } 1149 1144 1150 1145 func (s *Pulls) createPullRequest( 1151 1146 w http.ResponseWriter, 1152 1147 r *http.Request, 1153 - f *reporesolver.ResolvedRepo, 1148 + repo *models.Repo, 1154 1149 user *oauth.User, 1155 1150 title, body, targetBranch string, 1156 1151 patch string, ··· 1165 1160 s.createStackedPullRequest( 1166 1161 w, 1167 1162 r, 1168 - f, 1163 + repo, 1169 1164 user, 1170 1165 targetBranch, 1171 1166 patch, ··· 1211 1206 } 1212 1207 } 1213 1208 1209 + mentions, references := s.mentionsResolver.Resolve(r.Context(), body) 1210 + 1214 1211 rkey := tid.TID() 1215 1212 initialSubmission := models.PullSubmission{ 1216 1213 Patch: patch, ··· 1222 1219 Body: body, 1223 1220 TargetBranch: targetBranch, 1224 1221 OwnerDid: user.Did, 1225 - RepoAt: f.RepoAt(), 1222 + RepoAt: repo.RepoAt(), 1226 1223 Rkey: rkey, 1224 + Mentions: mentions, 1225 + References: references, 1227 1226 Submissions: []*models.PullSubmission{ 1228 1227 &initialSubmission, 1229 1228 }, ··· 1235 1234 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1236 1235 return 1237 1236 } 1238 - pullId, err := db.NextPullId(tx, f.RepoAt()) 1237 + pullId, err := db.NextPullId(tx, repo.RepoAt()) 1239 1238 if err != nil { 1240 1239 log.Println("failed to get pull id", err) 1241 1240 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") ··· 1250 1249 Val: &tangled.RepoPull{ 1251 1250 Title: title, 1252 1251 Target: &tangled.RepoPull_Target{ 1253 - Repo: string(f.RepoAt()), 1252 + Repo: string(repo.RepoAt()), 1254 1253 Branch: targetBranch, 1255 1254 }, 1256 1255 Patch: patch, ··· 1273 1272 1274 1273 s.notifier.NewPull(r.Context(), pull) 1275 1274 1276 - 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)) 1277 1277 } 1278 1278 1279 1279 func (s *Pulls) createStackedPullRequest( 1280 1280 w http.ResponseWriter, 1281 1281 r *http.Request, 1282 - f *reporesolver.ResolvedRepo, 1282 + repo *models.Repo, 1283 1283 user *oauth.User, 1284 1284 targetBranch string, 1285 1285 patch string, ··· 1311 1311 1312 1312 // build a stack out of this patch 1313 1313 stackId := uuid.New() 1314 - stack, err := newStack(f, user, targetBranch, patch, pullSource, stackId.String()) 1314 + stack, err := s.newStack(r.Context(), repo, user, targetBranch, patch, pullSource, stackId.String()) 1315 1315 if err != nil { 1316 1316 log.Println("failed to create stack", err) 1317 1317 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to create stack: %v", err)) ··· 1366 1366 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1367 1367 return 1368 1368 } 1369 + 1369 1370 } 1370 1371 1371 1372 if err = tx.Commit(); err != nil { ··· 1374 1375 return 1375 1376 } 1376 1377 1377 - 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)) 1378 1387 } 1379 1388 1380 1389 func (s *Pulls) ValidatePatch(w http.ResponseWriter, r *http.Request) { ··· 1405 1414 1406 1415 func (s *Pulls) PatchUploadFragment(w http.ResponseWriter, r *http.Request) { 1407 1416 user := s.oauth.GetUser(r) 1408 - f, err := s.repoResolver.Resolve(r) 1409 - if err != nil { 1410 - log.Println("failed to get repo and knot", err) 1411 - return 1412 - } 1413 1417 1414 1418 s.pages.PullPatchUploadFragment(w, pages.PullPatchUploadParams{ 1415 - RepoInfo: f.RepoInfo(user), 1419 + RepoInfo: s.repoResolver.GetRepoInfo(r, user), 1416 1420 }) 1417 1421 } 1418 1422 ··· 1433 1437 Host: host, 1434 1438 } 1435 1439 1436 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1440 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 1437 1441 xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 1438 1442 if err != nil { 1439 1443 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { ··· 1466 1470 } 1467 1471 1468 1472 s.pages.PullCompareBranchesFragment(w, pages.PullCompareBranchesParams{ 1469 - RepoInfo: f.RepoInfo(user), 1473 + RepoInfo: s.repoResolver.GetRepoInfo(r, user), 1470 1474 Branches: withoutDefault, 1471 1475 }) 1472 1476 } 1473 1477 1474 1478 func (s *Pulls) CompareForksFragment(w http.ResponseWriter, r *http.Request) { 1475 1479 user := s.oauth.GetUser(r) 1476 - f, err := s.repoResolver.Resolve(r) 1477 - if err != nil { 1478 - log.Println("failed to get repo and knot", err) 1479 - return 1480 - } 1481 1480 1482 1481 forks, err := db.GetForksByDid(s.db, user.Did) 1483 1482 if err != nil { ··· 1486 1485 } 1487 1486 1488 1487 s.pages.PullCompareForkFragment(w, pages.PullCompareForkParams{ 1489 - RepoInfo: f.RepoInfo(user), 1488 + RepoInfo: s.repoResolver.GetRepoInfo(r, user), 1490 1489 Forks: forks, 1491 1490 Selected: r.URL.Query().Get("fork"), 1492 1491 }) ··· 1508 1507 // fork repo 1509 1508 repo, err := db.GetRepo( 1510 1509 s.db, 1511 - db.FilterEq("did", forkOwnerDid), 1512 - db.FilterEq("name", forkName), 1510 + orm.FilterEq("did", forkOwnerDid), 1511 + orm.FilterEq("name", forkName), 1513 1512 ) 1514 1513 if err != nil { 1515 1514 log.Println("failed to get repo", "did", forkOwnerDid, "name", forkName, "err", err) ··· 1554 1553 Host: targetHost, 1555 1554 } 1556 1555 1557 - targetRepo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1556 + targetRepo := fmt.Sprintf("%s/%s", f.Did, f.Name) 1558 1557 targetXrpcBytes, err := tangled.RepoBranches(r.Context(), targetXrpcc, "", 0, targetRepo) 1559 1558 if err != nil { 1560 1559 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { ··· 1579 1578 }) 1580 1579 1581 1580 s.pages.PullCompareForkBranchesFragment(w, pages.PullCompareForkBranchesParams{ 1582 - RepoInfo: f.RepoInfo(user), 1581 + RepoInfo: s.repoResolver.GetRepoInfo(r, user), 1583 1582 SourceBranches: sourceBranches.Branches, 1584 1583 TargetBranches: targetBranches.Branches, 1585 1584 }) ··· 1587 1586 1588 1587 func (s *Pulls) ResubmitPull(w http.ResponseWriter, r *http.Request) { 1589 1588 user := s.oauth.GetUser(r) 1590 - f, err := s.repoResolver.Resolve(r) 1591 - if err != nil { 1592 - log.Println("failed to get repo and knot", err) 1593 - return 1594 - } 1595 1589 1596 1590 pull, ok := r.Context().Value("pull").(*models.Pull) 1597 1591 if !ok { ··· 1603 1597 switch r.Method { 1604 1598 case http.MethodGet: 1605 1599 s.pages.PullResubmitFragment(w, pages.PullResubmitParams{ 1606 - RepoInfo: f.RepoInfo(user), 1600 + RepoInfo: s.repoResolver.GetRepoInfo(r, user), 1607 1601 Pull: pull, 1608 1602 }) 1609 1603 return ··· 1670 1664 return 1671 1665 } 1672 1666 1673 - if !f.RepoInfo(user).Roles.IsPushAllowed() { 1667 + roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())} 1668 + if !roles.IsPushAllowed() { 1674 1669 log.Println("unauthorized user") 1675 1670 w.WriteHeader(http.StatusUnauthorized) 1676 1671 return ··· 1685 1680 Host: host, 1686 1681 } 1687 1682 1688 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1683 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 1689 1684 xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, pull.TargetBranch, pull.PullSource.Branch) 1690 1685 if err != nil { 1691 1686 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { ··· 1812 1807 func (s *Pulls) resubmitPullHelper( 1813 1808 w http.ResponseWriter, 1814 1809 r *http.Request, 1815 - f *reporesolver.ResolvedRepo, 1810 + repo *models.Repo, 1816 1811 user *oauth.User, 1817 1812 pull *models.Pull, 1818 1813 patch string, ··· 1821 1816 ) { 1822 1817 if pull.IsStacked() { 1823 1818 log.Println("resubmitting stacked PR") 1824 - s.resubmitStackedPullHelper(w, r, f, user, pull, patch, pull.StackId) 1819 + s.resubmitStackedPullHelper(w, r, repo, user, pull, patch, pull.StackId) 1825 1820 return 1826 1821 } 1827 1822 ··· 1901 1896 Val: &tangled.RepoPull{ 1902 1897 Title: pull.Title, 1903 1898 Target: &tangled.RepoPull_Target{ 1904 - Repo: string(f.RepoAt()), 1899 + Repo: string(repo.RepoAt()), 1905 1900 Branch: pull.TargetBranch, 1906 1901 }, 1907 1902 Patch: patch, // new patch ··· 1922 1917 return 1923 1918 } 1924 1919 1925 - 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)) 1926 1922 } 1927 1923 1928 1924 func (s *Pulls) resubmitStackedPullHelper( 1929 1925 w http.ResponseWriter, 1930 1926 r *http.Request, 1931 - f *reporesolver.ResolvedRepo, 1927 + repo *models.Repo, 1932 1928 user *oauth.User, 1933 1929 pull *models.Pull, 1934 1930 patch string, ··· 1937 1933 targetBranch := pull.TargetBranch 1938 1934 1939 1935 origStack, _ := r.Context().Value("stack").(models.Stack) 1940 - newStack, err := newStack(f, user, targetBranch, patch, pull.PullSource, stackId) 1936 + newStack, err := s.newStack(r.Context(), repo, user, targetBranch, patch, pull.PullSource, stackId) 1941 1937 if err != nil { 1942 1938 log.Println("failed to create resubmitted stack", err) 1943 1939 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") ··· 2079 2075 tx, 2080 2076 p.ParentChangeId, 2081 2077 // these should be enough filters to be unique per-stack 2082 - db.FilterEq("repo_at", p.RepoAt.String()), 2083 - db.FilterEq("owner_did", p.OwnerDid), 2084 - db.FilterEq("change_id", p.ChangeId), 2078 + orm.FilterEq("repo_at", p.RepoAt.String()), 2079 + orm.FilterEq("owner_did", p.OwnerDid), 2080 + orm.FilterEq("change_id", p.ChangeId), 2085 2081 ) 2086 2082 2087 2083 if err != nil { ··· 2115 2111 return 2116 2112 } 2117 2113 2118 - 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)) 2119 2116 } 2120 2117 2121 2118 func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) { ··· 2168 2165 2169 2166 authorName := ident.Handle.String() 2170 2167 mergeInput := &tangled.RepoMerge_Input{ 2171 - Did: f.OwnerDid(), 2168 + Did: f.Did, 2172 2169 Name: f.Name, 2173 2170 Branch: pull.TargetBranch, 2174 2171 Patch: patch, ··· 2233 2230 s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p) 2234 2231 } 2235 2232 2236 - 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)) 2237 2235 } 2238 2236 2239 2237 func (s *Pulls) ClosePull(w http.ResponseWriter, r *http.Request) { ··· 2253 2251 } 2254 2252 2255 2253 // auth filter: only owner or collaborators can close 2256 - roles := f.RolesInRepo(user) 2254 + roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())} 2257 2255 isOwner := roles.IsOwner() 2258 2256 isCollaborator := roles.IsCollaborator() 2259 2257 isPullAuthor := user.Did == pull.OwnerDid ··· 2305 2303 s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p) 2306 2304 } 2307 2305 2308 - 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)) 2309 2308 } 2310 2309 2311 2310 func (s *Pulls) ReopenPull(w http.ResponseWriter, r *http.Request) { ··· 2326 2325 } 2327 2326 2328 2327 // auth filter: only owner or collaborators can close 2329 - roles := f.RolesInRepo(user) 2328 + roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())} 2330 2329 isOwner := roles.IsOwner() 2331 2330 isCollaborator := roles.IsCollaborator() 2332 2331 isPullAuthor := user.Did == pull.OwnerDid ··· 2378 2377 s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p) 2379 2378 } 2380 2379 2381 - s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 2380 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 2381 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 2382 2382 } 2383 2383 2384 - 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) { 2385 2385 formatPatches, err := patchutil.ExtractPatches(patch) 2386 2386 if err != nil { 2387 2387 return nil, fmt.Errorf("Failed to extract patches: %v", err) ··· 2406 2406 body := fp.Body 2407 2407 rkey := tid.TID() 2408 2408 2409 + mentions, references := s.mentionsResolver.Resolve(ctx, body) 2410 + 2409 2411 initialSubmission := models.PullSubmission{ 2410 2412 Patch: fp.Raw, 2411 2413 SourceRev: fp.SHA, ··· 2416 2418 Body: body, 2417 2419 TargetBranch: targetBranch, 2418 2420 OwnerDid: user.Did, 2419 - RepoAt: f.RepoAt(), 2421 + RepoAt: repo.RepoAt(), 2420 2422 Rkey: rkey, 2423 + Mentions: mentions, 2424 + References: references, 2421 2425 Submissions: []*models.PullSubmission{ 2422 2426 &initialSubmission, 2423 2427 },
+2 -2
appview/repo/archive.go
··· 31 31 xrpcc := &indigoxrpc.Client{ 32 32 Host: host, 33 33 } 34 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 35 - archiveBytes, err := tangled.RepoArchive(r.Context(), xrpcc, "tar.gz", "", ref, repo) 34 + didSlashRepo := f.DidSlashRepo() 35 + archiveBytes, err := tangled.RepoArchive(r.Context(), xrpcc, "tar.gz", "", ref, didSlashRepo) 36 36 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 37 37 l.Error("failed to call XRPC repo.archive", "err", xrpcerr) 38 38 rp.pages.Error503(w)
+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 {
+11 -9
appview/repo/blob.go
··· 54 54 xrpcc := &indigoxrpc.Client{ 55 55 Host: host, 56 56 } 57 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name) 57 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 58 58 resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, repo) 59 59 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 60 60 l.Error("failed to call XRPC repo.blob", "err", xrpcerr) ··· 62 62 return 63 63 } 64 64 65 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 66 + 65 67 // Use XRPC response directly instead of converting to internal types 66 68 var breadcrumbs [][]string 67 - breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))}) 69 + breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", ownerSlashRepo, url.PathEscape(ref))}) 68 70 if filePath != "" { 69 71 for idx, elem := range strings.Split(filePath, "/") { 70 72 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))}) ··· 78 80 79 81 rp.pages.RepoBlob(w, pages.RepoBlobParams{ 80 82 LoggedInUser: user, 81 - RepoInfo: f.RepoInfo(user), 83 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 82 84 BreadCrumbs: breadcrumbs, 83 85 BlobView: blobView, 84 86 RepoBlob_Output: resp, ··· 105 107 if !rp.config.Core.Dev { 106 108 scheme = "https" 107 109 } 108 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name) 110 + repo := f.DidSlashRepo() 109 111 baseURL := &url.URL{ 110 112 Scheme: scheme, 111 113 Host: f.Knot, ··· 176 178 } 177 179 178 180 // NewBlobView creates a BlobView from the XRPC response 179 - func NewBlobView(resp *tangled.RepoBlob_Output, config *config.Config, f *reporesolver.ResolvedRepo, ref, filePath string, queryParams url.Values) models.BlobView { 181 + func NewBlobView(resp *tangled.RepoBlob_Output, config *config.Config, repo *models.Repo, ref, filePath string, queryParams url.Values) models.BlobView { 180 182 view := models.BlobView{ 181 183 Contents: "", 182 184 Lines: 0, ··· 198 200 199 201 // Determine if binary 200 202 if resp.IsBinary != nil && *resp.IsBinary { 201 - view.ContentSrc = generateBlobURL(config, f, ref, filePath) 203 + view.ContentSrc = generateBlobURL(config, repo, ref, filePath) 202 204 ext := strings.ToLower(filepath.Ext(resp.Path)) 203 205 204 206 switch ext { ··· 250 252 return view 251 253 } 252 254 253 - func generateBlobURL(config *config.Config, f *reporesolver.ResolvedRepo, ref, filePath string) string { 255 + func generateBlobURL(config *config.Config, repo *models.Repo, ref, filePath string) string { 254 256 scheme := "http" 255 257 if !config.Core.Dev { 256 258 scheme = "https" 257 259 } 258 260 259 - repoName := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 261 + repoName := fmt.Sprintf("%s/%s", repo.Did, repo.Name) 260 262 baseURL := &url.URL{ 261 263 Scheme: scheme, 262 - Host: f.Knot, 264 + Host: repo.Knot, 263 265 Path: "/xrpc/sh.tangled.repo.blob", 264 266 } 265 267 query := baseURL.Query()
+2 -2
appview/repo/branches.go
··· 29 29 xrpcc := &indigoxrpc.Client{ 30 30 Host: host, 31 31 } 32 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 32 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 33 33 xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 34 34 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 35 35 l.Error("failed to call XRPC repo.branches", "err", xrpcerr) ··· 46 46 user := rp.oauth.GetUser(r) 47 47 rp.pages.RepoBranches(w, pages.RepoBranchesParams{ 48 48 LoggedInUser: user, 49 - RepoInfo: f.RepoInfo(user), 49 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 50 50 RepoBranchesResponse: result, 51 51 }) 52 52 }
+4 -8
appview/repo/compare.go
··· 36 36 Host: host, 37 37 } 38 38 39 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 39 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 40 40 branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 41 41 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 42 42 l.Error("failed to call XRPC repo.branches", "err", xrpcerr) ··· 88 88 return 89 89 } 90 90 91 - repoinfo := f.RepoInfo(user) 92 - 93 91 rp.pages.RepoCompareNew(w, pages.RepoCompareNewParams{ 94 92 LoggedInUser: user, 95 - RepoInfo: repoinfo, 93 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 96 94 Branches: branches, 97 95 Tags: tags.Tags, 98 96 Base: base, ··· 151 149 Host: host, 152 150 } 153 151 154 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 152 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 155 153 156 154 branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 157 155 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { ··· 202 200 diff = patchutil.AsNiceDiff(formatPatch.FormatPatchRaw, base) 203 201 } 204 202 205 - repoinfo := f.RepoInfo(user) 206 - 207 203 rp.pages.RepoCompare(w, pages.RepoCompareParams{ 208 204 LoggedInUser: user, 209 - RepoInfo: repoinfo, 205 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 210 206 Branches: branches.Branches, 211 207 Tags: tags.Tags, 212 208 Base: base,
+24 -17
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 ··· 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)
+18 -19
appview/repo/index.go
··· 22 22 "tangled.org/core/appview/db" 23 23 "tangled.org/core/appview/models" 24 24 "tangled.org/core/appview/pages" 25 - "tangled.org/core/appview/reporesolver" 26 25 "tangled.org/core/appview/xrpcclient" 26 + "tangled.org/core/orm" 27 27 "tangled.org/core/types" 28 28 29 29 "github.com/go-chi/chi/v5" ··· 52 52 } 53 53 54 54 user := rp.oauth.GetUser(r) 55 - repoInfo := f.RepoInfo(user) 56 55 57 56 // Build index response from multiple XRPC calls 58 57 result, err := rp.buildIndexResponse(r.Context(), xrpcc, f, ref) ··· 62 61 rp.pages.RepoIndexPage(w, pages.RepoIndexParams{ 63 62 LoggedInUser: user, 64 63 NeedsKnotUpgrade: true, 65 - RepoInfo: repoInfo, 64 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 66 65 }) 67 66 return 68 67 } ··· 124 123 l.Error("failed to get email to did map", "err", err) 125 124 } 126 125 127 - vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, commitsTrunc) 126 + vc, err := commitverify.GetVerifiedCommits(rp.db, emailToDidMap, commitsTrunc) 128 127 if err != nil { 129 128 l.Error("failed to GetVerifiedObjectCommits", "err", err) 130 129 } ··· 140 139 for _, c := range commitsTrunc { 141 140 shas = append(shas, c.Hash.String()) 142 141 } 143 - pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas) 142 + pipelines, err := getPipelineStatuses(rp.db, f, shas) 144 143 if err != nil { 145 144 l.Error("failed to fetch pipeline statuses", "err", err) 146 145 // non-fatal ··· 148 147 149 148 rp.pages.RepoIndexPage(w, pages.RepoIndexParams{ 150 149 LoggedInUser: user, 151 - RepoInfo: repoInfo, 150 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 152 151 TagMap: tagMap, 153 152 RepoIndexResponse: *result, 154 153 CommitsTrunc: commitsTrunc, ··· 165 164 func (rp *Repo) getLanguageInfo( 166 165 ctx context.Context, 167 166 l *slog.Logger, 168 - f *reporesolver.ResolvedRepo, 167 + repo *models.Repo, 169 168 xrpcc *indigoxrpc.Client, 170 169 currentRef string, 171 170 isDefaultRef bool, ··· 173 172 // first attempt to fetch from db 174 173 langs, err := db.GetRepoLanguages( 175 174 rp.db, 176 - db.FilterEq("repo_at", f.RepoAt()), 177 - db.FilterEq("ref", currentRef), 175 + orm.FilterEq("repo_at", repo.RepoAt()), 176 + orm.FilterEq("ref", currentRef), 178 177 ) 179 178 180 179 if err != nil || langs == nil { 181 180 // non-fatal, fetch langs from ks via XRPC 182 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 183 - ls, err := tangled.RepoLanguages(ctx, xrpcc, currentRef, repo) 181 + didSlashRepo := fmt.Sprintf("%s/%s", repo.Did, repo.Name) 182 + ls, err := tangled.RepoLanguages(ctx, xrpcc, currentRef, didSlashRepo) 184 183 if err != nil { 185 184 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 186 185 l.Error("failed to call XRPC repo.languages", "err", xrpcerr) ··· 195 194 196 195 for _, lang := range ls.Languages { 197 196 langs = append(langs, models.RepoLanguage{ 198 - RepoAt: f.RepoAt(), 197 + RepoAt: repo.RepoAt(), 199 198 Ref: currentRef, 200 199 IsDefaultRef: isDefaultRef, 201 200 Language: lang.Name, ··· 210 209 defer tx.Rollback() 211 210 212 211 // update appview's cache 213 - err = db.UpdateRepoLanguages(tx, f.RepoAt(), currentRef, langs) 212 + err = db.UpdateRepoLanguages(tx, repo.RepoAt(), currentRef, langs) 214 213 if err != nil { 215 214 // non-fatal 216 215 l.Error("failed to cache lang results", "err", err) ··· 255 254 } 256 255 257 256 // buildIndexResponse creates a RepoIndexResponse by combining multiple xrpc calls in parallel 258 - func (rp *Repo) buildIndexResponse(ctx context.Context, xrpcc *indigoxrpc.Client, f *reporesolver.ResolvedRepo, ref string) (*types.RepoIndexResponse, error) { 259 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 257 + func (rp *Repo) buildIndexResponse(ctx context.Context, xrpcc *indigoxrpc.Client, repo *models.Repo, ref string) (*types.RepoIndexResponse, error) { 258 + didSlashRepo := fmt.Sprintf("%s/%s", repo.Did, repo.Name) 260 259 261 260 // first get branches to determine the ref if not specified 262 - branchesBytes, err := tangled.RepoBranches(ctx, xrpcc, "", 0, repo) 261 + branchesBytes, err := tangled.RepoBranches(ctx, xrpcc, "", 0, didSlashRepo) 263 262 if err != nil { 264 263 return nil, fmt.Errorf("failed to call repoBranches: %w", err) 265 264 } ··· 303 302 wg.Add(1) 304 303 go func() { 305 304 defer wg.Done() 306 - tagsBytes, err := tangled.RepoTags(ctx, xrpcc, "", 0, repo) 305 + tagsBytes, err := tangled.RepoTags(ctx, xrpcc, "", 0, didSlashRepo) 307 306 if err != nil { 308 307 errs = errors.Join(errs, fmt.Errorf("failed to call repoTags: %w", err)) 309 308 return ··· 318 317 wg.Add(1) 319 318 go func() { 320 319 defer wg.Done() 321 - resp, err := tangled.RepoTree(ctx, xrpcc, "", ref, repo) 320 + resp, err := tangled.RepoTree(ctx, xrpcc, "", ref, didSlashRepo) 322 321 if err != nil { 323 322 errs = errors.Join(errs, fmt.Errorf("failed to call repoTree: %w", err)) 324 323 return ··· 330 329 wg.Add(1) 331 330 go func() { 332 331 defer wg.Done() 333 - logBytes, err := tangled.RepoLog(ctx, xrpcc, "", 50, "", ref, repo) 332 + logBytes, err := tangled.RepoLog(ctx, xrpcc, "", 50, "", ref, didSlashRepo) 334 333 if err != nil { 335 334 errs = errors.Join(errs, fmt.Errorf("failed to call repoLog: %w", err)) 336 335 return
+8 -11
appview/repo/log.go
··· 57 57 cursor = strconv.Itoa(offset) 58 58 } 59 59 60 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 60 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 61 61 xrpcBytes, err := tangled.RepoLog(r.Context(), xrpcc, cursor, limit, "", ref, repo) 62 62 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 63 63 l.Error("failed to call XRPC repo.log", "err", xrpcerr) ··· 116 116 l.Error("failed to fetch email to did mapping", "err", err) 117 117 } 118 118 119 - vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, xrpcResp.Commits) 119 + vc, err := commitverify.GetVerifiedCommits(rp.db, emailToDidMap, xrpcResp.Commits) 120 120 if err != nil { 121 121 l.Error("failed to GetVerifiedObjectCommits", "err", err) 122 122 } 123 - 124 - repoInfo := f.RepoInfo(user) 125 123 126 124 var shas []string 127 125 for _, c := range xrpcResp.Commits { 128 126 shas = append(shas, c.Hash.String()) 129 127 } 130 - pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas) 128 + pipelines, err := getPipelineStatuses(rp.db, f, shas) 131 129 if err != nil { 132 130 l.Error("failed to getPipelineStatuses", "err", err) 133 131 // non-fatal ··· 136 134 rp.pages.RepoLog(w, pages.RepoLogParams{ 137 135 LoggedInUser: user, 138 136 TagMap: tagMap, 139 - RepoInfo: repoInfo, 137 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 140 138 RepoLogResponse: xrpcResp, 141 139 EmailToDid: emailToDidMap, 142 140 VerifiedCommits: vc, ··· 174 172 Host: host, 175 173 } 176 174 177 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 175 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 178 176 xrpcBytes, err := tangled.RepoDiff(r.Context(), xrpcc, ref, repo) 179 177 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 180 178 l.Error("failed to call XRPC repo.diff", "err", xrpcerr) ··· 194 192 l.Error("failed to get email to did mapping", "err", err) 195 193 } 196 194 197 - vc, err := commitverify.GetVerifiedCommits(rp.db, emailToDidMap, []types.NiceDiff{*result.Diff}) 195 + vc, err := commitverify.GetVerifiedCommits(rp.db, emailToDidMap, []types.Commit{result.Diff.Commit}) 198 196 if err != nil { 199 197 l.Error("failed to GetVerifiedCommits", "err", err) 200 198 } 201 199 202 200 user := rp.oauth.GetUser(r) 203 - repoInfo := f.RepoInfo(user) 204 - pipelines, err := getPipelineStatuses(rp.db, repoInfo, []string{result.Diff.Commit.This}) 201 + pipelines, err := getPipelineStatuses(rp.db, f, []string{result.Diff.Commit.This}) 205 202 if err != nil { 206 203 l.Error("failed to getPipelineStatuses", "err", err) 207 204 // non-fatal ··· 213 210 214 211 rp.pages.RepoCommit(w, pages.RepoCommitParams{ 215 212 LoggedInUser: user, 216 - RepoInfo: f.RepoInfo(user), 213 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 217 214 RepoCommitResponse: result, 218 215 EmailToDid: emailToDidMap, 219 216 VerifiedCommit: vc,
+4 -3
appview/repo/opengraph.go
··· 16 16 "tangled.org/core/appview/db" 17 17 "tangled.org/core/appview/models" 18 18 "tangled.org/core/appview/ogcard" 19 + "tangled.org/core/orm" 19 20 "tangled.org/core/types" 20 21 ) 21 22 ··· 338 339 var languageStats []types.RepoLanguageDetails 339 340 langs, err := db.GetRepoLanguages( 340 341 rp.db, 341 - db.FilterEq("repo_at", f.RepoAt()), 342 - db.FilterEq("is_default_ref", 1), 342 + orm.FilterEq("repo_at", f.RepoAt()), 343 + orm.FilterEq("is_default_ref", 1), 343 344 ) 344 345 if err != nil { 345 346 log.Printf("failed to get language stats from db: %v", err) ··· 374 375 }) 375 376 } 376 377 377 - card, err := rp.drawRepoSummaryCard(&f.Repo, languageStats) 378 + card, err := rp.drawRepoSummaryCard(f, languageStats) 378 379 if err != nil { 379 380 log.Println("failed to draw repo summary card", err) 380 381 http.Error(w, "failed to draw repo summary card", http.StatusInternalServerError)
+35 -35
appview/repo/repo.go
··· 24 24 xrpcclient "tangled.org/core/appview/xrpcclient" 25 25 "tangled.org/core/eventconsumer" 26 26 "tangled.org/core/idresolver" 27 + "tangled.org/core/orm" 27 28 "tangled.org/core/rbac" 28 29 "tangled.org/core/tid" 29 30 "tangled.org/core/xrpc/serviceauth" ··· 118 119 } 119 120 } 120 121 121 - newRepo := f.Repo 122 + newRepo := *f 122 123 newRepo.Spindle = newSpindle 123 124 record := newRepo.AsRecord() 124 125 ··· 257 258 l.Info("wrote label record to PDS") 258 259 259 260 // update the repo to subscribe to this label 260 - newRepo := f.Repo 261 + newRepo := *f 261 262 newRepo.Labels = append(newRepo.Labels, aturi) 262 263 repoRecord := newRepo.AsRecord() 263 264 ··· 345 346 // get form values 346 347 labelId := r.FormValue("label-id") 347 348 348 - label, err := db.GetLabelDefinition(rp.db, db.FilterEq("id", labelId)) 349 + label, err := db.GetLabelDefinition(rp.db, orm.FilterEq("id", labelId)) 349 350 if err != nil { 350 351 fail("Failed to find label definition.", err) 351 352 return ··· 369 370 } 370 371 371 372 // update repo record to remove the label reference 372 - newRepo := f.Repo 373 + newRepo := *f 373 374 var updated []string 374 375 removedAt := label.AtUri().String() 375 376 for _, l := range newRepo.Labels { ··· 409 410 410 411 err = db.UnsubscribeLabel( 411 412 tx, 412 - db.FilterEq("repo_at", f.RepoAt()), 413 - db.FilterEq("label_at", removedAt), 413 + orm.FilterEq("repo_at", f.RepoAt()), 414 + orm.FilterEq("label_at", removedAt), 414 415 ) 415 416 if err != nil { 416 417 fail("Failed to unsubscribe label.", err) 417 418 return 418 419 } 419 420 420 - err = db.DeleteLabelDefinition(tx, db.FilterEq("id", label.Id)) 421 + err = db.DeleteLabelDefinition(tx, orm.FilterEq("id", label.Id)) 421 422 if err != nil { 422 423 fail("Failed to delete label definition.", err) 423 424 return ··· 456 457 } 457 458 458 459 labelAts := r.Form["label"] 459 - _, err = db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", labelAts)) 460 + _, err = db.GetLabelDefinitions(rp.db, orm.FilterIn("at_uri", labelAts)) 460 461 if err != nil { 461 462 fail("Failed to subscribe to label.", err) 462 463 return 463 464 } 464 465 465 - newRepo := f.Repo 466 + newRepo := *f 466 467 newRepo.Labels = append(newRepo.Labels, labelAts...) 467 468 468 469 // dedup ··· 477 478 return 478 479 } 479 480 480 - 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) 481 482 if err != nil { 482 483 fail("Failed to update labels, no record found on PDS.", err) 483 484 return ··· 542 543 } 543 544 544 545 labelAts := r.Form["label"] 545 - _, err = db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", labelAts)) 546 + _, err = db.GetLabelDefinitions(rp.db, orm.FilterIn("at_uri", labelAts)) 546 547 if err != nil { 547 548 fail("Failed to unsubscribe to label.", err) 548 549 return 549 550 } 550 551 551 552 // update repo record to remove the label reference 552 - newRepo := f.Repo 553 + newRepo := *f 553 554 var updated []string 554 555 for _, l := range newRepo.Labels { 555 556 if !slices.Contains(labelAts, l) { ··· 565 566 return 566 567 } 567 568 568 - 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) 569 570 if err != nil { 570 571 fail("Failed to update labels, no record found on PDS.", err) 571 572 return ··· 582 583 583 584 err = db.UnsubscribeLabel( 584 585 rp.db, 585 - db.FilterEq("repo_at", f.RepoAt()), 586 - db.FilterIn("label_at", labelAts), 586 + orm.FilterEq("repo_at", f.RepoAt()), 587 + orm.FilterIn("label_at", labelAts), 587 588 ) 588 589 if err != nil { 589 590 fail("Failed to unsubscribe label.", err) ··· 612 613 613 614 labelDefs, err := db.GetLabelDefinitions( 614 615 rp.db, 615 - db.FilterIn("at_uri", f.Repo.Labels), 616 - db.FilterContains("scope", subject.Collection().String()), 616 + orm.FilterIn("at_uri", f.Labels), 617 + orm.FilterContains("scope", subject.Collection().String()), 617 618 ) 618 619 if err != nil { 619 620 l.Error("failed to fetch label defs", "err", err) ··· 625 626 defs[l.AtUri().String()] = &l 626 627 } 627 628 628 - states, err := db.GetLabels(rp.db, db.FilterEq("subject", subject)) 629 + states, err := db.GetLabels(rp.db, orm.FilterEq("subject", subject)) 629 630 if err != nil { 630 631 l.Error("failed to build label state", "err", err) 631 632 return ··· 635 636 user := rp.oauth.GetUser(r) 636 637 rp.pages.LabelPanel(w, pages.LabelPanelParams{ 637 638 LoggedInUser: user, 638 - RepoInfo: f.RepoInfo(user), 639 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 639 640 Defs: defs, 640 641 Subject: subject.String(), 641 642 State: state, ··· 660 661 661 662 labelDefs, err := db.GetLabelDefinitions( 662 663 rp.db, 663 - db.FilterIn("at_uri", f.Repo.Labels), 664 - db.FilterContains("scope", subject.Collection().String()), 664 + orm.FilterIn("at_uri", f.Labels), 665 + orm.FilterContains("scope", subject.Collection().String()), 665 666 ) 666 667 if err != nil { 667 668 l.Error("failed to fetch labels", "err", err) ··· 673 674 defs[l.AtUri().String()] = &l 674 675 } 675 676 676 - states, err := db.GetLabels(rp.db, db.FilterEq("subject", subject)) 677 + states, err := db.GetLabels(rp.db, orm.FilterEq("subject", subject)) 677 678 if err != nil { 678 679 l.Error("failed to build label state", "err", err) 679 680 return ··· 683 684 user := rp.oauth.GetUser(r) 684 685 rp.pages.EditLabelPanel(w, pages.EditLabelPanelParams{ 685 686 LoggedInUser: user, 686 - RepoInfo: f.RepoInfo(user), 687 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 687 688 Defs: defs, 688 689 Subject: subject.String(), 689 690 State: state, ··· 864 865 r.Context(), 865 866 client, 866 867 &tangled.RepoDelete_Input{ 867 - Did: f.OwnerDid(), 868 + Did: f.Did, 868 869 Name: f.Name, 869 870 Rkey: f.Rkey, 870 871 }, ··· 902 903 l.Info("removed collaborators") 903 904 904 905 // remove repo RBAC 905 - err = rp.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo()) 906 + err = rp.enforcer.RemoveRepo(f.Did, f.Knot, f.DidSlashRepo()) 906 907 if err != nil { 907 908 rp.pages.Notice(w, noticeId, "Failed to update RBAC rules") 908 909 return 909 910 } 910 911 911 912 // remove repo from db 912 - err = db.RemoveRepo(tx, f.OwnerDid(), f.Name) 913 + err = db.RemoveRepo(tx, f.Did, f.Name) 913 914 if err != nil { 914 915 rp.pages.Notice(w, noticeId, "Failed to update appview") 915 916 return ··· 930 931 return 931 932 } 932 933 933 - rp.pages.HxRedirect(w, fmt.Sprintf("/%s", f.OwnerDid())) 934 + rp.pages.HxRedirect(w, fmt.Sprintf("/%s", f.Did)) 934 935 } 935 936 936 937 func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) { ··· 959 960 return 960 961 } 961 962 962 - repoInfo := f.RepoInfo(user) 963 - if repoInfo.Source == nil { 963 + if f.Source == "" { 964 964 rp.pages.Notice(w, "repo", "This repository is not a fork.") 965 965 return 966 966 } ··· 971 971 &tangled.RepoForkSync_Input{ 972 972 Did: user.Did, 973 973 Name: f.Name, 974 - Source: repoInfo.Source.RepoAt().String(), 974 + Source: f.Source, 975 975 Branch: ref, 976 976 }, 977 977 ) ··· 1007 1007 rp.pages.ForkRepo(w, pages.ForkRepoParams{ 1008 1008 LoggedInUser: user, 1009 1009 Knots: knots, 1010 - RepoInfo: f.RepoInfo(user), 1010 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 1011 1011 }) 1012 1012 1013 1013 case http.MethodPost: ··· 1037 1037 // in the user's account. 1038 1038 existingRepo, err := db.GetRepo( 1039 1039 rp.db, 1040 - db.FilterEq("did", user.Did), 1041 - db.FilterEq("name", forkName), 1040 + orm.FilterEq("did", user.Did), 1041 + orm.FilterEq("name", forkName), 1042 1042 ) 1043 1043 if err != nil { 1044 1044 if !errors.Is(err, sql.ErrNoRows) { ··· 1058 1058 uri = "http" 1059 1059 } 1060 1060 1061 - 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) 1062 1062 l = l.With("cloneUrl", forkSourceUrl) 1063 1063 1064 1064 sourceAt := f.RepoAt().String() ··· 1071 1071 Knot: targetKnot, 1072 1072 Rkey: rkey, 1073 1073 Source: sourceAt, 1074 - Description: f.Repo.Description, 1074 + Description: f.Description, 1075 1075 Created: time.Now(), 1076 1076 Labels: rp.config.Label.DefaultLabelDefs, 1077 1077 }
+17 -19
appview/repo/repo_util.go
··· 1 1 package repo 2 2 3 3 import ( 4 + "maps" 4 5 "slices" 5 6 "sort" 6 7 "strings" 7 8 8 9 "tangled.org/core/appview/db" 9 10 "tangled.org/core/appview/models" 10 - "tangled.org/core/appview/pages/repoinfo" 11 + "tangled.org/core/orm" 11 12 "tangled.org/core/types" 12 - 13 - "github.com/go-git/go-git/v5/plumbing/object" 14 13 ) 15 14 16 15 func sortFiles(files []types.NiceTree) { ··· 43 42 }) 44 43 } 45 44 46 - func uniqueEmails(commits []*object.Commit) []string { 45 + func uniqueEmails(commits []types.Commit) []string { 47 46 emails := make(map[string]struct{}) 48 47 for _, commit := range commits { 49 - if commit.Author.Email != "" { 50 - emails[commit.Author.Email] = struct{}{} 51 - } 52 - if commit.Committer.Email != "" { 53 - emails[commit.Committer.Email] = struct{}{} 48 + emails[commit.Author.Email] = struct{}{} 49 + emails[commit.Committer.Email] = struct{}{} 50 + for _, c := range commit.CoAuthors() { 51 + emails[c.Email] = struct{}{} 54 52 } 55 53 } 56 - var uniqueEmails []string 57 - for email := range emails { 58 - uniqueEmails = append(uniqueEmails, email) 59 - } 60 - return uniqueEmails 54 + 55 + // delete empty emails if any, from the set 56 + delete(emails, "") 57 + 58 + return slices.Collect(maps.Keys(emails)) 61 59 } 62 60 63 61 func balanceIndexItems(commitCount, branchCount, tagCount, fileCount int) (commitsTrunc int, branchesTrunc int, tagsTrunc int) { ··· 93 91 // golang is so blessed that it requires 35 lines of imperative code for this 94 92 func getPipelineStatuses( 95 93 d *db.DB, 96 - repoInfo repoinfo.RepoInfo, 94 + repo *models.Repo, 97 95 shas []string, 98 96 ) (map[string]models.Pipeline, error) { 99 97 m := make(map[string]models.Pipeline) ··· 105 103 ps, err := db.GetPipelineStatuses( 106 104 d, 107 105 len(shas), 108 - db.FilterEq("repo_owner", repoInfo.OwnerDid), 109 - db.FilterEq("repo_name", repoInfo.Name), 110 - db.FilterEq("knot", repoInfo.Knot), 111 - db.FilterIn("sha", shas), 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), 112 110 ) 113 111 if err != nil { 114 112 return nil, err
+40 -11
appview/repo/settings.go
··· 10 10 11 11 "tangled.org/core/api/tangled" 12 12 "tangled.org/core/appview/db" 13 + "tangled.org/core/appview/models" 13 14 "tangled.org/core/appview/oauth" 14 15 "tangled.org/core/appview/pages" 15 16 xrpcclient "tangled.org/core/appview/xrpcclient" 17 + "tangled.org/core/orm" 16 18 "tangled.org/core/types" 17 19 18 20 comatproto "github.com/bluesky-social/indigo/api/atproto" ··· 194 196 Host: host, 195 197 } 196 198 197 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 199 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 198 200 xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 199 201 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 200 202 l.Error("failed to call XRPC repo.branches", "err", xrpcerr) ··· 209 211 return 210 212 } 211 213 212 - defaultLabels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", rp.config.Label.DefaultLabelDefs)) 214 + defaultLabels, err := db.GetLabelDefinitions(rp.db, orm.FilterIn("at_uri", rp.config.Label.DefaultLabelDefs)) 213 215 if err != nil { 214 216 l.Error("failed to fetch labels", "err", err) 215 217 rp.pages.Error503(w) 216 218 return 217 219 } 218 220 219 - labels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Repo.Labels)) 221 + labels, err := db.GetLabelDefinitions(rp.db, orm.FilterIn("at_uri", f.Labels)) 220 222 if err != nil { 221 223 l.Error("failed to fetch labels", "err", err) 222 224 rp.pages.Error503(w) ··· 237 239 labels = labels[:n] 238 240 239 241 subscribedLabels := make(map[string]struct{}) 240 - for _, l := range f.Repo.Labels { 242 + for _, l := range f.Labels { 241 243 subscribedLabels[l] = struct{}{} 242 244 } 243 245 ··· 254 256 255 257 rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{ 256 258 LoggedInUser: user, 257 - RepoInfo: f.RepoInfo(user), 259 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 258 260 Branches: result.Branches, 259 261 Labels: labels, 260 262 DefaultLabels: defaultLabels, ··· 271 273 f, err := rp.repoResolver.Resolve(r) 272 274 user := rp.oauth.GetUser(r) 273 275 274 - repoCollaborators, err := f.Collaborators(r.Context()) 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) 275 304 if err != nil { 276 305 l.Error("failed to get collaborators", "err", err) 277 306 } 278 307 279 308 rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{ 280 309 LoggedInUser: user, 281 - RepoInfo: f.RepoInfo(user), 310 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 282 311 Tabs: settingsTabs, 283 312 Tab: "access", 284 - Collaborators: repoCollaborators, 313 + Collaborators: collaborators, 285 314 }) 286 315 } 287 316 ··· 292 321 user := rp.oauth.GetUser(r) 293 322 294 323 // all spindles that the repo owner is a member of 295 - spindles, err := rp.enforcer.GetSpindlesForUser(f.OwnerDid()) 324 + spindles, err := rp.enforcer.GetSpindlesForUser(f.Did) 296 325 if err != nil { 297 326 l.Error("failed to fetch spindles", "err", err) 298 327 return ··· 339 368 340 369 rp.pages.RepoPipelineSettings(w, pages.RepoPipelineSettingsParams{ 341 370 LoggedInUser: user, 342 - RepoInfo: f.RepoInfo(user), 371 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 343 372 Tabs: settingsTabs, 344 373 Tab: "pipelines", 345 374 Spindles: spindles, ··· 388 417 } 389 418 l.Debug("got", "topicsStr", topicStr, "topics", topics) 390 419 391 - newRepo := f.Repo 420 + newRepo := *f 392 421 newRepo.Description = description 393 422 newRepo.Website = website 394 423 newRepo.Topics = topics
+4 -3
appview/repo/tags.go
··· 10 10 "tangled.org/core/appview/models" 11 11 "tangled.org/core/appview/pages" 12 12 xrpcclient "tangled.org/core/appview/xrpcclient" 13 + "tangled.org/core/orm" 13 14 "tangled.org/core/types" 14 15 15 16 indigoxrpc "github.com/bluesky-social/indigo/xrpc" ··· 31 32 xrpcc := &indigoxrpc.Client{ 32 33 Host: host, 33 34 } 34 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 35 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 35 36 xrpcBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 36 37 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 37 38 l.Error("failed to call XRPC repo.tags", "err", xrpcerr) ··· 44 45 rp.pages.Error503(w) 45 46 return 46 47 } 47 - artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt())) 48 + artifacts, err := db.GetArtifact(rp.db, orm.FilterEq("repo_at", f.RepoAt())) 48 49 if err != nil { 49 50 l.Error("failed grab artifacts", "err", err) 50 51 return ··· 71 72 user := rp.oauth.GetUser(r) 72 73 rp.pages.RepoTags(w, pages.RepoTagsParams{ 73 74 LoggedInUser: user, 74 - RepoInfo: f.RepoInfo(user), 75 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 75 76 RepoTagsResponse: result, 76 77 ArtifactMap: artifactMap, 77 78 DanglingArtifacts: danglingArtifacts,
+6 -4
appview/repo/tree.go
··· 9 9 10 10 "tangled.org/core/api/tangled" 11 11 "tangled.org/core/appview/pages" 12 + "tangled.org/core/appview/reporesolver" 12 13 xrpcclient "tangled.org/core/appview/xrpcclient" 13 14 "tangled.org/core/types" 14 15 ··· 39 40 xrpcc := &indigoxrpc.Client{ 40 41 Host: host, 41 42 } 42 - repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 43 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 43 44 xrpcResp, err := tangled.RepoTree(r.Context(), xrpcc, treePath, ref, repo) 44 45 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 45 46 l.Error("failed to call XRPC repo.tree", "err", xrpcerr) ··· 79 80 result.ReadmeFileName = xrpcResp.Readme.Filename 80 81 result.Readme = xrpcResp.Readme.Contents 81 82 } 83 + ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 82 84 // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated, 83 85 // so we can safely redirect to the "parent" (which is the same file). 84 86 if len(result.Files) == 0 && result.Parent == treePath { 85 - redirectTo := fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), url.PathEscape(ref), result.Parent) 87 + redirectTo := fmt.Sprintf("/%s/blob/%s/%s", ownerSlashRepo, url.PathEscape(ref), result.Parent) 86 88 http.Redirect(w, r, redirectTo, http.StatusFound) 87 89 return 88 90 } 89 91 user := rp.oauth.GetUser(r) 90 92 var breadcrumbs [][]string 91 - breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))}) 93 + breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", ownerSlashRepo, url.PathEscape(ref))}) 92 94 if treePath != "" { 93 95 for idx, elem := range strings.Split(treePath, "/") { 94 96 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))}) ··· 100 102 LoggedInUser: user, 101 103 BreadCrumbs: breadcrumbs, 102 104 TreePath: treePath, 103 - RepoInfo: f.RepoInfo(user), 105 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 104 106 RepoTreeResponse: result, 105 107 }) 106 108 }
+76 -164
appview/reporesolver/resolver.go
··· 1 1 package reporesolver 2 2 3 3 import ( 4 - "context" 5 - "database/sql" 6 - "errors" 7 4 "fmt" 8 5 "log" 9 6 "net/http" ··· 12 9 "strings" 13 10 14 11 "github.com/bluesky-social/indigo/atproto/identity" 15 - securejoin "github.com/cyphar/filepath-securejoin" 16 12 "github.com/go-chi/chi/v5" 17 13 "tangled.org/core/appview/config" 18 14 "tangled.org/core/appview/db" 19 15 "tangled.org/core/appview/models" 20 16 "tangled.org/core/appview/oauth" 21 - "tangled.org/core/appview/pages" 22 17 "tangled.org/core/appview/pages/repoinfo" 23 - "tangled.org/core/idresolver" 24 18 "tangled.org/core/rbac" 25 19 ) 26 20 27 - type ResolvedRepo struct { 28 - models.Repo 29 - OwnerId identity.Identity 30 - CurrentDir string 31 - Ref string 32 - 33 - rr *RepoResolver 21 + type RepoResolver struct { 22 + config *config.Config 23 + enforcer *rbac.Enforcer 24 + execer db.Execer 34 25 } 35 26 36 - type RepoResolver struct { 37 - config *config.Config 38 - enforcer *rbac.Enforcer 39 - idResolver *idresolver.Resolver 40 - execer db.Execer 27 + func New(config *config.Config, enforcer *rbac.Enforcer, execer db.Execer) *RepoResolver { 28 + return &RepoResolver{config: config, enforcer: enforcer, execer: execer} 41 29 } 42 30 43 - func New(config *config.Config, enforcer *rbac.Enforcer, resolver *idresolver.Resolver, execer db.Execer) *RepoResolver { 44 - return &RepoResolver{config: config, enforcer: enforcer, idResolver: resolver, execer: execer} 31 + // NOTE: this... should not even be here. the entire package will be removed in future refactor 32 + func GetBaseRepoPath(r *http.Request, repo *models.Repo) string { 33 + var ( 34 + user = chi.URLParam(r, "user") 35 + name = chi.URLParam(r, "repo") 36 + ) 37 + if user == "" || name == "" { 38 + return repo.DidSlashRepo() 39 + } 40 + return path.Join(user, name) 45 41 } 46 42 47 - func (rr *RepoResolver) Resolve(r *http.Request) (*ResolvedRepo, error) { 43 + // TODO: move this out of `RepoResolver` struct 44 + func (rr *RepoResolver) Resolve(r *http.Request) (*models.Repo, error) { 48 45 repo, ok := r.Context().Value("repo").(*models.Repo) 49 46 if !ok { 50 47 log.Println("malformed middleware: `repo` not exist in context") 51 48 return nil, fmt.Errorf("malformed middleware") 52 49 } 53 - id, ok := r.Context().Value("resolvedId").(identity.Identity) 54 - if !ok { 55 - log.Println("malformed middleware") 56 - return nil, fmt.Errorf("malformed middleware") 57 - } 58 50 59 - currentDir := path.Dir(extractPathAfterRef(r.URL.EscapedPath())) 60 - ref := chi.URLParam(r, "ref") 61 - 62 - return &ResolvedRepo{ 63 - Repo: *repo, 64 - OwnerId: id, 65 - CurrentDir: currentDir, 66 - Ref: ref, 67 - 68 - rr: rr, 69 - }, nil 70 - } 71 - 72 - func (f *ResolvedRepo) OwnerDid() string { 73 - return f.OwnerId.DID.String() 74 - } 75 - 76 - func (f *ResolvedRepo) OwnerHandle() string { 77 - return f.OwnerId.Handle.String() 51 + return repo, nil 78 52 } 79 53 80 - func (f *ResolvedRepo) OwnerSlashRepo() string { 81 - handle := f.OwnerId.Handle 82 - 83 - var p string 84 - if handle != "" && !handle.IsInvalidHandle() { 85 - p, _ = securejoin.SecureJoin(fmt.Sprintf("@%s", handle), f.Name) 86 - } else { 87 - p, _ = securejoin.SecureJoin(f.OwnerDid(), f.Name) 54 + // 1. [x] replace `RepoInfo` to `reporesolver.GetRepoInfo(r *http.Request, repo, user)` 55 + // 2. [x] remove `rr`, `CurrentDir`, `Ref` fields from `ResolvedRepo` 56 + // 3. [x] remove `ResolvedRepo` 57 + // 4. [ ] replace reporesolver to reposervice 58 + func (rr *RepoResolver) GetRepoInfo(r *http.Request, user *oauth.User) repoinfo.RepoInfo { 59 + ownerId, ook := r.Context().Value("resolvedId").(identity.Identity) 60 + repo, rok := r.Context().Value("repo").(*models.Repo) 61 + if !ook || !rok { 62 + log.Println("malformed request, failed to get repo from context") 88 63 } 89 64 90 - return p 91 - } 65 + // get dir/ref 66 + currentDir := path.Dir(extractPathAfterRef(r.URL.EscapedPath())) 67 + ref := chi.URLParam(r, "ref") 92 68 93 - func (f *ResolvedRepo) Collaborators(ctx context.Context) ([]pages.Collaborator, error) { 94 - repoCollaborators, err := f.rr.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot) 95 - if err != nil { 96 - return nil, err 69 + repoAt := repo.RepoAt() 70 + isStarred := false 71 + roles := repoinfo.RolesInRepo{} 72 + if user != nil { 73 + isStarred = db.GetStarStatus(rr.execer, user.Did, repoAt) 74 + roles.Roles = rr.enforcer.GetPermissionsInRepo(user.Did, repo.Knot, repo.DidSlashRepo()) 97 75 } 98 76 99 - var collaborators []pages.Collaborator 100 - for _, item := range repoCollaborators { 101 - // currently only two roles: owner and member 102 - var role string 103 - switch item[3] { 104 - case "repo:owner": 105 - role = "owner" 106 - case "repo:collaborator": 107 - role = "collaborator" 108 - default: 109 - continue 77 + stats := repo.RepoStats 78 + if stats == nil { 79 + starCount, err := db.GetStarCount(rr.execer, repoAt) 80 + if err != nil { 81 + log.Println("failed to get star count for ", repoAt) 110 82 } 111 - 112 - did := item[0] 113 - 114 - c := pages.Collaborator{ 115 - Did: did, 116 - Handle: "", 117 - Role: role, 83 + issueCount, err := db.GetIssueCount(rr.execer, repoAt) 84 + if err != nil { 85 + log.Println("failed to get issue count for ", repoAt) 118 86 } 119 - collaborators = append(collaborators, c) 120 - } 121 - 122 - // populate all collborators with handles 123 - identsToResolve := make([]string, len(collaborators)) 124 - for i, collab := range collaborators { 125 - identsToResolve[i] = collab.Did 126 - } 127 - 128 - resolvedIdents := f.rr.idResolver.ResolveIdents(ctx, identsToResolve) 129 - for i, resolved := range resolvedIdents { 130 - if resolved != nil { 131 - collaborators[i].Handle = resolved.Handle.String() 87 + pullCount, err := db.GetPullCount(rr.execer, repoAt) 88 + if err != nil { 89 + log.Println("failed to get pull count for ", repoAt) 132 90 } 133 - } 134 - 135 - return collaborators, nil 136 - } 137 - 138 - // this function is a bit weird since it now returns RepoInfo from an entirely different 139 - // package. we should refactor this or get rid of RepoInfo entirely. 140 - func (f *ResolvedRepo) RepoInfo(user *oauth.User) repoinfo.RepoInfo { 141 - repoAt := f.RepoAt() 142 - isStarred := false 143 - if user != nil { 144 - isStarred = db.GetStarStatus(f.rr.execer, user.Did, repoAt) 145 - } 146 - 147 - starCount, err := db.GetStarCount(f.rr.execer, repoAt) 148 - if err != nil { 149 - log.Println("failed to get star count for ", repoAt) 150 - } 151 - issueCount, err := db.GetIssueCount(f.rr.execer, repoAt) 152 - if err != nil { 153 - log.Println("failed to get issue count for ", repoAt) 154 - } 155 - pullCount, err := db.GetPullCount(f.rr.execer, repoAt) 156 - if err != nil { 157 - log.Println("failed to get issue count for ", repoAt) 158 - } 159 - source, err := db.GetRepoSource(f.rr.execer, repoAt) 160 - if errors.Is(err, sql.ErrNoRows) { 161 - source = "" 162 - } else if err != nil { 163 - log.Println("failed to get repo source for ", repoAt, err) 91 + stats = &models.RepoStats{ 92 + StarCount: starCount, 93 + IssueCount: issueCount, 94 + PullCount: pullCount, 95 + } 164 96 } 165 97 166 98 var sourceRepo *models.Repo 167 - if source != "" { 168 - sourceRepo, err = db.GetRepoByAtUri(f.rr.execer, source) 99 + var err error 100 + if repo.Source != "" { 101 + sourceRepo, err = db.GetRepoByAtUri(rr.execer, repo.Source) 169 102 if err != nil { 170 103 log.Println("failed to get repo by at uri", err) 171 104 } 172 105 } 173 106 174 - var sourceHandle *identity.Identity 175 - if sourceRepo != nil { 176 - sourceHandle, err = f.rr.idResolver.ResolveIdent(context.Background(), sourceRepo.Did) 177 - if err != nil { 178 - log.Println("failed to resolve source repo", err) 179 - } 180 - } 107 + repoInfo := repoinfo.RepoInfo{ 108 + // this is basically a models.Repo 109 + OwnerDid: ownerId.DID.String(), 110 + OwnerHandle: ownerId.Handle.String(), 111 + Name: repo.Name, 112 + Rkey: repo.Rkey, 113 + Description: repo.Description, 114 + Website: repo.Website, 115 + Topics: repo.Topics, 116 + Knot: repo.Knot, 117 + Spindle: repo.Spindle, 118 + Stats: *stats, 181 119 182 - knot := f.Knot 120 + // fork repo upstream 121 + Source: sourceRepo, 183 122 184 - repoInfo := repoinfo.RepoInfo{ 185 - OwnerDid: f.OwnerDid(), 186 - OwnerHandle: f.OwnerHandle(), 187 - Name: f.Name, 188 - Rkey: f.Repo.Rkey, 189 - RepoAt: repoAt, 190 - Description: f.Description, 191 - Website: f.Website, 192 - Topics: f.Topics, 193 - IsStarred: isStarred, 194 - Knot: knot, 195 - Spindle: f.Spindle, 196 - Roles: f.RolesInRepo(user), 197 - Stats: models.RepoStats{ 198 - StarCount: starCount, 199 - IssueCount: issueCount, 200 - PullCount: pullCount, 201 - }, 202 - CurrentDir: f.CurrentDir, 203 - Ref: f.Ref, 204 - } 123 + // page context 124 + CurrentDir: currentDir, 125 + Ref: ref, 205 126 206 - if sourceRepo != nil { 207 - repoInfo.Source = sourceRepo 208 - repoInfo.SourceHandle = sourceHandle.Handle.String() 127 + // info related to the session 128 + IsStarred: isStarred, 129 + Roles: roles, 209 130 } 210 131 211 132 return repoInfo 212 - } 213 - 214 - func (f *ResolvedRepo) RolesInRepo(u *oauth.User) repoinfo.RolesInRepo { 215 - if u != nil { 216 - r := f.rr.enforcer.GetPermissionsInRepo(u.Did, f.Knot, f.DidSlashRepo()) 217 - return repoinfo.RolesInRepo{Roles: r} 218 - } else { 219 - return repoinfo.RolesInRepo{} 220 - } 221 133 } 222 134 223 135 // extractPathAfterRef gets the actual repository path
+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)
+2
appview/settings/settings.go
··· 43 43 {"Name": "keys", "Icon": "key"}, 44 44 {"Name": "emails", "Icon": "mail"}, 45 45 {"Name": "notifications", "Icon": "bell"}, 46 + {"Name": "knots", "Icon": "volleyball"}, 47 + {"Name": "spindles", "Icon": "spool"}, 46 48 } 47 49 ) 48 50
+44 -26
appview/spindles/spindles.go
··· 20 20 "tangled.org/core/appview/serververify" 21 21 "tangled.org/core/appview/xrpcclient" 22 22 "tangled.org/core/idresolver" 23 + "tangled.org/core/orm" 23 24 "tangled.org/core/rbac" 24 25 "tangled.org/core/tid" 25 26 ··· 38 39 Logger *slog.Logger 39 40 } 40 41 42 + type tab = map[string]any 43 + 44 + var ( 45 + spindlesTabs []tab = []tab{ 46 + {"Name": "profile", "Icon": "user"}, 47 + {"Name": "keys", "Icon": "key"}, 48 + {"Name": "emails", "Icon": "mail"}, 49 + {"Name": "notifications", "Icon": "bell"}, 50 + {"Name": "knots", "Icon": "volleyball"}, 51 + {"Name": "spindles", "Icon": "spool"}, 52 + } 53 + ) 54 + 41 55 func (s *Spindles) Router() http.Handler { 42 56 r := chi.NewRouter() 43 57 ··· 58 72 user := s.OAuth.GetUser(r) 59 73 all, err := db.GetSpindles( 60 74 s.Db, 61 - db.FilterEq("owner", user.Did), 75 + orm.FilterEq("owner", user.Did), 62 76 ) 63 77 if err != nil { 64 78 s.Logger.Error("failed to fetch spindles", "err", err) ··· 69 83 s.Pages.Spindles(w, pages.SpindlesParams{ 70 84 LoggedInUser: user, 71 85 Spindles: all, 86 + Tabs: spindlesTabs, 87 + Tab: "spindles", 72 88 }) 73 89 } 74 90 ··· 86 102 87 103 spindles, err := db.GetSpindles( 88 104 s.Db, 89 - db.FilterEq("instance", instance), 90 - db.FilterEq("owner", user.Did), 91 - db.FilterIsNot("verified", "null"), 105 + orm.FilterEq("instance", instance), 106 + orm.FilterEq("owner", user.Did), 107 + orm.FilterIsNot("verified", "null"), 92 108 ) 93 109 if err != nil || len(spindles) != 1 { 94 110 l.Error("failed to get spindle", "err", err, "len(spindles)", len(spindles)) ··· 108 124 repos, err := db.GetRepos( 109 125 s.Db, 110 126 0, 111 - db.FilterEq("spindle", instance), 127 + orm.FilterEq("spindle", instance), 112 128 ) 113 129 if err != nil { 114 130 l.Error("failed to get spindle repos", "err", err) ··· 127 143 Spindle: spindle, 128 144 Members: members, 129 145 Repos: repoMap, 146 + Tabs: spindlesTabs, 147 + Tab: "spindles", 130 148 }) 131 149 } 132 150 ··· 273 291 274 292 spindles, err := db.GetSpindles( 275 293 s.Db, 276 - db.FilterEq("owner", user.Did), 277 - db.FilterEq("instance", instance), 294 + orm.FilterEq("owner", user.Did), 295 + orm.FilterEq("instance", instance), 278 296 ) 279 297 if err != nil || len(spindles) != 1 { 280 298 l.Error("failed to retrieve instance", "err", err, "len(spindles)", len(spindles)) ··· 302 320 // remove spindle members first 303 321 err = db.RemoveSpindleMember( 304 322 tx, 305 - db.FilterEq("did", user.Did), 306 - db.FilterEq("instance", instance), 323 + orm.FilterEq("did", user.Did), 324 + orm.FilterEq("instance", instance), 307 325 ) 308 326 if err != nil { 309 327 l.Error("failed to remove spindle members", "err", err) ··· 313 331 314 332 err = db.DeleteSpindle( 315 333 tx, 316 - db.FilterEq("owner", user.Did), 317 - db.FilterEq("instance", instance), 334 + orm.FilterEq("owner", user.Did), 335 + orm.FilterEq("instance", instance), 318 336 ) 319 337 if err != nil { 320 338 l.Error("failed to delete spindle", "err", err) ··· 365 383 366 384 shouldRedirect := r.Header.Get("shouldRedirect") 367 385 if shouldRedirect == "true" { 368 - s.Pages.HxRedirect(w, "/spindles") 386 + s.Pages.HxRedirect(w, "/settings/spindles") 369 387 return 370 388 } 371 389 ··· 393 411 394 412 spindles, err := db.GetSpindles( 395 413 s.Db, 396 - db.FilterEq("owner", user.Did), 397 - db.FilterEq("instance", instance), 414 + orm.FilterEq("owner", user.Did), 415 + orm.FilterEq("instance", instance), 398 416 ) 399 417 if err != nil || len(spindles) != 1 { 400 418 l.Error("failed to retrieve instance", "err", err, "len(spindles)", len(spindles)) ··· 436 454 437 455 verifiedSpindle, err := db.GetSpindles( 438 456 s.Db, 439 - db.FilterEq("id", rowId), 457 + orm.FilterEq("id", rowId), 440 458 ) 441 459 if err != nil || len(verifiedSpindle) != 1 { 442 460 l.Error("failed get new spindle", "err", err) ··· 469 487 470 488 spindles, err := db.GetSpindles( 471 489 s.Db, 472 - db.FilterEq("owner", user.Did), 473 - db.FilterEq("instance", instance), 490 + orm.FilterEq("owner", user.Did), 491 + orm.FilterEq("instance", instance), 474 492 ) 475 493 if err != nil || len(spindles) != 1 { 476 494 l.Error("failed to retrieve instance", "err", err, "len(spindles)", len(spindles)) ··· 581 599 } 582 600 583 601 // success 584 - s.Pages.HxRedirect(w, fmt.Sprintf("/spindles/%s", instance)) 602 + s.Pages.HxRedirect(w, fmt.Sprintf("/settings/spindles/%s", instance)) 585 603 } 586 604 587 605 func (s *Spindles) removeMember(w http.ResponseWriter, r *http.Request) { ··· 605 623 606 624 spindles, err := db.GetSpindles( 607 625 s.Db, 608 - db.FilterEq("owner", user.Did), 609 - db.FilterEq("instance", instance), 626 + orm.FilterEq("owner", user.Did), 627 + orm.FilterEq("instance", instance), 610 628 ) 611 629 if err != nil || len(spindles) != 1 { 612 630 l.Error("failed to retrieve instance", "err", err, "len(spindles)", len(spindles)) ··· 655 673 // get the record from the DB first: 656 674 members, err := db.GetSpindleMembers( 657 675 s.Db, 658 - db.FilterEq("did", user.Did), 659 - db.FilterEq("instance", instance), 660 - db.FilterEq("subject", memberId.DID), 676 + orm.FilterEq("did", user.Did), 677 + orm.FilterEq("instance", instance), 678 + orm.FilterEq("subject", memberId.DID), 661 679 ) 662 680 if err != nil || len(members) != 1 { 663 681 l.Error("failed to get member", "err", err) ··· 668 686 // remove from db 669 687 if err = db.RemoveSpindleMember( 670 688 tx, 671 - db.FilterEq("did", user.Did), 672 - db.FilterEq("instance", instance), 673 - db.FilterEq("subject", memberId.DID), 689 + orm.FilterEq("did", user.Did), 690 + orm.FilterEq("instance", instance), 691 + orm.FilterEq("subject", memberId.DID), 674 692 ); err != nil { 675 693 l.Error("failed to remove spindle member", "err", err) 676 694 fail()
+6 -5
appview/state/gfi.go
··· 11 11 "tangled.org/core/appview/pages" 12 12 "tangled.org/core/appview/pagination" 13 13 "tangled.org/core/consts" 14 + "tangled.org/core/orm" 14 15 ) 15 16 16 17 func (s *State) GoodFirstIssues(w http.ResponseWriter, r *http.Request) { ··· 20 21 21 22 goodFirstIssueLabel := s.config.Label.GoodFirstIssue 22 23 23 - gfiLabelDef, err := db.GetLabelDefinition(s.db, db.FilterEq("at_uri", goodFirstIssueLabel)) 24 + gfiLabelDef, err := db.GetLabelDefinition(s.db, orm.FilterEq("at_uri", goodFirstIssueLabel)) 24 25 if err != nil { 25 26 log.Println("failed to get gfi label def", err) 26 27 s.pages.Error500(w) 27 28 return 28 29 } 29 30 30 - repoLabels, err := db.GetRepoLabels(s.db, db.FilterEq("label_at", goodFirstIssueLabel)) 31 + repoLabels, err := db.GetRepoLabels(s.db, orm.FilterEq("label_at", goodFirstIssueLabel)) 31 32 if err != nil { 32 33 log.Println("failed to get repo labels", err) 33 34 s.pages.Error503(w) ··· 55 56 pagination.Page{ 56 57 Limit: 500, 57 58 }, 58 - db.FilterIn("repo_at", repoUris), 59 - db.FilterEq("open", 1), 59 + orm.FilterIn("repo_at", repoUris), 60 + orm.FilterEq("open", 1), 60 61 ) 61 62 if err != nil { 62 63 log.Println("failed to get issues", err) ··· 132 133 } 133 134 134 135 if len(uriList) > 0 { 135 - allLabelDefs, err = db.GetLabelDefinitions(s.db, db.FilterIn("at_uri", uriList)) 136 + allLabelDefs, err = db.GetLabelDefinitions(s.db, orm.FilterIn("at_uri", uriList)) 136 137 if err != nil { 137 138 log.Println("failed to fetch labels", err) 138 139 }
+17
appview/state/git_http.go
··· 25 25 26 26 } 27 27 28 + func (s *State) UploadArchive(w http.ResponseWriter, r *http.Request) { 29 + user, ok := r.Context().Value("resolvedId").(identity.Identity) 30 + if !ok { 31 + http.Error(w, "failed to resolve user", http.StatusInternalServerError) 32 + return 33 + } 34 + repo := r.Context().Value("repo").(*models.Repo) 35 + 36 + scheme := "https" 37 + if s.config.Core.Dev { 38 + scheme = "http" 39 + } 40 + 41 + targetURL := fmt.Sprintf("%s://%s/%s/%s/git-upload-archive?%s", scheme, repo.Knot, user.DID, repo.Name, r.URL.RawQuery) 42 + s.proxyRequest(w, r, targetURL) 43 + } 44 + 28 45 func (s *State) UploadPack(w http.ResponseWriter, r *http.Request) { 29 46 user, ok := r.Context().Value("resolvedId").(identity.Identity) 30 47 if !ok {
+6 -5
appview/state/knotstream.go
··· 16 16 ec "tangled.org/core/eventconsumer" 17 17 "tangled.org/core/eventconsumer/cursor" 18 18 "tangled.org/core/log" 19 + "tangled.org/core/orm" 19 20 "tangled.org/core/rbac" 20 21 "tangled.org/core/workflow" 21 22 ··· 30 31 31 32 knots, err := db.GetRegistrations( 32 33 d, 33 - db.FilterIsNot("registered", "null"), 34 + orm.FilterIsNot("registered", "null"), 34 35 ) 35 36 if err != nil { 36 37 return nil, err ··· 143 144 repos, err := db.GetRepos( 144 145 d, 145 146 0, 146 - db.FilterEq("did", record.RepoDid), 147 - db.FilterEq("name", record.RepoName), 147 + orm.FilterEq("did", record.RepoDid), 148 + orm.FilterEq("name", record.RepoName), 148 149 ) 149 150 if err != nil { 150 151 return fmt.Errorf("failed to look for repo in DB (%s/%s): %w", record.RepoDid, record.RepoName, err) ··· 209 210 repos, err := db.GetRepos( 210 211 d, 211 212 0, 212 - db.FilterEq("did", record.TriggerMetadata.Repo.Did), 213 - db.FilterEq("name", record.TriggerMetadata.Repo.Repo), 213 + orm.FilterEq("did", record.TriggerMetadata.Repo.Did), 214 + orm.FilterEq("name", record.TriggerMetadata.Repo.Repo), 214 215 ) 215 216 if err != nil { 216 217 return fmt.Errorf("failed to look for repo in DB: nsid %s, rkey %s, %w", msg.Nsid, msg.Rkey, err)
+27 -18
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("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.GetRepoStars(s.db, 0, db.FilterEq("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) ··· 238 247 s.pages.Error500(w) 239 248 return 240 249 } 241 - l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 250 + l = l.With("profileDid", profile.UserDid) 242 251 243 - 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)) 244 253 if err != nil { 245 254 l.Error("failed to get strings", "err", err) 246 255 s.pages.Error500(w) ··· 270 279 if err != nil { 271 280 return nil, err 272 281 } 273 - l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 282 + l = l.With("profileDid", profile.UserDid) 274 283 275 284 loggedInUser := s.oauth.GetUser(r) 276 285 params := FollowsPageParams{ ··· 292 301 followDids = append(followDids, extractDid(follow)) 293 302 } 294 303 295 - profiles, err := db.GetProfiles(s.db, db.FilterIn("did", followDids)) 304 + profiles, err := db.GetProfiles(s.db, orm.FilterIn("did", followDids)) 296 305 if err != nil { 297 306 l.Error("failed to get profiles", "followDids", followDids, "err", err) 298 307 return &params, err ··· 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 }
+8 -2
appview/state/router.go
··· 101 101 102 102 // These routes get proxied to the knot 103 103 r.Get("/info/refs", s.InfoRefs) 104 + r.Post("/git-upload-archive", s.UploadArchive) 104 105 r.Post("/git-upload-pack", s.UploadPack) 105 106 r.Post("/git-receive-pack", s.ReceivePack) 106 107 ··· 166 167 167 168 r.Mount("/settings", s.SettingsRouter()) 168 169 r.Mount("/strings", s.StringsRouter(mw)) 169 - r.Mount("/knots", s.KnotsRouter()) 170 - r.Mount("/spindles", s.SpindlesRouter()) 170 + 171 + r.Mount("/settings/knots", s.KnotsRouter()) 172 + r.Mount("/settings/spindles", s.SpindlesRouter()) 173 + 171 174 r.Mount("/notifications", s.NotificationsRouter(mw)) 172 175 173 176 r.Mount("/signup", s.SignupRouter()) ··· 261 264 issues := issues.New( 262 265 s.oauth, 263 266 s.repoResolver, 267 + s.enforcer, 264 268 s.pages, 265 269 s.idResolver, 270 + s.mentionsResolver, 266 271 s.db, 267 272 s.config, 268 273 s.notifier, ··· 279 284 s.repoResolver, 280 285 s.pages, 281 286 s.idResolver, 287 + s.mentionsResolver, 282 288 s.db, 283 289 s.config, 284 290 s.notifier,
+2 -1
appview/state/spindlestream.go
··· 17 17 ec "tangled.org/core/eventconsumer" 18 18 "tangled.org/core/eventconsumer/cursor" 19 19 "tangled.org/core/log" 20 + "tangled.org/core/orm" 20 21 "tangled.org/core/rbac" 21 22 spindle "tangled.org/core/spindle/models" 22 23 ) ··· 27 28 28 29 spindles, err := db.GetSpindles( 29 30 d, 30 - db.FilterIsNot("verified", "null"), 31 + orm.FilterIsNot("verified", "null"), 31 32 ) 32 33 if err != nil { 33 34 return nil, err
+30 -24
appview/state/state.go
··· 15 15 "tangled.org/core/appview/config" 16 16 "tangled.org/core/appview/db" 17 17 "tangled.org/core/appview/indexer" 18 + "tangled.org/core/appview/mentions" 18 19 "tangled.org/core/appview/models" 19 20 "tangled.org/core/appview/notify" 20 21 dbnotify "tangled.org/core/appview/notify/db" ··· 29 30 "tangled.org/core/jetstream" 30 31 "tangled.org/core/log" 31 32 tlog "tangled.org/core/log" 33 + "tangled.org/core/orm" 32 34 "tangled.org/core/rbac" 33 35 "tangled.org/core/tid" 34 36 ··· 42 44 ) 43 45 44 46 type State struct { 45 - db *db.DB 46 - notifier notify.Notifier 47 - indexer *indexer.Indexer 48 - oauth *oauth.OAuth 49 - enforcer *rbac.Enforcer 50 - pages *pages.Pages 51 - idResolver *idresolver.Resolver 52 - posthog posthog.Client 53 - jc *jetstream.JetstreamClient 54 - config *config.Config 55 - repoResolver *reporesolver.RepoResolver 56 - knotstream *eventconsumer.Consumer 57 - spindlestream *eventconsumer.Consumer 58 - logger *slog.Logger 59 - validator *validator.Validator 47 + db *db.DB 48 + notifier notify.Notifier 49 + indexer *indexer.Indexer 50 + oauth *oauth.OAuth 51 + enforcer *rbac.Enforcer 52 + pages *pages.Pages 53 + idResolver *idresolver.Resolver 54 + mentionsResolver *mentions.Resolver 55 + posthog posthog.Client 56 + jc *jetstream.JetstreamClient 57 + config *config.Config 58 + repoResolver *reporesolver.RepoResolver 59 + knotstream *eventconsumer.Consumer 60 + spindlestream *eventconsumer.Consumer 61 + logger *slog.Logger 62 + validator *validator.Validator 60 63 } 61 64 62 65 func Make(ctx context.Context, config *config.Config) (*State, error) { ··· 96 99 } 97 100 validator := validator.New(d, res, enforcer) 98 101 99 - repoResolver := reporesolver.New(config, enforcer, res, d) 102 + repoResolver := reporesolver.New(config, enforcer, d) 103 + 104 + mentionsResolver := mentions.New(config, res, d, log.SubLogger(logger, "mentionsResolver")) 100 105 101 106 wrapper := db.DbWrapper{Execer: d} 102 107 jc, err := jetstream.NewJetstreamClient( ··· 178 183 enforcer, 179 184 pages, 180 185 res, 186 + mentionsResolver, 181 187 posthog, 182 188 jc, 183 189 config, ··· 294 300 return 295 301 } 296 302 297 - gfiLabel, err := db.GetLabelDefinition(s.db, db.FilterEq("at_uri", s.config.Label.GoodFirstIssue)) 303 + gfiLabel, err := db.GetLabelDefinition(s.db, orm.FilterEq("at_uri", s.config.Label.GoodFirstIssue)) 298 304 if err != nil { 299 305 // non-fatal 300 306 } ··· 318 324 319 325 regs, err := db.GetRegistrations( 320 326 s.db, 321 - db.FilterEq("did", user.Did), 322 - db.FilterEq("needs_upgrade", 1), 327 + orm.FilterEq("did", user.Did), 328 + orm.FilterEq("needs_upgrade", 1), 323 329 ) 324 330 if err != nil { 325 331 l.Error("non-fatal: failed to get registrations", "err", err) ··· 327 333 328 334 spindles, err := db.GetSpindles( 329 335 s.db, 330 - db.FilterEq("owner", user.Did), 331 - db.FilterEq("needs_upgrade", 1), 336 + orm.FilterEq("owner", user.Did), 337 + orm.FilterEq("needs_upgrade", 1), 332 338 ) 333 339 if err != nil { 334 340 l.Error("non-fatal: failed to get spindles", "err", err) ··· 499 505 // Check for existing repos 500 506 existingRepo, err := db.GetRepo( 501 507 s.db, 502 - db.FilterEq("did", user.Did), 503 - db.FilterEq("name", repoName), 508 + orm.FilterEq("did", user.Did), 509 + orm.FilterEq("name", repoName), 504 510 ) 505 511 if err == nil && existingRepo != nil { 506 512 l.Info("repo exists") ··· 660 666 } 661 667 662 668 func BackfillDefaultDefs(e db.Execer, r *idresolver.Resolver, defaults []string) error { 663 - defaultLabels, err := db.GetLabelDefinitions(e, db.FilterIn("at_uri", defaults)) 669 + defaultLabels, err := db.GetLabelDefinitions(e, orm.FilterIn("at_uri", defaults)) 664 670 if err != nil { 665 671 return err 666 672 }
+7 -6
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) ··· 199 200 all, err := db.GetStrings( 200 201 s.Db, 201 202 0, 202 - db.FilterEq("did", id.DID), 203 - db.FilterEq("rkey", rkey), 203 + orm.FilterEq("did", id.DID), 204 + orm.FilterEq("rkey", rkey), 204 205 ) 205 206 if err != nil { 206 207 l.Error("failed to fetch string", "err", err) ··· 408 409 409 410 if err := db.DeleteString( 410 411 s.Db, 411 - db.FilterEq("did", user.Did), 412 - db.FilterEq("rkey", rkey), 412 + orm.FilterEq("did", user.Did), 413 + orm.FilterEq("rkey", rkey), 413 414 ); err != nil { 414 415 fail("Failed to delete string.", err) 415 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 }
+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.
+3 -3
docs/hacking.md
··· 117 117 # type `poweroff` at the shell to exit the VM 118 118 ``` 119 119 120 - This starts a knot on port 6000, a spindle on port 6555 120 + This starts a knot on port 6444, a spindle on port 6555 121 121 with `ssh` exposed on port 2222. 122 122 123 123 Once the services are running, head to 124 - http://localhost:3000/knots and hit verify. It should 124 + http://localhost:3000/settings/knots and hit verify. It should 125 125 verify the ownership of the services instantly if everything 126 126 went smoothly. 127 127 ··· 146 146 ### running a spindle 147 147 148 148 The above VM should already be running a spindle on 149 - `localhost:6555`. Head to http://localhost:3000/spindles and 149 + `localhost:6555`. Head to http://localhost:3000/settings/spindles and 150 150 hit verify. You can then configure each repository to use 151 151 this spindle and run CI jobs. 152 152
+1 -1
docs/knot-hosting.md
··· 131 131 132 132 You should now have a running knot server! You can finalize 133 133 your registration by hitting the `verify` button on the 134 - [/knots](https://tangled.org/knots) page. This simply creates 134 + [/settings/knots](https://tangled.org/settings/knots) page. This simply creates 135 135 a record on your PDS to announce the existence of the knot. 136 136 137 137 ### custom paths
+3 -3
docs/migrations.md
··· 14 14 For knots: 15 15 16 16 - Upgrade to latest tag (v1.9.0 or above) 17 - - Head to the [knot dashboard](https://tangled.org/knots) and 17 + - Head to the [knot dashboard](https://tangled.org/settings/knots) and 18 18 hit the "retry" button to verify your knot 19 19 20 20 For spindles: 21 21 22 22 - Upgrade to latest tag (v1.9.0 or above) 23 23 - Head to the [spindle 24 - dashboard](https://tangled.org/spindles) and hit the 24 + dashboard](https://tangled.org/settings/spindles) and hit the 25 25 "retry" button to verify your spindle 26 26 27 27 ## Upgrading from v1.7.x ··· 41 41 [settings](https://tangled.org/settings) page. 42 42 - Restart your knot once you have replaced the environment 43 43 variable 44 - - Head to the [knot dashboard](https://tangled.org/knots) and 44 + - Head to the [knot dashboard](https://tangled.org/settings/knots) and 45 45 hit the "retry" button to verify your knot. This simply 46 46 writes a `sh.tangled.knot` record to your PDS. 47 47
+9 -9
flake.lock
··· 35 35 "systems": "systems" 36 36 }, 37 37 "locked": { 38 - "lastModified": 1694529238, 39 - "narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=", 38 + "lastModified": 1731533236, 39 + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 40 40 "owner": "numtide", 41 41 "repo": "flake-utils", 42 - "rev": "ff7b65b44d01cf9ba6a71320833626af21126384", 42 + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 43 43 "type": "github" 44 44 }, 45 45 "original": { ··· 56 56 ] 57 57 }, 58 58 "locked": { 59 - "lastModified": 1754078208, 60 - "narHash": "sha256-YVoIFDCDpYuU3riaDEJ3xiGdPOtsx4sR5eTzHTytPV8=", 59 + "lastModified": 1763982521, 60 + "narHash": "sha256-ur4QIAHwgFc0vXiaxn5No/FuZicxBr2p0gmT54xZkUQ=", 61 61 "owner": "nix-community", 62 62 "repo": "gomod2nix", 63 - "rev": "7f963246a71626c7fc70b431a315c4388a0c95cf", 63 + "rev": "02e63a239d6eabd595db56852535992c898eba72", 64 64 "type": "github" 65 65 }, 66 66 "original": { ··· 150 150 }, 151 151 "nixpkgs": { 152 152 "locked": { 153 - "lastModified": 1751984180, 154 - "narHash": "sha256-LwWRsENAZJKUdD3SpLluwDmdXY9F45ZEgCb0X+xgOL0=", 153 + "lastModified": 1766070988, 154 + "narHash": "sha256-G/WVghka6c4bAzMhTwT2vjLccg/awmHkdKSd2JrycLc=", 155 155 "owner": "nixos", 156 156 "repo": "nixpkgs", 157 - "rev": "9807714d6944a957c2e036f84b0ff8caf9930bc0", 157 + "rev": "c6245e83d836d0433170a16eb185cefe0572f8b8", 158 158 "type": "github" 159 159 }, 160 160 "original": {
+31 -4
flake.nix
··· 80 80 }).buildGoApplication; 81 81 modules = ./nix/gomod2nix.toml; 82 82 sqlite-lib = self.callPackage ./nix/pkgs/sqlite-lib.nix { 83 - inherit (pkgs) gcc; 84 83 inherit sqlite-lib-src; 85 84 }; 86 85 lexgen = self.callPackage ./nix/pkgs/lexgen.nix {inherit indigo;}; ··· 92 91 spindle = self.callPackage ./nix/pkgs/spindle.nix {}; 93 92 knot-unwrapped = self.callPackage ./nix/pkgs/knot-unwrapped.nix {}; 94 93 knot = self.callPackage ./nix/pkgs/knot.nix {}; 94 + did-method-plc = self.callPackage ./nix/pkgs/did-method-plc.nix {}; 95 + bluesky-jetstream = self.callPackage ./nix/pkgs/bluesky-jetstream.nix {}; 96 + bluesky-relay = self.callPackage ./nix/pkgs/bluesky-relay.nix {}; 97 + tap = self.callPackage ./nix/pkgs/tap.nix {}; 95 98 }); 96 99 in { 97 100 overlays.default = final: prev: { 98 - inherit (mkPackageSet final) lexgen goat sqlite-lib spindle knot-unwrapped knot appview; 101 + inherit (mkPackageSet final) lexgen goat sqlite-lib spindle knot-unwrapped knot appview did-method-plc bluesky-jetstream bluesky-relay tap; 99 102 }; 100 103 101 104 packages = forAllSystems (system: let ··· 104 107 staticPackages = mkPackageSet pkgs.pkgsStatic; 105 108 crossPackages = mkPackageSet pkgs.pkgsCross.gnu64.pkgsStatic; 106 109 in { 107 - inherit (packages) appview appview-static-files lexgen goat spindle knot knot-unwrapped sqlite-lib; 110 + inherit (packages) appview appview-static-files lexgen goat spindle knot knot-unwrapped sqlite-lib did-method-plc bluesky-jetstream bluesky-relay tap; 108 111 109 112 pkgsStatic-appview = staticPackages.appview; 110 113 pkgsStatic-knot = staticPackages.knot; ··· 156 159 nativeBuildInputs = [ 157 160 pkgs.go 158 161 pkgs.air 159 - pkgs.tilt 160 162 pkgs.gopls 161 163 pkgs.httpie 162 164 pkgs.litecli ··· 304 306 imports = [./nix/modules/spindle.nix]; 305 307 306 308 services.tangled.spindle.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.spindle; 309 + services.tangled.spindle.tap-package = lib.mkDefault self.packages.${pkgs.system}.tap; 310 + }; 311 + nixosModules.did-method-plc = { 312 + lib, 313 + pkgs, 314 + ... 315 + }: { 316 + imports = [./nix/modules/did-method-plc.nix]; 317 + services.did-method-plc.package = lib.mkDefault self.packages.${pkgs.system}.did-method-plc; 318 + }; 319 + nixosModules.bluesky-relay = { 320 + lib, 321 + pkgs, 322 + ... 323 + }: { 324 + imports = [./nix/modules/bluesky-relay.nix]; 325 + services.bluesky-relay.package = lib.mkDefault self.packages.${pkgs.system}.bluesky-relay; 326 + }; 327 + nixosModules.bluesky-jetstream = { 328 + lib, 329 + pkgs, 330 + ... 331 + }: { 332 + imports = [./nix/modules/bluesky-jetstream.nix]; 333 + services.bluesky-jetstream.package = lib.mkDefault self.packages.${pkgs.system}.bluesky-jetstream; 307 334 }; 308 335 }; 309 336 }
+4 -4
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 ··· 44 44 github.com/stretchr/testify v1.10.0 45 45 github.com/urfave/cli/v3 v3.3.3 46 46 github.com/whyrusleeping/cbor-gen v0.3.1 47 - github.com/wyatt915/goldmark-treeblood v0.0.1 48 47 github.com/yuin/goldmark v1.7.13 48 + github.com/yuin/goldmark-emoji v1.0.6 49 49 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc 50 50 gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab 51 51 golang.org/x/crypto v0.40.0 52 52 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b 53 53 golang.org/x/image v0.31.0 54 54 golang.org/x/net v0.42.0 55 - golang.org/x/sync v0.17.0 56 55 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da 57 56 gopkg.in/yaml.v3 v3.0.1 58 57 ) ··· 132 131 github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect 133 132 github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect 134 133 github.com/hashicorp/go-sockaddr v1.0.7 // indirect 134 + github.com/hashicorp/go-version v1.8.0 // indirect 135 135 github.com/hashicorp/golang-lru v1.0.2 // indirect 136 136 github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect 137 137 github.com/hashicorp/hcl v1.0.1-vault-7 // indirect ··· 190 190 github.com/vmihailenco/go-tinylfu v0.2.2 // indirect 191 191 github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect 192 192 github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect 193 - github.com/wyatt915/treeblood v0.1.16 // indirect 194 193 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 195 194 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 196 195 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect ··· 205 204 go.uber.org/atomic v1.11.0 // indirect 206 205 go.uber.org/multierr v1.11.0 // indirect 207 206 go.uber.org/zap v1.27.0 // indirect 207 + golang.org/x/sync v0.17.0 // indirect 208 208 golang.org/x/sys v0.34.0 // indirect 209 209 golang.org/x/text v0.29.0 // indirect 210 210 golang.org/x/time v0.12.0 // indirect
+4 -4
go.sum
··· 264 264 github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= 265 265 github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw= 266 266 github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= 267 + github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4= 268 + github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= 267 269 github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= 268 270 github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= 269 271 github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= ··· 495 497 github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw= 496 498 github.com/whyrusleeping/cbor-gen v0.3.1 h1:82ioxmhEYut7LBVGhGq8xoRkXPLElVuh5mV67AFfdv0= 497 499 github.com/whyrusleeping/cbor-gen v0.3.1/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= 498 - github.com/wyatt915/goldmark-treeblood v0.0.1 h1:6vLJcjFrHgE4ASu2ga4hqIQmbvQLU37v53jlHZ3pqDs= 499 - github.com/wyatt915/goldmark-treeblood v0.0.1/go.mod h1:SmcJp5EBaV17rroNlgNQFydYwy0+fv85CUr/ZaCz208= 500 - github.com/wyatt915/treeblood v0.1.16 h1:byxNbWZhnPDxdTp7W5kQhCeaY8RBVmojTFz1tEHgg8Y= 501 - github.com/wyatt915/treeblood v0.1.16/go.mod h1:i7+yhhmzdDP17/97pIsOSffw74EK/xk+qJ0029cSXUY= 502 500 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 503 501 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 504 502 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= ··· 509 507 github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 510 508 github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= 511 509 github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 510 + github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= 511 + github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= 512 512 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ= 513 513 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I= 514 514 gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab h1:gK9tS6QJw5F0SIhYJnGG2P83kuabOdmWBbSmZhJkz2A=
+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)
+15 -4
jetstream/jetstream.go
··· 72 72 // existing instances of the closure when j.WantedDids is mutated 73 73 return func(ctx context.Context, evt *models.Event) error { 74 74 75 + j.mu.RLock() 75 76 // empty filter => all dids allowed 76 - if len(j.wantedDids) == 0 { 77 - return processFunc(ctx, evt) 77 + matches := len(j.wantedDids) == 0 78 + if !matches { 79 + if _, ok := j.wantedDids[evt.Did]; ok { 80 + matches = true 81 + } 78 82 } 83 + j.mu.RUnlock() 79 84 80 - if _, ok := j.wantedDids[evt.Did]; ok { 85 + if matches { 81 86 return processFunc(ctx, evt) 82 87 } else { 83 88 return nil ··· 122 127 123 128 go func() { 124 129 if j.waitForDid { 125 - for len(j.wantedDids) == 0 { 130 + for { 131 + j.mu.RLock() 132 + hasDid := len(j.wantedDids) != 0 133 + j.mu.RUnlock() 134 + if hasDid { 135 + break 136 + } 126 137 time.Sleep(time.Second) 127 138 } 128 139 }
+81
knotserver/db/db.go
··· 1 + package db 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + "log/slog" 7 + "strings" 8 + 9 + _ "github.com/mattn/go-sqlite3" 10 + "tangled.org/core/log" 11 + ) 12 + 13 + type DB struct { 14 + db *sql.DB 15 + logger *slog.Logger 16 + } 17 + 18 + func Setup(ctx context.Context, dbPath string) (*DB, error) { 19 + // https://github.com/mattn/go-sqlite3#connection-string 20 + opts := []string{ 21 + "_foreign_keys=1", 22 + "_journal_mode=WAL", 23 + "_synchronous=NORMAL", 24 + "_auto_vacuum=incremental", 25 + } 26 + 27 + logger := log.FromContext(ctx) 28 + logger = log.SubLogger(logger, "db") 29 + 30 + db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&")) 31 + if err != nil { 32 + return nil, err 33 + } 34 + 35 + conn, err := db.Conn(ctx) 36 + if err != nil { 37 + return nil, err 38 + } 39 + defer conn.Close() 40 + 41 + _, err = conn.ExecContext(ctx, ` 42 + create table if not exists known_dids ( 43 + did text primary key 44 + ); 45 + 46 + create table if not exists public_keys ( 47 + id integer primary key autoincrement, 48 + did text not null, 49 + key text not null, 50 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 51 + unique(did, key), 52 + foreign key (did) references known_dids(did) on delete cascade 53 + ); 54 + 55 + create table if not exists _jetstream ( 56 + id integer primary key autoincrement, 57 + last_time_us integer not null 58 + ); 59 + 60 + create table if not exists events ( 61 + rkey text not null, 62 + nsid text not null, 63 + event text not null, -- json 64 + created integer not null default (strftime('%s', 'now')), 65 + primary key (rkey, nsid) 66 + ); 67 + 68 + create table if not exists migrations ( 69 + id integer primary key autoincrement, 70 + name text unique 71 + ); 72 + `) 73 + if err != nil { 74 + return nil, err 75 + } 76 + 77 + return &DB{ 78 + db: db, 79 + logger: logger, 80 + }, nil 81 + }
-64
knotserver/db/init.go
··· 1 - package db 2 - 3 - import ( 4 - "database/sql" 5 - "strings" 6 - 7 - _ "github.com/mattn/go-sqlite3" 8 - ) 9 - 10 - type DB struct { 11 - db *sql.DB 12 - } 13 - 14 - func Setup(dbPath string) (*DB, error) { 15 - // https://github.com/mattn/go-sqlite3#connection-string 16 - opts := []string{ 17 - "_foreign_keys=1", 18 - "_journal_mode=WAL", 19 - "_synchronous=NORMAL", 20 - "_auto_vacuum=incremental", 21 - } 22 - 23 - db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&")) 24 - if err != nil { 25 - return nil, err 26 - } 27 - 28 - // NOTE: If any other migration is added here, you MUST 29 - // copy the pattern in appview: use a single sql.Conn 30 - // for every migration. 31 - 32 - _, err = db.Exec(` 33 - create table if not exists known_dids ( 34 - did text primary key 35 - ); 36 - 37 - create table if not exists public_keys ( 38 - id integer primary key autoincrement, 39 - did text not null, 40 - key text not null, 41 - created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 42 - unique(did, key), 43 - foreign key (did) references known_dids(did) on delete cascade 44 - ); 45 - 46 - create table if not exists _jetstream ( 47 - id integer primary key autoincrement, 48 - last_time_us integer not null 49 - ); 50 - 51 - create table if not exists events ( 52 - rkey text not null, 53 - nsid text not null, 54 - event text not null, -- json 55 - created integer not null default (strftime('%s', 'now')), 56 - primary key (rkey, nsid) 57 - ); 58 - `) 59 - if err != nil { 60 - return nil, err 61 - } 62 - 63 - return &DB{db: db}, nil 64 - }
+1 -17
knotserver/git/diff.go
··· 77 77 nd.Diff = append(nd.Diff, ndiff) 78 78 } 79 79 80 - nd.Stat.FilesChanged = len(diffs) 81 - nd.Commit.This = c.Hash.String() 82 - nd.Commit.PGPSignature = c.PGPSignature 83 - nd.Commit.Committer = c.Committer 84 - nd.Commit.Tree = c.TreeHash.String() 85 - 86 - if parent.Hash.IsZero() { 87 - nd.Commit.Parent = "" 88 - } else { 89 - nd.Commit.Parent = parent.Hash.String() 90 - } 91 - nd.Commit.Author = c.Author 92 - nd.Commit.Message = c.Message 93 - 94 - if v, ok := c.ExtraHeaders["change-id"]; ok { 95 - nd.Commit.ChangedId = string(v) 96 - } 80 + nd.Commit.FromGoGitCommit(c) 97 81 98 82 return &nd, nil 99 83 }
+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 ".",
+47
knotserver/git.go
··· 56 56 } 57 57 } 58 58 59 + func (h *Knot) UploadArchive(w http.ResponseWriter, r *http.Request) { 60 + did := chi.URLParam(r, "did") 61 + name := chi.URLParam(r, "name") 62 + repo, err := securejoin.SecureJoin(h.c.Repo.ScanPath, filepath.Join(did, name)) 63 + if err != nil { 64 + gitError(w, err.Error(), http.StatusInternalServerError) 65 + h.l.Error("git: failed to secure join repo path", "handler", "UploadPack", "error", err) 66 + return 67 + } 68 + 69 + const expectedContentType = "application/x-git-upload-archive-request" 70 + contentType := r.Header.Get("Content-Type") 71 + if contentType != expectedContentType { 72 + gitError(w, fmt.Sprintf("Expected Content-Type: '%s', but received '%s'.", expectedContentType, contentType), http.StatusUnsupportedMediaType) 73 + } 74 + 75 + var bodyReader io.ReadCloser = r.Body 76 + if r.Header.Get("Content-Encoding") == "gzip" { 77 + gzipReader, err := gzip.NewReader(r.Body) 78 + if err != nil { 79 + gitError(w, err.Error(), http.StatusInternalServerError) 80 + h.l.Error("git: failed to create gzip reader", "handler", "UploadArchive", "error", err) 81 + return 82 + } 83 + defer gzipReader.Close() 84 + bodyReader = gzipReader 85 + } 86 + 87 + w.Header().Set("Content-Type", "application/x-git-upload-archive-result") 88 + 89 + h.l.Info("git: executing git-upload-archive", "handler", "UploadArchive", "repo", repo) 90 + 91 + cmd := service.ServiceCommand{ 92 + GitProtocol: r.Header.Get("Git-Protocol"), 93 + Dir: repo, 94 + Stdout: w, 95 + Stdin: bodyReader, 96 + } 97 + 98 + w.WriteHeader(http.StatusOK) 99 + 100 + if err := cmd.UploadArchive(); err != nil { 101 + h.l.Error("git: failed to execute git-upload-pack", "handler", "UploadPack", "error", err) 102 + return 103 + } 104 + } 105 + 59 106 func (h *Knot) UploadPack(w http.ResponseWriter, r *http.Request) { 60 107 did := chi.URLParam(r, "did") 61 108 name := chi.URLParam(r, "name")
+1
knotserver/router.go
··· 82 82 r.Route("/{name}", func(r chi.Router) { 83 83 // routes for git operations 84 84 r.Get("/info/refs", h.InfoRefs) 85 + r.Post("/git-upload-archive", h.UploadArchive) 85 86 r.Post("/git-upload-pack", h.UploadPack) 86 87 r.Post("/git-receive-pack", h.ReceivePack) 87 88 })
+1 -1
knotserver/server.go
··· 64 64 logger.Info("running in dev mode, signature verification is disabled") 65 65 } 66 66 67 - db, err := db.Setup(c.Server.DBPath) 67 + db, err := db.Setup(ctx, c.Server.DBPath) 68 68 if err != nil { 69 69 return fmt.Errorf("failed to load db: %w", err) 70 70 }
+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,
+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 }
+33
lexicons/pipeline/cancelPipeline.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.pipeline.cancelPipeline", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Cancel a running pipeline", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["repo", "pipeline", "workflow"], 13 + "properties": { 14 + "repo": { 15 + "type": "string", 16 + "format": "at-uri", 17 + "description": "repo at-uri, spindle can't resolve repo from pipeline at-uri yet" 18 + }, 19 + "pipeline": { 20 + "type": "string", 21 + "format": "at-uri", 22 + "description": "pipeline at-uri" 23 + }, 24 + "workflow": { 25 + "type": "string", 26 + "description": "workflow name" 27 + } 28 + } 29 + } 30 + } 31 + } 32 + } 33 + }
+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 }
+6 -30
nix/gomod2nix.toml
··· 165 165 [mod."github.com/davecgh/go-spew"] 166 166 version = "v1.1.2-0.20180830191138-d8f796af33cc" 167 167 hash = "sha256-fV9oI51xjHdOmEx6+dlq7Ku2Ag+m/bmbzPo6A4Y74qc=" 168 - [mod."github.com/decred/dcrd/dcrec/secp256k1/v4"] 169 - version = "v4.4.0" 170 - hash = "sha256-qrhEIwhDll3cxoVpMbm1NQ9/HTI42S7ms8Buzlo5HCg=" 171 168 [mod."github.com/dgraph-io/ristretto"] 172 169 version = "v0.2.0" 173 170 hash = "sha256-bnpxX+oO/Qf7IJevA0gsbloVoqRx+5bh7RQ9d9eLNYw=" ··· 307 304 [mod."github.com/hashicorp/go-sockaddr"] 308 305 version = "v1.0.7" 309 306 hash = "sha256-p6eDOrGzN1jMmT/F/f/VJMq0cKNFhUcEuVVwTE6vSrs=" 307 + [mod."github.com/hashicorp/go-version"] 308 + version = "v1.8.0" 309 + hash = "sha256-KXtqERmYrWdpqPCViWcHbe6jnuH7k16bvBIcuJuevj8=" 310 310 [mod."github.com/hashicorp/golang-lru"] 311 311 version = "v1.0.2" 312 312 hash = "sha256-yy+5botc6T5wXgOe2mfNXJP3wr+MkVlUZ2JBkmmrA48=" ··· 373 373 [mod."github.com/klauspost/cpuid/v2"] 374 374 version = "v2.3.0" 375 375 hash = "sha256-50JhbQyT67BK38HIdJihPtjV7orYp96HknI2VP7A9Yc=" 376 - [mod."github.com/lestrrat-go/blackmagic"] 377 - version = "v1.0.4" 378 - hash = "sha256-HmWOpwoPDNMwLdOi7onNn3Sb+ZsAa3Ai3gVBbXmQ0e8=" 379 - [mod."github.com/lestrrat-go/httpcc"] 380 - version = "v1.0.1" 381 - hash = "sha256-SMRSwJpqDIs/xL0l2e8vP0W65qtCHX2wigcOeqPJmos=" 382 - [mod."github.com/lestrrat-go/httprc"] 383 - version = "v1.0.6" 384 - hash = "sha256-mfZzePEhrmyyu/avEBd2MsDXyto8dq5+fyu5lA8GUWM=" 385 - [mod."github.com/lestrrat-go/iter"] 386 - version = "v1.0.2" 387 - hash = "sha256-30tErRf7Qu/NOAt1YURXY/XJSA6sCr6hYQfO8QqHrtw=" 388 - [mod."github.com/lestrrat-go/jwx/v2"] 389 - version = "v2.1.6" 390 - hash = "sha256-0LszXRZIba+X8AOrs3T4uanAUafBdlVB8/MpUNEFpbc=" 391 - [mod."github.com/lestrrat-go/option"] 392 - version = "v1.0.1" 393 - hash = "sha256-jVcIYYVsxElIS/l2akEw32vdEPR8+anR6oeT1FoYULI=" 394 376 [mod."github.com/lucasb-eyer/go-colorful"] 395 377 version = "v1.2.0" 396 378 hash = "sha256-Gg9dDJFCTaHrKHRR1SrJgZ8fWieJkybljybkI9x0gyE=" ··· 511 493 [mod."github.com/ryanuber/go-glob"] 512 494 version = "v1.0.0" 513 495 hash = "sha256-YkMl1utwUhi3E0sHK23ISpAsPyj4+KeXyXKoFYGXGVY=" 514 - [mod."github.com/segmentio/asm"] 515 - version = "v1.2.0" 516 - hash = "sha256-zbNuKxNrUDUc6IlmRQNuJQzVe5Ol/mqp7srDg9IMMqs=" 517 496 [mod."github.com/sergi/go-diff"] 518 497 version = "v1.1.0" 519 498 hash = "sha256-8NJMabldpf40uwQN20T6QXx5KORDibCBJL02KD661xY=" ··· 548 527 [mod."github.com/whyrusleeping/cbor-gen"] 549 528 version = "v0.3.1" 550 529 hash = "sha256-PAd8M2Z8t6rVRBII+Rg8Bz+QaJIwbW64bfyqsv31kgc=" 551 - [mod."github.com/wyatt915/goldmark-treeblood"] 552 - version = "v0.0.1" 553 - hash = "sha256-hAVFaktO02MiiqZFffr8ZlvFEfwxw4Y84OZ2t7e5G7g=" 554 - [mod."github.com/wyatt915/treeblood"] 555 - version = "v0.1.16" 556 - hash = "sha256-T68sa+iVx0qY7dDjXEAJvRWQEGXYIpUsf9tcWwO1tIw=" 557 530 [mod."github.com/xo/terminfo"] 558 531 version = "v0.0.0-20220910002029-abceb7e1c41e" 559 532 hash = "sha256-GyCDxxMQhXA3Pi/TsWXpA8cX5akEoZV7CFx4RO3rARU=" 560 533 [mod."github.com/yuin/goldmark"] 561 534 version = "v1.7.13" 562 535 hash = "sha256-vBCxZrPYPc8x/nvAAv3Au59dCCyfS80Vw3/a9EXK7TE=" 536 + [mod."github.com/yuin/goldmark-emoji"] 537 + version = "v1.0.6" 538 + hash = "sha256-+d6bZzOPE+JSFsZbQNZMCWE+n3jgcQnkPETVk47mxSY=" 563 539 [mod."github.com/yuin/goldmark-highlighting/v2"] 564 540 version = "v2.0.0-20230729083705-37449abec8cc" 565 541 hash = "sha256-HpiwU7jIeDUAg2zOpTIiviQir8dpRPuXYh2nqFFccpg="
+64
nix/modules/bluesky-jetstream.nix
··· 1 + { 2 + config, 3 + pkgs, 4 + lib, 5 + ... 6 + }: let 7 + cfg = config.services.bluesky-jetstream; 8 + in 9 + with lib; { 10 + options.services.bluesky-jetstream = { 11 + enable = mkEnableOption "jetstream server"; 12 + package = mkPackageOption pkgs "bluesky-jetstream" {}; 13 + 14 + # dataDir = mkOption { 15 + # type = types.str; 16 + # default = "/var/lib/jetstream"; 17 + # description = "directory to store data (pebbleDB)"; 18 + # }; 19 + livenessTtl = mkOption { 20 + type = types.int; 21 + default = 15; 22 + description = "time to restart when no event detected (seconds)"; 23 + }; 24 + websocketUrl = mkOption { 25 + type = types.str; 26 + default = "wss://bsky.network/xrpc/com.atproto.sync.subscribeRepos"; 27 + description = "full websocket path to the ATProto SubscribeRepos XRPC endpoint"; 28 + }; 29 + }; 30 + config = mkIf cfg.enable { 31 + systemd.services.bluesky-jetstream = { 32 + description = "bluesky jetstream"; 33 + after = ["network.target" "pds.service"]; 34 + wantedBy = ["multi-user.target"]; 35 + 36 + serviceConfig = { 37 + User = "jetstream"; 38 + Group = "jetstream"; 39 + StateDirectory = "jetstream"; 40 + StateDirectoryMode = "0755"; 41 + # preStart = '' 42 + # mkdir -p "${cfg.dataDir}" 43 + # chown -R jetstream:jetstream "${cfg.dataDir}" 44 + # ''; 45 + # WorkingDirectory = cfg.dataDir; 46 + Environment = [ 47 + "JETSTREAM_DATA_DIR=/var/lib/jetstream/data" 48 + "JETSTREAM_LIVENESS_TTL=${toString cfg.livenessTtl}s" 49 + "JETSTREAM_WS_URL=${cfg.websocketUrl}" 50 + ]; 51 + ExecStart = getExe cfg.package; 52 + Restart = "always"; 53 + RestartSec = 5; 54 + }; 55 + }; 56 + users = { 57 + users.jetstream = { 58 + group = "jetstream"; 59 + isSystemUser = true; 60 + }; 61 + groups.jetstream = {}; 62 + }; 63 + }; 64 + }
+48
nix/modules/bluesky-relay.nix
··· 1 + { 2 + config, 3 + pkgs, 4 + lib, 5 + ... 6 + }: let 7 + cfg = config.services.bluesky-relay; 8 + in 9 + with lib; { 10 + options.services.bluesky-relay = { 11 + enable = mkEnableOption "relay server"; 12 + package = mkPackageOption pkgs "bluesky-relay" {}; 13 + }; 14 + config = mkIf cfg.enable { 15 + systemd.services.bluesky-relay = { 16 + description = "bluesky relay"; 17 + after = ["network.target" "pds.service"]; 18 + wantedBy = ["multi-user.target"]; 19 + 20 + serviceConfig = { 21 + User = "relay"; 22 + Group = "relay"; 23 + StateDirectory = "relay"; 24 + StateDirectoryMode = "0755"; 25 + Environment = [ 26 + "RELAY_ADMIN_PASSWORD=password" 27 + "RELAY_PLC_HOST=https://plc.tngl.boltless.dev" 28 + "DATABASE_URL=sqlite:///var/lib/relay/relay.sqlite" 29 + "RELAY_IP_BIND=:2470" 30 + "RELAY_PERSIST_DIR=/var/lib/relay" 31 + "RELAY_DISABLE_REQUEST_CRAWL=0" 32 + "RELAY_INITIAL_SEQ_NUMBER=1" 33 + "RELAY_ALLOW_INSECURE_HOSTS=1" 34 + ]; 35 + ExecStart = "${getExe cfg.package} serve"; 36 + Restart = "always"; 37 + RestartSec = 5; 38 + }; 39 + }; 40 + users = { 41 + users.relay = { 42 + group = "relay"; 43 + isSystemUser = true; 44 + }; 45 + groups.relay = {}; 46 + }; 47 + }; 48 + }
+76
nix/modules/did-method-plc.nix
··· 1 + { 2 + config, 3 + pkgs, 4 + lib, 5 + ... 6 + }: let 7 + cfg = config.services.did-method-plc; 8 + in 9 + with lib; { 10 + options.services.did-method-plc = { 11 + enable = mkEnableOption "did-method-plc server"; 12 + package = mkPackageOption pkgs "did-method-plc" {}; 13 + }; 14 + config = mkIf cfg.enable { 15 + services.postgresql = { 16 + enable = true; 17 + package = pkgs.postgresql_14; 18 + ensureDatabases = ["plc"]; 19 + ensureUsers = [ 20 + { 21 + name = "pg"; 22 + # ensurePermissions."DATABASE plc" = "ALL PRIVILEGES"; 23 + } 24 + ]; 25 + authentication = '' 26 + local all all trust 27 + host all all 127.0.0.1/32 trust 28 + ''; 29 + }; 30 + systemd.services.did-method-plc = { 31 + description = "did-method-plc"; 32 + 33 + after = ["postgresql.service"]; 34 + wants = ["postgresql.service"]; 35 + wantedBy = ["multi-user.target"]; 36 + 37 + environment = let 38 + db_creds_json = builtins.toJSON { 39 + username = "pg"; 40 + password = ""; 41 + host = "127.0.0.1"; 42 + port = 5432; 43 + }; 44 + in { 45 + # TODO: inherit from config 46 + DEBUG_MODE = "1"; 47 + LOG_ENABLED = "true"; 48 + LOG_LEVEL = "debug"; 49 + LOG_DESTINATION = "1"; 50 + ENABLE_MIGRATIONS = "true"; 51 + DB_CREDS_JSON = db_creds_json; 52 + DB_MIGRATE_CREDS_JSON = db_creds_json; 53 + PLC_VERSION = "0.0.1"; 54 + PORT = "8080"; 55 + }; 56 + 57 + serviceConfig = { 58 + ExecStart = getExe cfg.package; 59 + User = "plc"; 60 + Group = "plc"; 61 + StateDirectory = "plc"; 62 + StateDirectoryMode = "0755"; 63 + Restart = "always"; 64 + 65 + # Hardening 66 + }; 67 + }; 68 + users = { 69 + users.plc = { 70 + group = "plc"; 71 + isSystemUser = true; 72 + }; 73 + groups.plc = {}; 74 + }; 75 + }; 76 + }
+2
nix/modules/knot.nix
··· 195 195 Match User ${cfg.gitUser} 196 196 AuthorizedKeysCommand /etc/ssh/keyfetch_wrapper 197 197 AuthorizedKeysCommandUser nobody 198 + ChallengeResponseAuthentication no 199 + PasswordAuthentication no 198 200 ''; 199 201 }; 200 202
+35
nix/modules/spindle.nix
··· 1 1 { 2 2 config, 3 + pkgs, 3 4 lib, 4 5 ... 5 6 }: let ··· 16 17 package = mkOption { 17 18 type = types.package; 18 19 description = "Package to use for the spindle"; 20 + }; 21 + tap-package = mkOption { 22 + type = types.package; 23 + description = "Package to use for the spindle"; 24 + }; 25 + 26 + atpRelayUrl = mkOption { 27 + type = types.str; 28 + default = "https://relay1.us-east.bsky.network"; 29 + description = "atproto relay"; 19 30 }; 20 31 21 32 server = { ··· 114 125 config = mkIf cfg.enable { 115 126 virtualisation.docker.enable = true; 116 127 128 + systemd.services.spindle-tap = { 129 + description = "spindle tap service"; 130 + after = ["network.target" "docker.service"]; 131 + wantedBy = ["multi-user.target"]; 132 + serviceConfig = { 133 + LogsDirectory = "spindle-tap"; 134 + StateDirectory = "spindle-tap"; 135 + Environment = [ 136 + "TAP_BIND=:2480" 137 + "TAP_PLC_URL=${cfg.server.plcUrl}" 138 + "TAP_RELAY_URL=${cfg.atpRelayUrl}" 139 + "TAP_COLLECTION_FILTERS=${concatStringsSep "," [ 140 + "sh.tangled.repo" 141 + "sh.tangled.repo.collaborator" 142 + "sh.tangled.spindle.member" 143 + ]}" 144 + ]; 145 + ExecStart = "${getExe cfg.tap-package} run"; 146 + }; 147 + }; 148 + 117 149 systemd.services.spindle = { 118 150 description = "spindle service"; 119 151 after = ["network.target" "docker.service"]; 120 152 wantedBy = ["multi-user.target"]; 153 + path = [ 154 + pkgs.git 155 + ]; 121 156 serviceConfig = { 122 157 LogsDirectory = "spindle"; 123 158 StateDirectory = "spindle";
+20
nix/pkgs/bluesky-jetstream.nix
··· 1 + { 2 + buildGoModule, 3 + fetchFromGitHub, 4 + }: 5 + buildGoModule { 6 + pname = "bluesky-jetstream"; 7 + version = "0.1.0"; 8 + src = fetchFromGitHub { 9 + owner = "bluesky-social"; 10 + repo = "jetstream"; 11 + rev = "7d7efa58d7f14101a80ccc4f1085953948b7d5de"; 12 + sha256 = "sha256-1e9SL/8gaDPMA4YZed51ffzgpkptbMd0VTbTTDbPTFw="; 13 + }; 14 + subPackages = ["cmd/jetstream"]; 15 + vendorHash = "sha256-/21XJQH6fo9uPzlABUAbdBwt1O90odmppH6gXu2wkiQ="; 16 + doCheck = false; 17 + meta = { 18 + mainProgram = "jetstream"; 19 + }; 20 + }
+20
nix/pkgs/bluesky-relay.nix
··· 1 + { 2 + buildGoModule, 3 + fetchFromGitHub, 4 + }: 5 + buildGoModule { 6 + pname = "bluesky-relay"; 7 + version = "0.1.0"; 8 + src = fetchFromGitHub { 9 + owner = "boltlessengineer"; 10 + repo = "indigo"; 11 + rev = "b769ea60b7dde5e2bd0b8ee3ce8462a0c0e596fe"; 12 + sha256 = "sha256-jHRY825TBYaH1WkKFUoNbo4UlMSyuHvCGjYPiBnKo44="; 13 + }; 14 + subPackages = ["cmd/relay"]; 15 + vendorHash = "sha256-UOedwNYnM8Jx6B7Y9tFcZX8IeUBESAFAPTRYk7n0yo8="; 16 + doCheck = false; 17 + meta = { 18 + mainProgram = "relay"; 19 + }; 20 + }
+65
nix/pkgs/did-method-plc.nix
··· 1 + # inspired by https://github.com/NixOS/nixpkgs/blob/333bfb7c258fab089a834555ea1c435674c459b4/pkgs/by-name/ga/gatsby-cli/package.nix 2 + { 3 + lib, 4 + stdenv, 5 + fetchFromGitHub, 6 + fetchYarnDeps, 7 + yarnConfigHook, 8 + yarnBuildHook, 9 + nodejs, 10 + makeBinaryWrapper, 11 + }: 12 + stdenv.mkDerivation (finalAttrs: { 13 + pname = "did-method-plc"; 14 + version = "0.0.1"; 15 + 16 + src = fetchFromGitHub { 17 + owner = "did-method-plc"; 18 + repo = "did-method-plc"; 19 + rev = "158ba5535ac3da4fd4309954bde41deab0b45972"; 20 + sha256 = "sha256-O5smubbrnTDMCvL6iRyMXkddr5G7YHxkQRVMRULHanQ="; 21 + }; 22 + postPatch = '' 23 + # remove dd-trace dependency 24 + sed -i '3d' packages/server/service/index.js 25 + ''; 26 + 27 + yarnOfflineCache = fetchYarnDeps { 28 + yarnLock = finalAttrs.src + "/yarn.lock"; 29 + hash = "sha256-g8GzaAbWSnWwbQjJMV2DL5/ZlWCCX0sRkjjvX3tqU4Y="; 30 + }; 31 + 32 + nativeBuildInputs = [ 33 + yarnConfigHook 34 + yarnBuildHook 35 + nodejs 36 + makeBinaryWrapper 37 + ]; 38 + yarnBuildScript = "lerna"; 39 + yarnBuildFlags = [ 40 + "run" 41 + "build" 42 + "--scope" 43 + "@did-plc/server" 44 + "--include-dependencies" 45 + ]; 46 + 47 + installPhase = '' 48 + runHook preInstall 49 + 50 + mkdir -p $out/lib/node_modules/ 51 + mv packages/ $out/lib/packages/ 52 + mv node_modules/* $out/lib/node_modules/ 53 + 54 + makeWrapper ${lib.getExe nodejs} $out/bin/plc \ 55 + --add-flags $out/lib/packages/server/service/index.js \ 56 + --add-flags --enable-source-maps \ 57 + --set NODE_PATH $out/lib/node_modules 58 + 59 + runHook postInstall 60 + ''; 61 + 62 + meta = { 63 + mainProgram = "plc"; 64 + }; 65 + })
+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
+20
nix/pkgs/tap.nix
··· 1 + { 2 + buildGoModule, 3 + fetchFromGitHub, 4 + }: 5 + buildGoModule { 6 + pname = "tap"; 7 + version = "0.1.0"; 8 + src = fetchFromGitHub { 9 + owner = "bluesky-social"; 10 + repo = "indigo"; 11 + rev = "f92cb29224fcc60f666b20ee3514e431a58ff811"; 12 + sha256 = "sha256-35ltXnq0SJeo3j33D7Nndbcnw5XWBJLRrmZ+nCmZVQw="; 13 + }; 14 + subPackages = ["cmd/tap"]; 15 + vendorHash = "sha256-UOedwNYnM8Jx6B7Y9tFcZX8IeUBESAFAPTRYk7n0yo8="; 16 + doCheck = false; 17 + meta = { 18 + mainProgram = "tap"; 19 + }; 20 + }
+6 -4
nix/vm.nix
··· 19 19 20 20 plcUrl = envVarOr "TANGLED_VM_PLC_URL" "https://plc.directory"; 21 21 jetstream = envVarOr "TANGLED_VM_JETSTREAM_ENDPOINT" "wss://jetstream1.us-west.bsky.network/subscribe"; 22 + relayUrl = envVarOr "TANGLED_VM_RELAY_URL" "https://relay1.us-east.bsky.network"; 22 23 in 23 24 nixpkgs.lib.nixosSystem { 24 25 inherit system; ··· 48 49 # knot 49 50 { 50 51 from = "host"; 51 - host.port = 6000; 52 - guest.port = 6000; 52 + host.port = 6444; 53 + guest.port = 6444; 53 54 } 54 55 # spindle 55 56 { ··· 87 88 motd = "Welcome to the development knot!\n"; 88 89 server = { 89 90 owner = envVar "TANGLED_VM_KNOT_OWNER"; 90 - hostname = envVarOr "TANGLED_VM_KNOT_HOST" "localhost:6000"; 91 + hostname = envVarOr "TANGLED_VM_KNOT_HOST" "localhost:6444"; 91 92 plcUrl = plcUrl; 92 93 jetstreamEndpoint = jetstream; 93 - listenAddr = "0.0.0.0:6000"; 94 + listenAddr = "0.0.0.0:6444"; 94 95 }; 95 96 }; 96 97 services.tangled.spindle = { 97 98 enable = true; 99 + atpRelayUrl = relayUrl; 98 100 server = { 99 101 owner = envVar "TANGLED_VM_SPINDLE_OWNER"; 100 102 hostname = envVarOr "TANGLED_VM_SPINDLE_HOST" "localhost:6555";
+132
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 + _, err = tx.Exec(` 24 + create table if not exists migrations ( 25 + id integer primary key autoincrement, 26 + name text unique 27 + ); 28 + `) 29 + if err != nil { 30 + return fmt.Errorf("creating migrations table: %w", err) 31 + } 32 + 33 + var exists bool 34 + err = tx.QueryRow("select exists (select 1 from migrations where name = ?)", name).Scan(&exists) 35 + if err != nil { 36 + return err 37 + } 38 + 39 + if !exists { 40 + // run migration 41 + err = migrationFn(tx) 42 + if err != nil { 43 + logger.Error("failed to run migration", "err", err) 44 + return err 45 + } 46 + 47 + // mark migration as complete 48 + _, err = tx.Exec("insert into migrations (name) values (?)", name) 49 + if err != nil { 50 + logger.Error("failed to mark migration as complete", "err", err) 51 + return err 52 + } 53 + 54 + // commit the transaction 55 + if err := tx.Commit(); err != nil { 56 + return err 57 + } 58 + 59 + logger.Info("migration applied successfully") 60 + } else { 61 + logger.Warn("skipped migration, already applied") 62 + } 63 + 64 + return nil 65 + } 66 + 67 + type Filter struct { 68 + Key string 69 + arg any 70 + Cmp string 71 + } 72 + 73 + func newFilter(key, cmp string, arg any) Filter { 74 + return Filter{ 75 + Key: key, 76 + arg: arg, 77 + Cmp: cmp, 78 + } 79 + } 80 + 81 + func FilterEq(key string, arg any) Filter { return newFilter(key, "=", arg) } 82 + func FilterNotEq(key string, arg any) Filter { return newFilter(key, "<>", arg) } 83 + func FilterGte(key string, arg any) Filter { return newFilter(key, ">=", arg) } 84 + func FilterLte(key string, arg any) Filter { return newFilter(key, "<=", arg) } 85 + func FilterIs(key string, arg any) Filter { return newFilter(key, "is", arg) } 86 + func FilterIsNot(key string, arg any) Filter { return newFilter(key, "is not", arg) } 87 + func FilterIn(key string, arg any) Filter { return newFilter(key, "in", arg) } 88 + func FilterLike(key string, arg any) Filter { return newFilter(key, "like", arg) } 89 + func FilterNotLike(key string, arg any) Filter { return newFilter(key, "not like", arg) } 90 + func FilterContains(key string, arg any) Filter { 91 + return newFilter(key, "like", fmt.Sprintf("%%%v%%", arg)) 92 + } 93 + 94 + func (f Filter) Condition() string { 95 + rv := reflect.ValueOf(f.arg) 96 + kind := rv.Kind() 97 + 98 + // if we have `FilterIn(k, [1, 2, 3])`, compile it down to `k in (?, ?, ?)` 99 + if (kind == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8) || kind == reflect.Array { 100 + if rv.Len() == 0 { 101 + // always false 102 + return "1 = 0" 103 + } 104 + 105 + placeholders := make([]string, rv.Len()) 106 + for i := range placeholders { 107 + placeholders[i] = "?" 108 + } 109 + 110 + return fmt.Sprintf("%s %s (%s)", f.Key, f.Cmp, strings.Join(placeholders, ", ")) 111 + } 112 + 113 + return fmt.Sprintf("%s %s ?", f.Key, f.Cmp) 114 + } 115 + 116 + func (f Filter) Arg() []any { 117 + rv := reflect.ValueOf(f.arg) 118 + kind := rv.Kind() 119 + if (kind == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8) || kind == reflect.Array { 120 + if rv.Len() == 0 { 121 + return nil 122 + } 123 + 124 + out := make([]any, rv.Len()) 125 + for i := range rv.Len() { 126 + out[i] = rv.Index(i).Interface() 127 + } 128 + return out 129 + } 130 + 131 + return []any{f.arg} 132 + }
-1
patchutil/patchutil.go
··· 296 296 } 297 297 298 298 nd := types.NiceDiff{} 299 - nd.Commit.Parent = targetBranch 300 299 301 300 for _, d := range diffs { 302 301 ndiff := types.Diff{}
+8
rbac/rbac.go
··· 285 285 return e.E.Enforce(user, domain, repo, "repo:delete") 286 286 } 287 287 288 + func (e *Enforcer) IsRepoOwner(user, domain, repo string) (bool, error) { 289 + return e.E.Enforce(user, domain, repo, "repo:owner") 290 + } 291 + 292 + func (e *Enforcer) IsRepoCollaborator(user, domain, repo string) (bool, error) { 293 + return e.E.Enforce(user, domain, repo, "repo:collaborator") 294 + } 295 + 288 296 func (e *Enforcer) IsPushAllowed(user, domain, repo string) (bool, error) { 289 297 return e.E.Enforce(user, domain, repo, "repo:push") 290 298 }
+144
rbac2/rbac2.go
··· 1 + package rbac2 2 + 3 + import ( 4 + "database/sql" 5 + "fmt" 6 + 7 + adapter "github.com/Blank-Xu/sql-adapter" 8 + "github.com/bluesky-social/indigo/atproto/syntax" 9 + "github.com/casbin/casbin/v2" 10 + "github.com/casbin/casbin/v2/model" 11 + "github.com/casbin/casbin/v2/util" 12 + "tangled.org/core/api/tangled" 13 + ) 14 + 15 + const ( 16 + Model = ` 17 + [request_definition] 18 + r = sub, dom, obj, act 19 + 20 + [policy_definition] 21 + p = sub, dom, obj, act 22 + 23 + [role_definition] 24 + g = _, _, _ 25 + 26 + [policy_effect] 27 + e = some(where (p.eft == allow)) 28 + 29 + [matchers] 30 + m = g(r.sub, p.sub, r.dom) && keyMatch4(r.dom, p.dom) && r.obj == p.obj && r.act == p.act 31 + ` 32 + ) 33 + 34 + type Enforcer struct { 35 + e *casbin.Enforcer 36 + } 37 + 38 + func NewEnforcer(path string) (*Enforcer, error) { 39 + m, err := model.NewModelFromString(Model) 40 + if err != nil { 41 + return nil, err 42 + } 43 + 44 + db, err := sql.Open("sqlite3", path+"?_foreign_keys=1") 45 + if err != nil { 46 + return nil, err 47 + } 48 + 49 + a, err := adapter.NewAdapter(db, "sqlite3", "acl") 50 + if err != nil { 51 + return nil, err 52 + } 53 + 54 + e, err := casbin.NewEnforcer(m, a) 55 + if err != nil { 56 + return nil, err 57 + } 58 + 59 + if err := seedTangledPolicies(e); err != nil { 60 + return nil, err 61 + } 62 + 63 + return &Enforcer{e}, nil 64 + } 65 + 66 + func seedTangledPolicies(e *casbin.Enforcer) error { 67 + // policies 68 + aturi := func(nsid string) string { 69 + return fmt.Sprintf("at://{did}/%s/{rkey}", nsid) 70 + } 71 + 72 + _, err := e.AddPoliciesEx([][]string{ 73 + // sub | dom | obj | act 74 + {"repo:owner", aturi(tangled.RepoNSID), "/", "write"}, 75 + {"repo:owner", aturi(tangled.RepoNSID), "/collaborator", "write"}, // invite 76 + {"repo:collaborator", aturi(tangled.RepoNSID), "/settings", "write"}, 77 + {"repo:collaborator", aturi(tangled.RepoNSID), "/git", "write"}, // git push 78 + 79 + {"server:owner", "/knot/{did}", "/member", "write"}, // invite 80 + {"server:member", "/knot/{did}", "/git", "write"}, 81 + 82 + {"server:owner", "/spindle/{did}", "/member", "write"}, // invite 83 + }) 84 + if err != nil { 85 + return err 86 + } 87 + 88 + // grouping policies 89 + // TODO(boltless): define our own matcher to replace keyMatch4 90 + e.AddNamedDomainMatchingFunc("g", "keyMatch4", util.KeyMatch4) 91 + _, err = e.AddGroupingPoliciesEx([][]string{ 92 + // sub | role | dom 93 + {"repo:owner", "repo:collaborator", aturi(tangled.RepoNSID)}, 94 + 95 + // using '/knot/' prefix here because knot/spindle identifiers don't 96 + // include the collection type 97 + {"server:owner", "server:member", "/knot/{did}"}, 98 + {"server:owner", "server:member", "/spindle/{did}"}, 99 + }) 100 + return err 101 + } 102 + 103 + func (e *Enforcer) hasImplicitRoleForUser(name string, role string, domain ...string) (bool, error) { 104 + roles, err := e.e.GetImplicitRolesForUser(name, domain...) 105 + if err != nil { 106 + return false, err 107 + } 108 + for _, r := range roles { 109 + if r == role { 110 + return true, nil 111 + } 112 + } 113 + return false, nil 114 + } 115 + 116 + // setRoleForUser sets single user role for specified domain. 117 + // All existing users with that role will be removed. 118 + func (e *Enforcer) setRoleForUser(name string, role string, domain ...string) error { 119 + currentUsers, err := e.e.GetUsersForRole(role, domain...) 120 + if err != nil { 121 + return err 122 + } 123 + 124 + for _, oldUser := range currentUsers { 125 + _, err = e.e.DeleteRoleForUser(oldUser, role, domain...) 126 + if err != nil { 127 + return err 128 + } 129 + } 130 + 131 + _, err = e.e.AddRoleForUser(name, role, domain...) 132 + return err 133 + } 134 + 135 + // validateAtUri enforeces AT-URI to have valid did as authority and match collection NSID. 136 + func validateAtUri(uri syntax.ATURI, expected string) error { 137 + if !uri.Authority().IsDID() { 138 + return fmt.Errorf("expected at-uri with did") 139 + } 140 + if expected != "" && uri.Collection().String() != expected { 141 + return fmt.Errorf("incorrect repo at-uri collection nsid '%s' (expected '%s')", uri.Collection(), expected) 142 + } 143 + return nil 144 + }
+115
rbac2/rbac2_test.go
··· 1 + package rbac2_test 2 + 3 + import ( 4 + "testing" 5 + 6 + "github.com/bluesky-social/indigo/atproto/syntax" 7 + _ "github.com/mattn/go-sqlite3" 8 + "github.com/stretchr/testify/assert" 9 + "tangled.org/core/rbac2" 10 + ) 11 + 12 + func setup(t *testing.T) *rbac2.Enforcer { 13 + enforcer, err := rbac2.NewEnforcer(":memory:") 14 + assert.NoError(t, err) 15 + 16 + return enforcer 17 + } 18 + 19 + func TestRepoOwnerPermissions(t *testing.T) { 20 + var ( 21 + e = setup(t) 22 + ok bool 23 + err error 24 + fooRepo = syntax.ATURI("at://did:plc:foo/sh.tangled.repo/reporkey") 25 + fooUser = syntax.DID("did:plc:foo") 26 + ) 27 + 28 + assert.NoError(t, e.AddRepo(fooRepo)) 29 + 30 + ok, err = e.IsRepoOwner(fooUser, fooRepo) 31 + assert.NoError(t, err) 32 + assert.True(t, ok, "repo author should be repo owner") 33 + 34 + ok, err = e.IsRepoWriteAllowed(fooUser, fooRepo) 35 + assert.NoError(t, err) 36 + assert.True(t, ok, "repo owner should be able to modify the repo itself") 37 + 38 + ok, err = e.IsRepoCollaborator(fooUser, fooRepo) 39 + assert.NoError(t, err) 40 + assert.True(t, ok, "repo owner should inherit role role:collaborator") 41 + 42 + ok, err = e.IsRepoSettingsWriteAllowed(fooUser, fooRepo) 43 + assert.NoError(t, err) 44 + assert.True(t, ok, "repo owner should inherit collaborator permissions") 45 + } 46 + 47 + func TestRepoCollaboratorPermissions(t *testing.T) { 48 + var ( 49 + e = setup(t) 50 + ok bool 51 + err error 52 + fooRepo = syntax.ATURI("at://did:plc:foo/sh.tangled.repo/reporkey") 53 + barUser = syntax.DID("did:plc:bar") 54 + ) 55 + 56 + assert.NoError(t, e.AddRepo(fooRepo)) 57 + assert.NoError(t, e.AddRepoCollaborator(barUser, fooRepo)) 58 + 59 + ok, err = e.IsRepoCollaborator(barUser, fooRepo) 60 + assert.NoError(t, err) 61 + assert.True(t, ok, "should set repo collaborator") 62 + 63 + ok, err = e.IsRepoSettingsWriteAllowed(barUser, fooRepo) 64 + assert.NoError(t, err) 65 + assert.True(t, ok, "repo collaborator should be able to edit repo settings") 66 + 67 + ok, err = e.IsRepoWriteAllowed(barUser, fooRepo) 68 + assert.NoError(t, err) 69 + assert.False(t, ok, "repo collaborator shouldn't be able to modify the repo itself") 70 + } 71 + 72 + func TestGetByRole(t *testing.T) { 73 + var ( 74 + e = setup(t) 75 + err error 76 + fooRepo = syntax.ATURI("at://did:plc:foo/sh.tangled.repo/reporkey") 77 + owner = syntax.DID("did:plc:foo") 78 + collaborator1 = syntax.DID("did:plc:bar") 79 + collaborator2 = syntax.DID("did:plc:baz") 80 + ) 81 + 82 + assert.NoError(t, e.AddRepo(fooRepo)) 83 + assert.NoError(t, e.AddRepoCollaborator(collaborator1, fooRepo)) 84 + assert.NoError(t, e.AddRepoCollaborator(collaborator2, fooRepo)) 85 + 86 + collaborators, err := e.GetRepoCollaborators(fooRepo) 87 + assert.NoError(t, err) 88 + assert.ElementsMatch(t, []syntax.DID{ 89 + owner, 90 + collaborator1, 91 + collaborator2, 92 + }, collaborators) 93 + } 94 + 95 + func TestSpindleOwnerPermissions(t *testing.T) { 96 + var ( 97 + e = setup(t) 98 + ok bool 99 + err error 100 + spindle = syntax.DID("did:web:spindle.example.com") 101 + owner = syntax.DID("did:plc:foo") 102 + member = syntax.DID("did:plc:bar") 103 + ) 104 + 105 + assert.NoError(t, e.SetSpindleOwner(owner, spindle)) 106 + assert.NoError(t, e.AddSpindleMember(member, spindle)) 107 + 108 + ok, err = e.IsSpindleMemberInviteAllowed(owner, spindle) 109 + assert.NoError(t, err) 110 + assert.True(t, ok, "spindle owner can invite members") 111 + 112 + ok, err = e.IsSpindleMemberInviteAllowed(member, spindle) 113 + assert.NoError(t, err) 114 + assert.False(t, ok, "spindle member cannot invite members") 115 + }
+91
rbac2/repo.go
··· 1 + package rbac2 2 + 3 + import ( 4 + "slices" 5 + "strings" 6 + 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 + "tangled.org/core/api/tangled" 9 + ) 10 + 11 + // AddRepo adds new repo with its owner to rbac enforcer 12 + func (e *Enforcer) AddRepo(repo syntax.ATURI) error { 13 + if err := validateAtUri(repo, tangled.RepoNSID); err != nil { 14 + return err 15 + } 16 + user := repo.Authority() 17 + 18 + return e.setRoleForUser(user.String(), "repo:owner", repo.String()) 19 + } 20 + 21 + // DeleteRepo deletes all policies related to the repo 22 + func (e *Enforcer) DeleteRepo(repo syntax.ATURI) error { 23 + if err := validateAtUri(repo, tangled.RepoNSID); err != nil { 24 + return err 25 + } 26 + 27 + _, err := e.e.DeleteDomains(repo.String()) 28 + return err 29 + } 30 + 31 + // AddRepoCollaborator adds new collaborator to the repo 32 + func (e *Enforcer) AddRepoCollaborator(user syntax.DID, repo syntax.ATURI) error { 33 + if err := validateAtUri(repo, tangled.RepoNSID); err != nil { 34 + return err 35 + } 36 + 37 + _, err := e.e.AddRoleForUser(user.String(), "repo:collaborator", repo.String()) 38 + return err 39 + } 40 + 41 + // RemoveRepoCollaborator removes the collaborator from the repo. 42 + // This won't remove inherited roles like repository owner. 43 + func (e *Enforcer) RemoveRepoCollaborator(user syntax.DID, repo syntax.ATURI) error { 44 + if err := validateAtUri(repo, tangled.RepoNSID); err != nil { 45 + return err 46 + } 47 + 48 + _, err := e.e.DeleteRoleForUser(user.String(), "repo:collaborator", repo.String()) 49 + return err 50 + } 51 + 52 + func (e *Enforcer) GetRepoCollaborators(repo syntax.ATURI) ([]syntax.DID, error) { 53 + var collaborators []syntax.DID 54 + members, err := e.e.GetImplicitUsersForRole("repo:collaborator", repo.String()) 55 + if err != nil { 56 + return nil, err 57 + } 58 + for _, m := range members { 59 + if !strings.HasPrefix(m, "did:") { // skip non-user subjects like 'repo:owner' 60 + continue 61 + } 62 + collaborators = append(collaborators, syntax.DID(m)) 63 + } 64 + 65 + slices.Sort(collaborators) 66 + return slices.Compact(collaborators), nil 67 + } 68 + 69 + func (e *Enforcer) IsRepoOwner(user syntax.DID, repo syntax.ATURI) (bool, error) { 70 + return e.e.HasRoleForUser(user.String(), "repo:owner", repo.String()) 71 + } 72 + 73 + func (e *Enforcer) IsRepoCollaborator(user syntax.DID, repo syntax.ATURI) (bool, error) { 74 + return e.hasImplicitRoleForUser(user.String(), "repo:collaborator", repo.String()) 75 + } 76 + 77 + func (e *Enforcer) IsRepoWriteAllowed(user syntax.DID, repo syntax.ATURI) (bool, error) { 78 + return e.e.Enforce(user.String(), repo.String(), "#/", "write") 79 + } 80 + 81 + func (e *Enforcer) IsRepoSettingsWriteAllowed(user syntax.DID, repo syntax.ATURI) (bool, error) { 82 + return e.e.Enforce(user.String(), repo.String(), "#/settings", "write") 83 + } 84 + 85 + func (e *Enforcer) IsRepoCollaboratorInviteAllowed(user syntax.DID, repo syntax.ATURI) (bool, error) { 86 + return e.e.Enforce(user.String(), repo.String(), "#/collaborator", "write") 87 + } 88 + 89 + func (e *Enforcer) IsRepoGitPushAllowed(user syntax.DID, repo syntax.ATURI) (bool, error) { 90 + return e.e.Enforce(user.String(), repo.String(), "#/git", "write") 91 + }
+29
rbac2/spindle.go
··· 1 + package rbac2 2 + 3 + import "github.com/bluesky-social/indigo/atproto/syntax" 4 + 5 + func (e *Enforcer) SetSpindleOwner(user syntax.DID, spindle syntax.DID) error { 6 + return e.setRoleForUser(user.String(), "server:owner", intoSpindle(spindle)) 7 + } 8 + 9 + func (e *Enforcer) IsSpindleMember(user syntax.DID, spindle syntax.DID) (bool, error) { 10 + return e.e.HasRoleForUser(user.String(), "server:member", spindle.String()) 11 + } 12 + 13 + func (e *Enforcer) AddSpindleMember(user syntax.DID, spindle syntax.DID) error { 14 + _, err := e.e.AddRoleForUser(user.String(), "server:member", intoSpindle(spindle)) 15 + return err 16 + } 17 + 18 + func (e *Enforcer) RemoveSpindleMember(user syntax.DID, spindle syntax.DID) error { 19 + _, err := e.e.DeleteRoleForUser(user.String(), "server:member", intoSpindle(spindle)) 20 + return err 21 + } 22 + 23 + func (e *Enforcer) IsSpindleMemberInviteAllowed(user syntax.DID, spindle syntax.DID) (bool, error) { 24 + return e.e.Enforce(user.String(), intoSpindle(spindle), "#/member", "write") 25 + } 26 + 27 + func intoSpindle(did syntax.DID) string { 28 + return "/spindle/" + did.String() 29 + }
+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 + }
+18 -11
spindle/config/config.go
··· 3 3 import ( 4 4 "context" 5 5 "fmt" 6 + "path" 6 7 7 8 "github.com/bluesky-social/indigo/atproto/syntax" 8 9 "github.com/sethvargo/go-envconfig" 9 10 ) 10 11 11 12 type Server struct { 12 - ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:6555"` 13 - DBPath string `env:"DB_PATH, default=spindle.db"` 14 - Hostname string `env:"HOSTNAME, required"` 15 - JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"` 16 - PlcUrl string `env:"PLC_URL, default=https://plc.directory"` 17 - Dev bool `env:"DEV, default=false"` 18 - Owner string `env:"OWNER, required"` 19 - Secrets Secrets `env:",prefix=SECRETS_"` 20 - LogDir string `env:"LOG_DIR, default=/var/log/spindle"` 21 - QueueSize int `env:"QUEUE_SIZE, default=100"` 22 - MaxJobCount int `env:"MAX_JOB_COUNT, default=2"` // max number of jobs that run at a time 13 + ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:6555"` 14 + DBPath string `env:"DB_PATH, default=spindle.db"` 15 + Hostname string `env:"HOSTNAME, required"` 16 + JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"` 17 + TapUrl string `env:"TAP_URL, required"` 18 + PlcUrl string `env:"PLC_URL, default=https://plc.directory"` 19 + Dev bool `env:"DEV, default=false"` 20 + Owner syntax.DID `env:"OWNER, required"` 21 + Secrets Secrets `env:",prefix=SECRETS_"` 22 + LogDir string `env:"LOG_DIR, default=/var/log/spindle"` 23 + DataDir string `env:"DATA_DIR, default=/var/lib/spindle"` 24 + QueueSize int `env:"QUEUE_SIZE, default=100"` 25 + MaxJobCount int `env:"MAX_JOB_COUNT, default=2"` // max number of jobs that run at a time 23 26 } 24 27 25 28 func (s Server) Did() syntax.DID { 26 29 return syntax.DID(fmt.Sprintf("did:web:%s", s.Hostname)) 30 + } 31 + 32 + func (s Server) RepoDir() string { 33 + return path.Join(s.DataDir, "repos") 27 34 } 28 35 29 36 type Secrets struct {
+59 -18
spindle/db/db.go
··· 1 1 package db 2 2 3 3 import ( 4 + "context" 4 5 "database/sql" 5 6 "strings" 6 7 8 + "github.com/bluesky-social/indigo/atproto/syntax" 7 9 _ "github.com/mattn/go-sqlite3" 10 + "tangled.org/core/log" 11 + "tangled.org/core/orm" 8 12 ) 9 13 10 14 type DB struct { 11 15 *sql.DB 12 16 } 13 17 14 - func Make(dbPath string) (*DB, error) { 18 + func Make(ctx context.Context, dbPath string) (*DB, error) { 15 19 // https://github.com/mattn/go-sqlite3#connection-string 16 20 opts := []string{ 17 21 "_foreign_keys=1", ··· 19 23 "_synchronous=NORMAL", 20 24 "_auto_vacuum=incremental", 21 25 } 26 + 27 + logger := log.FromContext(ctx) 28 + logger = log.SubLogger(logger, "db") 22 29 23 30 db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&")) 24 31 if err != nil { 25 32 return nil, err 26 33 } 27 34 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. 35 + conn, err := db.Conn(ctx) 36 + if err != nil { 37 + return nil, err 38 + } 39 + defer conn.Close() 31 40 32 41 _, err = db.Exec(` 33 42 create table if not exists _jetstream ( ··· 76 85 return nil, err 77 86 } 78 87 79 - return &DB{db}, nil 80 - } 88 + // run migrations 89 + 90 + // NOTE: this won't migrate existing records 91 + // they will be fetched again with tap instead 92 + orm.RunMigration(conn, logger, "add-rkey-to-repos", func(tx *sql.Tx) error { 93 + // archive legacy repos (just in case) 94 + _, err = tx.Exec(`alter table repos rename to repos_old`) 95 + if err != nil { 96 + return err 97 + } 98 + 99 + _, err := tx.Exec(` 100 + create table repos_new ( 101 + -- identifiers 102 + id integer primary key autoincrement, 103 + did text not null, 104 + rkey text not null, 105 + at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.repo' || '/' || rkey) stored, 106 + 107 + name text not null, 108 + knot text not null, 109 + 110 + addedAt text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 111 + unique(did, rkey) 112 + ); 113 + `) 114 + if err != nil { 115 + return err 116 + } 117 + 118 + return nil 119 + }) 81 120 82 - func (d *DB) SaveLastTimeUs(lastTimeUs int64) error { 83 - _, err := d.Exec(` 84 - insert into _jetstream (id, last_time_us) 85 - values (1, ?) 86 - on conflict(id) do update set last_time_us = excluded.last_time_us 87 - `, lastTimeUs) 88 - return err 121 + return &DB{db}, nil 89 122 } 90 123 91 - func (d *DB) GetLastTimeUs() (int64, error) { 92 - var lastTimeUs int64 93 - row := d.QueryRow(`select last_time_us from _jetstream where id = 1;`) 94 - err := row.Scan(&lastTimeUs) 95 - return lastTimeUs, err 124 + func (d *DB) IsKnownDid(did syntax.DID) (bool, error) { 125 + // is spindle member / repo collaborator 126 + var exists bool 127 + err := d.QueryRow( 128 + `select exists ( 129 + select 1 from repo_collaborators where did = ? 130 + union all 131 + select 1 from spindle_members where did = ? 132 + )`, 133 + did, 134 + did, 135 + ).Scan(&exists) 136 + return exists, err 96 137 }
+6 -18
spindle/db/events.go
··· 18 18 EventJson string `json:"event"` 19 19 } 20 20 21 - func (d *DB) InsertEvent(event Event, notifier *notifier.Notifier) error { 21 + func (d *DB) insertEvent(event Event, notifier *notifier.Notifier) error { 22 22 _, err := d.Exec( 23 23 `insert into events (rkey, nsid, event, created) values (?, ?, ?, ?)`, 24 24 event.Rkey, ··· 70 70 return evts, nil 71 71 } 72 72 73 - func (d *DB) CreateStatusEvent(rkey string, s tangled.PipelineStatus, n *notifier.Notifier) error { 74 - eventJson, err := json.Marshal(s) 75 - if err != nil { 76 - return err 77 - } 78 - 79 - event := Event{ 80 - Rkey: rkey, 81 - Nsid: tangled.PipelineStatusNSID, 82 - Created: time.Now().UnixNano(), 83 - EventJson: string(eventJson), 84 - } 85 - 86 - return d.InsertEvent(event, n) 87 - } 88 - 89 73 func (d *DB) createStatusEvent( 90 74 workflowId models.WorkflowId, 91 75 statusKind models.StatusKind, ··· 116 100 EventJson: string(eventJson), 117 101 } 118 102 119 - return d.InsertEvent(event, n) 103 + return d.insertEvent(event, n) 120 104 121 105 } 122 106 ··· 164 148 165 149 func (d *DB) StatusFailed(workflowId models.WorkflowId, workflowError string, exitCode int64, n *notifier.Notifier) error { 166 150 return d.createStatusEvent(workflowId, models.StatusKindFailed, &workflowError, &exitCode, n) 151 + } 152 + 153 + func (d *DB) StatusCancelled(workflowId models.WorkflowId, workflowError string, exitCode int64, n *notifier.Notifier) error { 154 + return d.createStatusEvent(workflowId, models.StatusKindCancelled, &workflowError, &exitCode, n) 167 155 } 168 156 169 157 func (d *DB) StatusSuccess(workflowId models.WorkflowId, n *notifier.Notifier) error {
-44
spindle/db/known_dids.go
··· 1 - package db 2 - 3 - func (d *DB) AddDid(did string) error { 4 - _, err := d.Exec(`insert or ignore into known_dids (did) values (?)`, did) 5 - return err 6 - } 7 - 8 - func (d *DB) RemoveDid(did string) error { 9 - _, err := d.Exec(`delete from known_dids where did = ?`, did) 10 - return err 11 - } 12 - 13 - func (d *DB) GetAllDids() ([]string, error) { 14 - var dids []string 15 - 16 - rows, err := d.Query(`select did from known_dids`) 17 - if err != nil { 18 - return nil, err 19 - } 20 - defer rows.Close() 21 - 22 - for rows.Next() { 23 - var did string 24 - if err := rows.Scan(&did); err != nil { 25 - return nil, err 26 - } 27 - dids = append(dids, did) 28 - } 29 - 30 - if err := rows.Err(); err != nil { 31 - return nil, err 32 - } 33 - 34 - return dids, nil 35 - } 36 - 37 - func (d *DB) HasKnownDids() bool { 38 - var count int 39 - err := d.QueryRow(`select count(*) from known_dids`).Scan(&count) 40 - if err != nil { 41 - return false 42 - } 43 - return count > 0 44 - }
+121 -11
spindle/db/repos.go
··· 1 1 package db 2 2 3 + import "github.com/bluesky-social/indigo/atproto/syntax" 4 + 3 5 type Repo struct { 4 - Knot string 5 - Owner string 6 - Name string 6 + Did syntax.DID 7 + Rkey syntax.RecordKey 8 + Name string 9 + Knot string 10 + } 11 + 12 + type RepoCollaborator struct { 13 + Did syntax.DID 14 + Rkey syntax.RecordKey 15 + Repo syntax.ATURI 16 + Subject syntax.DID 7 17 } 8 18 9 - func (d *DB) AddRepo(knot, owner, name string) error { 10 - _, err := d.Exec(`insert or ignore into repos (knot, owner, name) values (?, ?, ?)`, knot, owner, name) 19 + func (d *DB) PutRepo(repo *Repo) error { 20 + _, err := d.Exec( 21 + `insert or ignore into repos (did, rkey, name, knot) 22 + values (?, ?, ?, ?) 23 + on conflict(did, rkey) do update set 24 + name = excluded.name 25 + knot = excluded.knot`, 26 + repo.Did, 27 + repo.Rkey, 28 + repo.Name, 29 + repo.Knot, 30 + ) 31 + return err 32 + } 33 + 34 + func (d *DB) DeleteRepo(did syntax.DID, rkey syntax.RecordKey) error { 35 + _, err := d.Exec( 36 + `delete from repos where did = ? and rkey = ?`, 37 + did, 38 + rkey, 39 + ) 11 40 return err 12 41 } 13 42 ··· 16 45 if err != nil { 17 46 return nil, err 18 47 } 48 + defer rows.Close() 19 49 20 50 var knots []string 21 51 for rows.Next() { ··· 33 63 return knots, nil 34 64 } 35 65 36 - func (d *DB) GetRepo(knot, owner, name string) (*Repo, error) { 66 + func (d *DB) GetRepo(did syntax.DID, rkey syntax.RecordKey) (*Repo, error) { 37 67 var repo Repo 38 - 39 - query := "select knot, owner, name from repos where knot = ? and owner = ? and name = ?" 40 - err := d.DB.QueryRow(query, knot, owner, name). 41 - Scan(&repo.Knot, &repo.Owner, &repo.Name) 42 - 68 + err := d.DB.QueryRow( 69 + `select 70 + did, 71 + rkey, 72 + name, 73 + knot 74 + from repos where did = ? and rkey = ?`, 75 + did, 76 + rkey, 77 + ).Scan( 78 + &repo.Did, 79 + &repo.Rkey, 80 + &repo.Name, 81 + &repo.Knot, 82 + ) 43 83 if err != nil { 44 84 return nil, err 45 85 } 86 + return &repo, nil 87 + } 46 88 89 + func (d *DB) GetRepoWithName(did syntax.DID, name string) (*Repo, error) { 90 + var repo Repo 91 + err := d.DB.QueryRow( 92 + `select 93 + did, 94 + rkey, 95 + name, 96 + knot 97 + from repos where did = ? and name = ?`, 98 + did, 99 + name, 100 + ).Scan( 101 + &repo.Did, 102 + &repo.Rkey, 103 + &repo.Name, 104 + &repo.Knot, 105 + ) 106 + if err != nil { 107 + return nil, err 108 + } 47 109 return &repo, nil 48 110 } 111 + 112 + func (d *DB) PutRepoCollaborator(collaborator *RepoCollaborator) error { 113 + _, err := d.Exec( 114 + `insert into repo_collaborators (did, rkey, repo, subject) 115 + values (?, ?, ?, ?) 116 + on conflict(did, rkey) do update set 117 + repo = excluded.repo 118 + subject = excluded.subject`, 119 + collaborator.Did, 120 + collaborator.Rkey, 121 + collaborator.Repo, 122 + collaborator.Subject, 123 + ) 124 + return err 125 + } 126 + 127 + func (d *DB) RemoveRepoCollaborator(did syntax.DID, rkey syntax.RecordKey) error { 128 + _, err := d.Exec( 129 + `delete from repo_collaborators where did = ? and rkey = ?`, 130 + did, 131 + rkey, 132 + ) 133 + return err 134 + } 135 + 136 + func (d *DB) GetRepoCollaborator(did syntax.DID, rkey syntax.RecordKey) (*RepoCollaborator, error) { 137 + var collaborator RepoCollaborator 138 + err := d.DB.QueryRow( 139 + `select 140 + did, 141 + rkey, 142 + repo, 143 + subject 144 + from repo_collaborators 145 + where did = ? and rkey = ?`, 146 + did, 147 + rkey, 148 + ).Scan( 149 + &collaborator.Did, 150 + &collaborator.Rkey, 151 + &collaborator.Repo, 152 + &collaborator.Subject, 153 + ) 154 + if err != nil { 155 + return nil, err 156 + } 157 + return &collaborator, nil 158 + }
+22 -21
spindle/engine/engine.go
··· 3 3 import ( 4 4 "context" 5 5 "errors" 6 - "fmt" 7 6 "log/slog" 7 + "sync" 8 8 9 9 securejoin "github.com/cyphar/filepath-securejoin" 10 - "golang.org/x/sync/errgroup" 11 10 "tangled.org/core/notifier" 12 11 "tangled.org/core/spindle/config" 13 12 "tangled.org/core/spindle/db" ··· 31 30 } 32 31 } 33 32 34 - eg, ctx := errgroup.WithContext(ctx) 33 + var wg sync.WaitGroup 35 34 for eng, wfs := range pipeline.Workflows { 36 35 workflowTimeout := eng.WorkflowTimeout() 37 36 l.Info("using workflow timeout", "timeout", workflowTimeout) 38 37 39 38 for _, w := range wfs { 40 - eg.Go(func() error { 39 + wg.Add(1) 40 + go func() { 41 + defer wg.Done() 42 + 41 43 wid := models.WorkflowId{ 42 44 PipelineId: pipelineId, 43 45 Name: w.Name, ··· 45 47 46 48 err := db.StatusRunning(wid, n) 47 49 if err != nil { 48 - return err 50 + l.Error("failed to set workflow status to running", "wid", wid, "err", err) 51 + return 49 52 } 50 53 51 54 err = eng.SetupWorkflow(ctx, wid, &w) ··· 61 64 62 65 dbErr := db.StatusFailed(wid, err.Error(), -1, n) 63 66 if dbErr != nil { 64 - return dbErr 67 + l.Error("failed to set workflow status to failed", "wid", wid, "err", dbErr) 65 68 } 66 - return err 69 + return 67 70 } 68 71 defer eng.DestroyWorkflow(ctx, wid) 69 72 70 - wfLogger, err := models.NewWorkflowLogger(cfg.Server.LogDir, wid) 73 + secretValues := make([]string, len(allSecrets)) 74 + for i, s := range allSecrets { 75 + secretValues[i] = s.Value 76 + } 77 + wfLogger, err := models.NewWorkflowLogger(cfg.Server.LogDir, wid, secretValues) 71 78 if err != nil { 72 79 l.Warn("failed to setup step logger; logs will not be persisted", "error", err) 73 80 wfLogger = nil ··· 99 106 if errors.Is(err, ErrTimedOut) { 100 107 dbErr := db.StatusTimeout(wid, n) 101 108 if dbErr != nil { 102 - return dbErr 109 + l.Error("failed to set workflow status to timeout", "wid", wid, "err", dbErr) 103 110 } 104 111 } else { 105 112 dbErr := db.StatusFailed(wid, err.Error(), -1, n) 106 113 if dbErr != nil { 107 - return dbErr 114 + l.Error("failed to set workflow status to failed", "wid", wid, "err", dbErr) 108 115 } 109 116 } 110 - 111 - return fmt.Errorf("starting steps image: %w", err) 117 + return 112 118 } 113 119 } 114 120 115 121 err = db.StatusSuccess(wid, n) 116 122 if err != nil { 117 - return err 123 + l.Error("failed to set workflow status to success", "wid", wid, "err", err) 118 124 } 119 - 120 - return nil 121 - }) 125 + }() 122 126 } 123 127 } 124 128 125 - if err := eg.Wait(); err != nil { 126 - l.Error("failed to run one or more workflows", "err", err) 127 - } else { 128 - l.Info("successfully ran full pipeline") 129 - } 129 + wg.Wait() 130 + l.Info("all workflows completed") 130 131 }
+33 -18
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{} ··· 180 179 return err 181 180 } 182 181 e.registerCleanup(wid, func(ctx context.Context) error { 183 - return e.docker.NetworkRemove(ctx, networkName(wid)) 182 + err := e.docker.NetworkRemove(ctx, networkName(wid)) 183 + if err != nil { 184 + return fmt.Errorf("removing network: %w", err) 185 + } 186 + return nil 184 187 }) 185 188 186 189 addl := wf.Data.(addlFields) ··· 230 233 return fmt.Errorf("creating container: %w", err) 231 234 } 232 235 e.registerCleanup(wid, func(ctx context.Context) error { 233 - err = e.docker.ContainerStop(ctx, resp.ID, container.StopOptions{}) 236 + err := e.docker.ContainerStop(ctx, resp.ID, container.StopOptions{}) 234 237 if err != nil { 235 - return err 238 + return fmt.Errorf("stopping container: %w", err) 236 239 } 237 240 238 - return e.docker.ContainerRemove(ctx, resp.ID, container.RemoveOptions{ 241 + err = e.docker.ContainerRemove(ctx, resp.ID, container.RemoveOptions{ 239 242 RemoveVolumes: true, 240 243 RemoveLinks: false, 241 244 Force: false, 242 245 }) 246 + if err != nil { 247 + return fmt.Errorf("removing container: %w", err) 248 + } 249 + return nil 243 250 }) 244 251 245 252 err = e.docker.ContainerStart(ctx, resp.ID, container.StartOptions{}) ··· 288 295 289 296 func (e *Engine) RunStep(ctx context.Context, wid models.WorkflowId, w *models.Workflow, idx int, secrets []secrets.UnlockedSecret, wfLogger *models.WorkflowLogger) error { 290 297 addl := w.Data.(addlFields) 291 - workflowEnvs := ConstructEnvs(addl.env) 298 + workflowEnvs := ConstructEnvs(w.Environment) 292 299 // TODO(winter): should SetupWorkflow also have secret access? 293 300 // IMO yes, but probably worth thinking on. 294 301 for _, s := range secrets { 295 302 workflowEnvs.AddEnv(s.Key, s.Value) 296 303 } 297 304 298 - step := w.Steps[idx].(Step) 305 + step := w.Steps[idx] 299 306 300 307 select { 301 308 case <-ctx.Done(): ··· 304 311 } 305 312 306 313 envs := append(EnvVars(nil), workflowEnvs...) 307 - for k, v := range step.environment { 308 - envs.AddEnv(k, v) 314 + if nixStep, ok := step.(Step); ok { 315 + for k, v := range nixStep.environment { 316 + envs.AddEnv(k, v) 317 + } 309 318 } 310 319 envs.AddEnv("HOME", homeDir) 311 320 312 321 mkExecResp, err := e.docker.ContainerExecCreate(ctx, addl.container, container.ExecOptions{ 313 - Cmd: []string{"bash", "-c", step.command}, 322 + Cmd: []string{"bash", "-c", step.Command()}, 314 323 AttachStdout: true, 315 324 AttachStderr: true, 316 325 Env: envs, ··· 333 342 // Docker doesn't provide an API to kill an exec run 334 343 // (sure, we could grab the PID and kill it ourselves, 335 344 // but that's wasted effort) 336 - e.l.Warn("step timed out", "step", step.Name) 345 + e.l.Warn("step timed out", "step", step.Name()) 337 346 338 347 <-tailDone 339 348 ··· 393 402 } 394 403 395 404 func (e *Engine) DestroyWorkflow(ctx context.Context, wid models.WorkflowId) error { 396 - e.cleanupMu.Lock() 397 - key := wid.String() 398 - 399 - fns := e.cleanup[key] 400 - delete(e.cleanup, key) 401 - e.cleanupMu.Unlock() 405 + fns := e.drainCleanups(wid) 402 406 403 407 for _, fn := range fns { 404 408 if err := fn(ctx); err != nil { ··· 414 418 415 419 key := wid.String() 416 420 e.cleanup[key] = append(e.cleanup[key], fn) 421 + } 422 + 423 + func (e *Engine) drainCleanups(wid models.WorkflowId) []cleanupFunc { 424 + e.cleanupMu.Lock() 425 + key := wid.String() 426 + 427 + fns := e.cleanup[key] 428 + delete(e.cleanup, key) 429 + e.cleanupMu.Unlock() 430 + 431 + return fns 417 432 } 418 433 419 434 func networkName(wid models.WorkflowId) string {
-300
spindle/ingester.go
··· 1 - package spindle 2 - 3 - import ( 4 - "context" 5 - "encoding/json" 6 - "errors" 7 - "fmt" 8 - "time" 9 - 10 - "tangled.org/core/api/tangled" 11 - "tangled.org/core/eventconsumer" 12 - "tangled.org/core/rbac" 13 - "tangled.org/core/spindle/db" 14 - 15 - comatproto "github.com/bluesky-social/indigo/api/atproto" 16 - "github.com/bluesky-social/indigo/atproto/identity" 17 - "github.com/bluesky-social/indigo/atproto/syntax" 18 - "github.com/bluesky-social/indigo/xrpc" 19 - "github.com/bluesky-social/jetstream/pkg/models" 20 - securejoin "github.com/cyphar/filepath-securejoin" 21 - ) 22 - 23 - type Ingester func(ctx context.Context, e *models.Event) error 24 - 25 - func (s *Spindle) ingest() Ingester { 26 - return func(ctx context.Context, e *models.Event) error { 27 - var err error 28 - defer func() { 29 - eventTime := e.TimeUS 30 - lastTimeUs := eventTime + 1 31 - if err := s.db.SaveLastTimeUs(lastTimeUs); err != nil { 32 - err = fmt.Errorf("(deferred) failed to save last time us: %w", err) 33 - } 34 - }() 35 - 36 - if e.Kind != models.EventKindCommit { 37 - return nil 38 - } 39 - 40 - switch e.Commit.Collection { 41 - case tangled.SpindleMemberNSID: 42 - err = s.ingestMember(ctx, e) 43 - case tangled.RepoNSID: 44 - err = s.ingestRepo(ctx, e) 45 - case tangled.RepoCollaboratorNSID: 46 - err = s.ingestCollaborator(ctx, e) 47 - } 48 - 49 - if err != nil { 50 - s.l.Debug("failed to process message", "nsid", e.Commit.Collection, "err", err) 51 - } 52 - 53 - return nil 54 - } 55 - } 56 - 57 - func (s *Spindle) ingestMember(_ context.Context, e *models.Event) error { 58 - var err error 59 - did := e.Did 60 - rkey := e.Commit.RKey 61 - 62 - l := s.l.With("component", "ingester", "record", tangled.SpindleMemberNSID) 63 - 64 - switch e.Commit.Operation { 65 - case models.CommitOperationCreate, models.CommitOperationUpdate: 66 - raw := e.Commit.Record 67 - record := tangled.SpindleMember{} 68 - err = json.Unmarshal(raw, &record) 69 - if err != nil { 70 - l.Error("invalid record", "error", err) 71 - return err 72 - } 73 - 74 - domain := s.cfg.Server.Hostname 75 - recordInstance := record.Instance 76 - 77 - if recordInstance != domain { 78 - l.Error("domain mismatch", "domain", recordInstance, "expected", domain) 79 - return fmt.Errorf("domain mismatch: %s != %s", record.Instance, domain) 80 - } 81 - 82 - ok, err := s.e.IsSpindleInviteAllowed(did, rbacDomain) 83 - if err != nil || !ok { 84 - l.Error("failed to add member", "did", did, "error", err) 85 - return fmt.Errorf("failed to enforce permissions: %w", err) 86 - } 87 - 88 - if err := db.AddSpindleMember(s.db, db.SpindleMember{ 89 - Did: syntax.DID(did), 90 - Rkey: rkey, 91 - Instance: recordInstance, 92 - Subject: syntax.DID(record.Subject), 93 - Created: time.Now(), 94 - }); err != nil { 95 - l.Error("failed to add member", "error", err) 96 - return fmt.Errorf("failed to add member: %w", err) 97 - } 98 - 99 - if err := s.e.AddSpindleMember(rbacDomain, record.Subject); err != nil { 100 - l.Error("failed to add member", "error", err) 101 - return fmt.Errorf("failed to add member: %w", err) 102 - } 103 - l.Info("added member from firehose", "member", record.Subject) 104 - 105 - if err := s.db.AddDid(record.Subject); err != nil { 106 - l.Error("failed to add did", "error", err) 107 - return fmt.Errorf("failed to add did: %w", err) 108 - } 109 - s.jc.AddDid(record.Subject) 110 - 111 - return nil 112 - 113 - case models.CommitOperationDelete: 114 - record, err := db.GetSpindleMember(s.db, did, rkey) 115 - if err != nil { 116 - l.Error("failed to find member", "error", err) 117 - return fmt.Errorf("failed to find member: %w", err) 118 - } 119 - 120 - if err := db.RemoveSpindleMember(s.db, did, rkey); err != nil { 121 - l.Error("failed to remove member", "error", err) 122 - return fmt.Errorf("failed to remove member: %w", err) 123 - } 124 - 125 - if err := s.e.RemoveSpindleMember(rbacDomain, record.Subject.String()); err != nil { 126 - l.Error("failed to add member", "error", err) 127 - return fmt.Errorf("failed to add member: %w", err) 128 - } 129 - l.Info("added member from firehose", "member", record.Subject) 130 - 131 - if err := s.db.RemoveDid(record.Subject.String()); err != nil { 132 - l.Error("failed to add did", "error", err) 133 - return fmt.Errorf("failed to add did: %w", err) 134 - } 135 - s.jc.RemoveDid(record.Subject.String()) 136 - 137 - } 138 - return nil 139 - } 140 - 141 - func (s *Spindle) ingestRepo(ctx context.Context, e *models.Event) error { 142 - var err error 143 - did := e.Did 144 - 145 - l := s.l.With("component", "ingester", "record", tangled.RepoNSID) 146 - 147 - l.Info("ingesting repo record", "did", did) 148 - 149 - switch e.Commit.Operation { 150 - case models.CommitOperationCreate, models.CommitOperationUpdate: 151 - raw := e.Commit.Record 152 - record := tangled.Repo{} 153 - err = json.Unmarshal(raw, &record) 154 - if err != nil { 155 - l.Error("invalid record", "error", err) 156 - return err 157 - } 158 - 159 - domain := s.cfg.Server.Hostname 160 - 161 - // no spindle configured for this repo 162 - if record.Spindle == nil { 163 - l.Info("no spindle configured", "name", record.Name) 164 - return nil 165 - } 166 - 167 - // this repo did not want this spindle 168 - if *record.Spindle != domain { 169 - l.Info("different spindle configured", "name", record.Name, "spindle", *record.Spindle, "domain", domain) 170 - return nil 171 - } 172 - 173 - // add this repo to the watch list 174 - if err := s.db.AddRepo(record.Knot, did, record.Name); err != nil { 175 - l.Error("failed to add repo", "error", err) 176 - return fmt.Errorf("failed to add repo: %w", err) 177 - } 178 - 179 - didSlashRepo, err := securejoin.SecureJoin(did, record.Name) 180 - if err != nil { 181 - return err 182 - } 183 - 184 - // add repo to rbac 185 - if err := s.e.AddRepo(did, rbac.ThisServer, didSlashRepo); err != nil { 186 - l.Error("failed to add repo to enforcer", "error", err) 187 - return fmt.Errorf("failed to add repo: %w", err) 188 - } 189 - 190 - // add collaborators to rbac 191 - owner, err := s.res.ResolveIdent(ctx, did) 192 - if err != nil || owner.Handle.IsInvalidHandle() { 193 - return err 194 - } 195 - if err := s.fetchAndAddCollaborators(ctx, owner, didSlashRepo); err != nil { 196 - return err 197 - } 198 - 199 - // add this knot to the event consumer 200 - src := eventconsumer.NewKnotSource(record.Knot) 201 - s.ks.AddSource(context.Background(), src) 202 - 203 - return nil 204 - 205 - } 206 - return nil 207 - } 208 - 209 - func (s *Spindle) ingestCollaborator(ctx context.Context, e *models.Event) error { 210 - var err error 211 - 212 - l := s.l.With("component", "ingester", "record", tangled.RepoCollaboratorNSID, "did", e.Did) 213 - 214 - l.Info("ingesting collaborator record") 215 - 216 - switch e.Commit.Operation { 217 - case models.CommitOperationCreate, models.CommitOperationUpdate: 218 - raw := e.Commit.Record 219 - record := tangled.RepoCollaborator{} 220 - err = json.Unmarshal(raw, &record) 221 - if err != nil { 222 - l.Error("invalid record", "error", err) 223 - return err 224 - } 225 - 226 - subjectId, err := s.res.ResolveIdent(ctx, record.Subject) 227 - if err != nil || subjectId.Handle.IsInvalidHandle() { 228 - return err 229 - } 230 - 231 - repoAt, err := syntax.ParseATURI(record.Repo) 232 - if err != nil { 233 - l.Info("rejecting record, invalid repoAt", "repoAt", record.Repo) 234 - return nil 235 - } 236 - 237 - // TODO: get rid of this entirely 238 - // resolve this aturi to extract the repo record 239 - owner, err := s.res.ResolveIdent(ctx, repoAt.Authority().String()) 240 - if err != nil || owner.Handle.IsInvalidHandle() { 241 - return fmt.Errorf("failed to resolve handle: %w", err) 242 - } 243 - 244 - xrpcc := xrpc.Client{ 245 - Host: owner.PDSEndpoint(), 246 - } 247 - 248 - resp, err := comatproto.RepoGetRecord(ctx, &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 249 - if err != nil { 250 - return err 251 - } 252 - 253 - repo := resp.Value.Val.(*tangled.Repo) 254 - didSlashRepo, _ := securejoin.SecureJoin(owner.DID.String(), repo.Name) 255 - 256 - // check perms for this user 257 - if ok, err := s.e.IsCollaboratorInviteAllowed(owner.DID.String(), rbac.ThisServer, didSlashRepo); !ok || err != nil { 258 - return fmt.Errorf("insufficient permissions: %w", err) 259 - } 260 - 261 - // add collaborator to rbac 262 - if err := s.e.AddCollaborator(record.Subject, rbac.ThisServer, didSlashRepo); err != nil { 263 - l.Error("failed to add repo to enforcer", "error", err) 264 - return fmt.Errorf("failed to add repo: %w", err) 265 - } 266 - 267 - return nil 268 - } 269 - return nil 270 - } 271 - 272 - func (s *Spindle) fetchAndAddCollaborators(ctx context.Context, owner *identity.Identity, didSlashRepo string) error { 273 - l := s.l.With("component", "ingester", "handler", "fetchAndAddCollaborators") 274 - 275 - l.Info("fetching and adding existing collaborators") 276 - 277 - xrpcc := xrpc.Client{ 278 - Host: owner.PDSEndpoint(), 279 - } 280 - 281 - resp, err := comatproto.RepoListRecords(ctx, &xrpcc, tangled.RepoCollaboratorNSID, "", 50, owner.DID.String(), false) 282 - if err != nil { 283 - return err 284 - } 285 - 286 - var errs error 287 - for _, r := range resp.Records { 288 - if r == nil { 289 - continue 290 - } 291 - record := r.Value.Val.(*tangled.RepoCollaborator) 292 - 293 - if err := s.e.AddCollaborator(record.Subject, rbac.ThisServer, didSlashRepo); err != nil { 294 - l.Error("failed to add repo to enforcer", "error", err) 295 - errors.Join(errs, fmt.Errorf("failed to add repo: %w", err)) 296 - } 297 - } 298 - 299 - return errs 300 - }
+6 -7
spindle/models/clone.go
··· 55 55 } 56 56 } 57 57 58 - repoURL := buildRepoURL(tr, dev) 58 + repoURL := BuildRepoURL(tr.Repo, dev) 59 59 60 60 var cloneOpts tangled.Pipeline_CloneOpts 61 61 if twf.Clone != nil { ··· 101 101 } 102 102 } 103 103 104 - // buildRepoURL constructs the repository URL from trigger metadata 105 - func buildRepoURL(tr tangled.Pipeline_TriggerMetadata, devMode bool) string { 106 - if tr.Repo == nil { 104 + // BuildRepoURL constructs the repository URL from repo metadata. 105 + func BuildRepoURL(repo *tangled.Pipeline_TriggerRepo, devMode bool) string { 106 + if repo == nil { 107 107 return "" 108 108 } 109 109 110 - // Determine protocol 111 110 scheme := "https://" 112 111 if devMode { 113 112 scheme = "http://" 114 113 } 115 114 116 115 // Get host from knot 117 - host := tr.Repo.Knot 116 + host := repo.Knot 118 117 119 118 // In dev mode, replace localhost with host.docker.internal for Docker networking 120 119 if devMode && strings.Contains(host, "localhost") { ··· 122 121 } 123 122 124 123 // Build URL: {scheme}{knot}/{did}/{repo} 125 - return fmt.Sprintf("%s%s/%s/%s", scheme, host, tr.Repo.Did, tr.Repo.Repo) 124 + return fmt.Sprintf("%s%s/%s/%s", scheme, host, repo.Did, repo.Repo) 126 125 } 127 126 128 127 // buildFetchArgs constructs the arguments for git fetch based on clone options
+6 -1
spindle/models/logger.go
··· 12 12 type WorkflowLogger struct { 13 13 file *os.File 14 14 encoder *json.Encoder 15 + mask *SecretMask 15 16 } 16 17 17 - func NewWorkflowLogger(baseDir string, wid WorkflowId) (*WorkflowLogger, error) { 18 + func NewWorkflowLogger(baseDir string, wid WorkflowId, secretValues []string) (*WorkflowLogger, error) { 18 19 path := LogFilePath(baseDir, wid) 19 20 20 21 file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) ··· 25 26 return &WorkflowLogger{ 26 27 file: file, 27 28 encoder: json.NewEncoder(file), 29 + mask: NewSecretMask(secretValues), 28 30 }, nil 29 31 } 30 32 ··· 62 64 63 65 func (w *dataWriter) Write(p []byte) (int, error) { 64 66 line := strings.TrimRight(string(p), "\r\n") 67 + if w.logger.mask != nil { 68 + line = w.logger.mask.Mask(line) 69 + } 65 70 entry := NewDataLogLine(w.idx, line, w.stream) 66 71 if err := w.logger.encoder.Encode(entry); err != nil { 67 72 return 0, err
+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.AtUri().String() 24 + 25 + // Repo info 26 + if tr.Repo != nil { 27 + env["TANGLED_REPO_KNOT"] = tr.Repo.Knot 28 + env["TANGLED_REPO_DID"] = tr.Repo.Did 29 + env["TANGLED_REPO_NAME"] = tr.Repo.Repo 30 + env["TANGLED_REPO_DEFAULT_BRANCH"] = tr.Repo.DefaultBranch 31 + env["TANGLED_REPO_URL"] = BuildRepoURL(tr.Repo, devMode) 32 + } 33 + 34 + switch workflow.TriggerKind(tr.Kind) { 35 + case workflow.TriggerKindPush: 36 + if tr.Push != nil { 37 + refName := plumbing.ReferenceName(tr.Push.Ref) 38 + refType := "branch" 39 + if refName.IsTag() { 40 + refType = "tag" 41 + } 42 + 43 + env["TANGLED_REF"] = tr.Push.Ref 44 + env["TANGLED_REF_NAME"] = refName.Short() 45 + env["TANGLED_REF_TYPE"] = refType 46 + env["TANGLED_SHA"] = tr.Push.NewSha 47 + env["TANGLED_COMMIT_SHA"] = tr.Push.NewSha 48 + } 49 + 50 + case workflow.TriggerKindPullRequest: 51 + if tr.PullRequest != nil { 52 + // For PRs, the "ref" is the source branch 53 + env["TANGLED_REF"] = "refs/heads/" + tr.PullRequest.SourceBranch 54 + env["TANGLED_REF_NAME"] = tr.PullRequest.SourceBranch 55 + env["TANGLED_REF_TYPE"] = "branch" 56 + env["TANGLED_SHA"] = tr.PullRequest.SourceSha 57 + env["TANGLED_COMMIT_SHA"] = tr.PullRequest.SourceSha 58 + 59 + // PR-specific variables 60 + env["TANGLED_PR_SOURCE_BRANCH"] = tr.PullRequest.SourceBranch 61 + env["TANGLED_PR_TARGET_BRANCH"] = tr.PullRequest.TargetBranch 62 + env["TANGLED_PR_SOURCE_SHA"] = tr.PullRequest.SourceSha 63 + env["TANGLED_PR_ACTION"] = tr.PullRequest.Action 64 + } 65 + 66 + case workflow.TriggerKindManual: 67 + // Manual triggers may not have ref/sha info 68 + // Include any manual inputs if present 69 + if tr.Manual != nil { 70 + for _, pair := range tr.Manual.Inputs { 71 + env["TANGLED_INPUT_"+strings.ToUpper(pair.Key)] = pair.Value 72 + } 73 + } 74 + } 75 + 76 + return env 77 + }
+260
spindle/models/pipeline_env_test.go
··· 1 + package models 2 + 3 + import ( 4 + "testing" 5 + 6 + "tangled.org/core/api/tangled" 7 + "tangled.org/core/workflow" 8 + ) 9 + 10 + func TestPipelineEnvVars_PushBranch(t *testing.T) { 11 + tr := &tangled.Pipeline_TriggerMetadata{ 12 + Kind: string(workflow.TriggerKindPush), 13 + Push: &tangled.Pipeline_PushTriggerData{ 14 + NewSha: "abc123def456", 15 + OldSha: "000000000000", 16 + Ref: "refs/heads/main", 17 + }, 18 + Repo: &tangled.Pipeline_TriggerRepo{ 19 + Knot: "example.com", 20 + Did: "did:plc:user123", 21 + Repo: "my-repo", 22 + DefaultBranch: "main", 23 + }, 24 + } 25 + id := PipelineId{ 26 + Knot: "example.com", 27 + Rkey: "123123", 28 + } 29 + env := PipelineEnvVars(tr, id, false) 30 + 31 + // Check standard CI variable 32 + if env["CI"] != "true" { 33 + t.Errorf("Expected CI='true', got '%s'", env["CI"]) 34 + } 35 + 36 + // Check ref variables 37 + if env["TANGLED_REF"] != "refs/heads/main" { 38 + t.Errorf("Expected TANGLED_REF='refs/heads/main', got '%s'", env["TANGLED_REF"]) 39 + } 40 + if env["TANGLED_REF_NAME"] != "main" { 41 + t.Errorf("Expected TANGLED_REF_NAME='main', got '%s'", env["TANGLED_REF_NAME"]) 42 + } 43 + if env["TANGLED_REF_TYPE"] != "branch" { 44 + t.Errorf("Expected TANGLED_REF_TYPE='branch', got '%s'", env["TANGLED_REF_TYPE"]) 45 + } 46 + 47 + // Check SHA variables 48 + if env["TANGLED_SHA"] != "abc123def456" { 49 + t.Errorf("Expected TANGLED_SHA='abc123def456', got '%s'", env["TANGLED_SHA"]) 50 + } 51 + if env["TANGLED_COMMIT_SHA"] != "abc123def456" { 52 + t.Errorf("Expected TANGLED_COMMIT_SHA='abc123def456', got '%s'", env["TANGLED_COMMIT_SHA"]) 53 + } 54 + 55 + // Check repo variables 56 + if env["TANGLED_REPO_KNOT"] != "example.com" { 57 + t.Errorf("Expected TANGLED_REPO_KNOT='example.com', got '%s'", env["TANGLED_REPO_KNOT"]) 58 + } 59 + if env["TANGLED_REPO_DID"] != "did:plc:user123" { 60 + t.Errorf("Expected TANGLED_REPO_DID='did:plc:user123', got '%s'", env["TANGLED_REPO_DID"]) 61 + } 62 + if env["TANGLED_REPO_NAME"] != "my-repo" { 63 + t.Errorf("Expected TANGLED_REPO_NAME='my-repo', got '%s'", env["TANGLED_REPO_NAME"]) 64 + } 65 + if env["TANGLED_REPO_DEFAULT_BRANCH"] != "main" { 66 + t.Errorf("Expected TANGLED_REPO_DEFAULT_BRANCH='main', got '%s'", env["TANGLED_REPO_DEFAULT_BRANCH"]) 67 + } 68 + if env["TANGLED_REPO_URL"] != "https://example.com/did:plc:user123/my-repo" { 69 + t.Errorf("Expected TANGLED_REPO_URL='https://example.com/did:plc:user123/my-repo', got '%s'", env["TANGLED_REPO_URL"]) 70 + } 71 + } 72 + 73 + func TestPipelineEnvVars_PushTag(t *testing.T) { 74 + tr := &tangled.Pipeline_TriggerMetadata{ 75 + Kind: string(workflow.TriggerKindPush), 76 + Push: &tangled.Pipeline_PushTriggerData{ 77 + NewSha: "abc123def456", 78 + OldSha: "000000000000", 79 + Ref: "refs/tags/v1.2.3", 80 + }, 81 + Repo: &tangled.Pipeline_TriggerRepo{ 82 + Knot: "example.com", 83 + Did: "did:plc:user123", 84 + Repo: "my-repo", 85 + }, 86 + } 87 + id := PipelineId{ 88 + Knot: "example.com", 89 + Rkey: "123123", 90 + } 91 + env := PipelineEnvVars(tr, id, false) 92 + 93 + if env["TANGLED_REF"] != "refs/tags/v1.2.3" { 94 + t.Errorf("Expected TANGLED_REF='refs/tags/v1.2.3', got '%s'", env["TANGLED_REF"]) 95 + } 96 + if env["TANGLED_REF_NAME"] != "v1.2.3" { 97 + t.Errorf("Expected TANGLED_REF_NAME='v1.2.3', got '%s'", env["TANGLED_REF_NAME"]) 98 + } 99 + if env["TANGLED_REF_TYPE"] != "tag" { 100 + t.Errorf("Expected TANGLED_REF_TYPE='tag', got '%s'", env["TANGLED_REF_TYPE"]) 101 + } 102 + } 103 + 104 + func TestPipelineEnvVars_PullRequest(t *testing.T) { 105 + tr := &tangled.Pipeline_TriggerMetadata{ 106 + Kind: string(workflow.TriggerKindPullRequest), 107 + PullRequest: &tangled.Pipeline_PullRequestTriggerData{ 108 + SourceBranch: "feature-branch", 109 + TargetBranch: "main", 110 + SourceSha: "pr-sha-789", 111 + Action: "opened", 112 + }, 113 + Repo: &tangled.Pipeline_TriggerRepo{ 114 + Knot: "example.com", 115 + Did: "did:plc:user123", 116 + Repo: "my-repo", 117 + }, 118 + } 119 + id := PipelineId{ 120 + Knot: "example.com", 121 + Rkey: "123123", 122 + } 123 + env := PipelineEnvVars(tr, id, false) 124 + 125 + // Check ref variables for PR 126 + if env["TANGLED_REF"] != "refs/heads/feature-branch" { 127 + t.Errorf("Expected TANGLED_REF='refs/heads/feature-branch', got '%s'", env["TANGLED_REF"]) 128 + } 129 + if env["TANGLED_REF_NAME"] != "feature-branch" { 130 + t.Errorf("Expected TANGLED_REF_NAME='feature-branch', got '%s'", env["TANGLED_REF_NAME"]) 131 + } 132 + if env["TANGLED_REF_TYPE"] != "branch" { 133 + t.Errorf("Expected TANGLED_REF_TYPE='branch', got '%s'", env["TANGLED_REF_TYPE"]) 134 + } 135 + 136 + // Check SHA variables 137 + if env["TANGLED_SHA"] != "pr-sha-789" { 138 + t.Errorf("Expected TANGLED_SHA='pr-sha-789', got '%s'", env["TANGLED_SHA"]) 139 + } 140 + if env["TANGLED_COMMIT_SHA"] != "pr-sha-789" { 141 + t.Errorf("Expected TANGLED_COMMIT_SHA='pr-sha-789', got '%s'", env["TANGLED_COMMIT_SHA"]) 142 + } 143 + 144 + // Check PR-specific variables 145 + if env["TANGLED_PR_SOURCE_BRANCH"] != "feature-branch" { 146 + t.Errorf("Expected TANGLED_PR_SOURCE_BRANCH='feature-branch', got '%s'", env["TANGLED_PR_SOURCE_BRANCH"]) 147 + } 148 + if env["TANGLED_PR_TARGET_BRANCH"] != "main" { 149 + t.Errorf("Expected TANGLED_PR_TARGET_BRANCH='main', got '%s'", env["TANGLED_PR_TARGET_BRANCH"]) 150 + } 151 + if env["TANGLED_PR_SOURCE_SHA"] != "pr-sha-789" { 152 + t.Errorf("Expected TANGLED_PR_SOURCE_SHA='pr-sha-789', got '%s'", env["TANGLED_PR_SOURCE_SHA"]) 153 + } 154 + if env["TANGLED_PR_ACTION"] != "opened" { 155 + t.Errorf("Expected TANGLED_PR_ACTION='opened', got '%s'", env["TANGLED_PR_ACTION"]) 156 + } 157 + } 158 + 159 + func TestPipelineEnvVars_ManualWithInputs(t *testing.T) { 160 + tr := &tangled.Pipeline_TriggerMetadata{ 161 + Kind: string(workflow.TriggerKindManual), 162 + Manual: &tangled.Pipeline_ManualTriggerData{ 163 + Inputs: []*tangled.Pipeline_Pair{ 164 + {Key: "version", Value: "1.0.0"}, 165 + {Key: "environment", Value: "production"}, 166 + }, 167 + }, 168 + Repo: &tangled.Pipeline_TriggerRepo{ 169 + Knot: "example.com", 170 + Did: "did:plc:user123", 171 + Repo: "my-repo", 172 + }, 173 + } 174 + id := PipelineId{ 175 + Knot: "example.com", 176 + Rkey: "123123", 177 + } 178 + env := PipelineEnvVars(tr, id, false) 179 + 180 + // Check manual input variables 181 + if env["TANGLED_INPUT_VERSION"] != "1.0.0" { 182 + t.Errorf("Expected TANGLED_INPUT_VERSION='1.0.0', got '%s'", env["TANGLED_INPUT_VERSION"]) 183 + } 184 + if env["TANGLED_INPUT_ENVIRONMENT"] != "production" { 185 + t.Errorf("Expected TANGLED_INPUT_ENVIRONMENT='production', got '%s'", env["TANGLED_INPUT_ENVIRONMENT"]) 186 + } 187 + 188 + // Manual triggers shouldn't have ref/sha variables 189 + if _, ok := env["TANGLED_REF"]; ok { 190 + t.Error("Manual trigger should not have TANGLED_REF") 191 + } 192 + if _, ok := env["TANGLED_SHA"]; ok { 193 + t.Error("Manual trigger should not have TANGLED_SHA") 194 + } 195 + } 196 + 197 + func TestPipelineEnvVars_DevMode(t *testing.T) { 198 + tr := &tangled.Pipeline_TriggerMetadata{ 199 + Kind: string(workflow.TriggerKindPush), 200 + Push: &tangled.Pipeline_PushTriggerData{ 201 + NewSha: "abc123", 202 + Ref: "refs/heads/main", 203 + }, 204 + Repo: &tangled.Pipeline_TriggerRepo{ 205 + Knot: "localhost:3000", 206 + Did: "did:plc:user123", 207 + Repo: "my-repo", 208 + }, 209 + } 210 + id := PipelineId{ 211 + Knot: "example.com", 212 + Rkey: "123123", 213 + } 214 + env := PipelineEnvVars(tr, id, true) 215 + 216 + // Dev mode should use http:// and replace localhost with host.docker.internal 217 + expectedURL := "http://host.docker.internal:3000/did:plc:user123/my-repo" 218 + if env["TANGLED_REPO_URL"] != expectedURL { 219 + t.Errorf("Expected TANGLED_REPO_URL='%s', got '%s'", expectedURL, env["TANGLED_REPO_URL"]) 220 + } 221 + } 222 + 223 + func TestPipelineEnvVars_NilTrigger(t *testing.T) { 224 + id := PipelineId{ 225 + Knot: "example.com", 226 + Rkey: "123123", 227 + } 228 + env := PipelineEnvVars(nil, id, false) 229 + 230 + if env != nil { 231 + t.Error("Expected nil env for nil trigger") 232 + } 233 + } 234 + 235 + func TestPipelineEnvVars_NilPushData(t *testing.T) { 236 + tr := &tangled.Pipeline_TriggerMetadata{ 237 + Kind: string(workflow.TriggerKindPush), 238 + Push: nil, 239 + Repo: &tangled.Pipeline_TriggerRepo{ 240 + Knot: "example.com", 241 + Did: "did:plc:user123", 242 + Repo: "my-repo", 243 + }, 244 + } 245 + id := PipelineId{ 246 + Knot: "example.com", 247 + Rkey: "123123", 248 + } 249 + env := PipelineEnvVars(tr, id, false) 250 + 251 + // Should still have repo variables 252 + if env["TANGLED_REPO_KNOT"] != "example.com" { 253 + t.Errorf("Expected TANGLED_REPO_KNOT='example.com', got '%s'", env["TANGLED_REPO_KNOT"]) 254 + } 255 + 256 + // Should not have ref/sha variables 257 + if _, ok := env["TANGLED_REF"]; ok { 258 + t.Error("Should not have TANGLED_REF when push data is nil") 259 + } 260 + }
+51
spindle/models/secret_mask.go
··· 1 + package models 2 + 3 + import ( 4 + "encoding/base64" 5 + "strings" 6 + ) 7 + 8 + // SecretMask replaces secret values in strings with "***". 9 + type SecretMask struct { 10 + replacer *strings.Replacer 11 + } 12 + 13 + // NewSecretMask creates a mask for the given secret values. 14 + // Also registers base64-encoded variants of each secret. 15 + func NewSecretMask(values []string) *SecretMask { 16 + var pairs []string 17 + 18 + for _, value := range values { 19 + if value == "" { 20 + continue 21 + } 22 + 23 + pairs = append(pairs, value, "***") 24 + 25 + b64 := base64.StdEncoding.EncodeToString([]byte(value)) 26 + if b64 != value { 27 + pairs = append(pairs, b64, "***") 28 + } 29 + 30 + b64NoPad := strings.TrimRight(b64, "=") 31 + if b64NoPad != b64 && b64NoPad != value { 32 + pairs = append(pairs, b64NoPad, "***") 33 + } 34 + } 35 + 36 + if len(pairs) == 0 { 37 + return nil 38 + } 39 + 40 + return &SecretMask{ 41 + replacer: strings.NewReplacer(pairs...), 42 + } 43 + } 44 + 45 + // Mask replaces all registered secret values with "***". 46 + func (m *SecretMask) Mask(input string) string { 47 + if m == nil || m.replacer == nil { 48 + return input 49 + } 50 + return m.replacer.Replace(input) 51 + }
+135
spindle/models/secret_mask_test.go
··· 1 + package models 2 + 3 + import ( 4 + "encoding/base64" 5 + "testing" 6 + ) 7 + 8 + func TestSecretMask_BasicMasking(t *testing.T) { 9 + mask := NewSecretMask([]string{"mysecret123"}) 10 + 11 + input := "The password is mysecret123 in this log" 12 + expected := "The password is *** in this log" 13 + 14 + result := mask.Mask(input) 15 + if result != expected { 16 + t.Errorf("expected %q, got %q", expected, result) 17 + } 18 + } 19 + 20 + func TestSecretMask_Base64Encoded(t *testing.T) { 21 + secret := "mysecret123" 22 + mask := NewSecretMask([]string{secret}) 23 + 24 + b64 := base64.StdEncoding.EncodeToString([]byte(secret)) 25 + input := "Encoded: " + b64 26 + expected := "Encoded: ***" 27 + 28 + result := mask.Mask(input) 29 + if result != expected { 30 + t.Errorf("expected %q, got %q", expected, result) 31 + } 32 + } 33 + 34 + func TestSecretMask_Base64NoPadding(t *testing.T) { 35 + // "test" encodes to "dGVzdA==" with padding 36 + secret := "test" 37 + mask := NewSecretMask([]string{secret}) 38 + 39 + b64NoPad := "dGVzdA" // base64 without padding 40 + input := "Token: " + b64NoPad 41 + expected := "Token: ***" 42 + 43 + result := mask.Mask(input) 44 + if result != expected { 45 + t.Errorf("expected %q, got %q", expected, result) 46 + } 47 + } 48 + 49 + func TestSecretMask_MultipleSecrets(t *testing.T) { 50 + mask := NewSecretMask([]string{"password1", "apikey123"}) 51 + 52 + input := "Using password1 and apikey123 for auth" 53 + expected := "Using *** and *** for auth" 54 + 55 + result := mask.Mask(input) 56 + if result != expected { 57 + t.Errorf("expected %q, got %q", expected, result) 58 + } 59 + } 60 + 61 + func TestSecretMask_MultipleOccurrences(t *testing.T) { 62 + mask := NewSecretMask([]string{"secret"}) 63 + 64 + input := "secret appears twice: secret" 65 + expected := "*** appears twice: ***" 66 + 67 + result := mask.Mask(input) 68 + if result != expected { 69 + t.Errorf("expected %q, got %q", expected, result) 70 + } 71 + } 72 + 73 + func TestSecretMask_ShortValues(t *testing.T) { 74 + mask := NewSecretMask([]string{"abc", "xy", ""}) 75 + 76 + if mask == nil { 77 + t.Fatal("expected non-nil mask") 78 + } 79 + 80 + input := "abc xy test" 81 + expected := "*** *** test" 82 + result := mask.Mask(input) 83 + if result != expected { 84 + t.Errorf("expected %q, got %q", expected, result) 85 + } 86 + } 87 + 88 + func TestSecretMask_NilMask(t *testing.T) { 89 + var mask *SecretMask 90 + 91 + input := "some input text" 92 + result := mask.Mask(input) 93 + if result != input { 94 + t.Errorf("expected %q, got %q", input, result) 95 + } 96 + } 97 + 98 + func TestSecretMask_EmptyInput(t *testing.T) { 99 + mask := NewSecretMask([]string{"secret"}) 100 + 101 + result := mask.Mask("") 102 + if result != "" { 103 + t.Errorf("expected empty string, got %q", result) 104 + } 105 + } 106 + 107 + func TestSecretMask_NoMatch(t *testing.T) { 108 + mask := NewSecretMask([]string{"secretvalue"}) 109 + 110 + input := "nothing to mask here" 111 + result := mask.Mask(input) 112 + if result != input { 113 + t.Errorf("expected %q, got %q", input, result) 114 + } 115 + } 116 + 117 + func TestSecretMask_EmptySecretsList(t *testing.T) { 118 + mask := NewSecretMask([]string{}) 119 + 120 + if mask != nil { 121 + t.Error("expected nil mask for empty secrets list") 122 + } 123 + } 124 + 125 + func TestSecretMask_EmptySecretsFiltered(t *testing.T) { 126 + mask := NewSecretMask([]string{"ab", "validpassword", "", "xyz"}) 127 + 128 + input := "Using validpassword here" 129 + expected := "Using *** here" 130 + 131 + result := mask.Mask(input) 132 + if result != expected { 133 + t.Errorf("expected %q, got %q", expected, result) 134 + } 135 + }
+144 -70
spindle/server.go
··· 1 1 package spindle 2 2 3 3 import ( 4 + "bytes" 4 5 "context" 5 6 _ "embed" 6 7 "encoding/json" 7 8 "fmt" 8 9 "log/slog" 10 + "maps" 9 11 "net/http" 12 + "os" 13 + "os/exec" 14 + "path" 15 + "strings" 10 16 17 + "github.com/bluesky-social/indigo/atproto/syntax" 11 18 "github.com/go-chi/chi/v5" 19 + "github.com/hashicorp/go-version" 12 20 "tangled.org/core/api/tangled" 13 21 "tangled.org/core/eventconsumer" 14 22 "tangled.org/core/eventconsumer/cursor" 15 23 "tangled.org/core/idresolver" 16 - "tangled.org/core/jetstream" 17 24 "tangled.org/core/log" 18 25 "tangled.org/core/notifier" 19 - "tangled.org/core/rbac" 26 + "tangled.org/core/rbac2" 20 27 "tangled.org/core/spindle/config" 21 28 "tangled.org/core/spindle/db" 22 29 "tangled.org/core/spindle/engine" ··· 25 32 "tangled.org/core/spindle/queue" 26 33 "tangled.org/core/spindle/secrets" 27 34 "tangled.org/core/spindle/xrpc" 35 + "tangled.org/core/tap" 28 36 "tangled.org/core/xrpc/serviceauth" 29 37 ) 30 38 31 39 //go:embed motd 32 40 var motd []byte 33 41 34 - const ( 35 - rbacDomain = "thisserver" 36 - ) 37 - 38 42 type Spindle struct { 39 - jc *jetstream.JetstreamClient 43 + tap *tap.Client 40 44 db *db.DB 41 - e *rbac.Enforcer 45 + e *rbac2.Enforcer 42 46 l *slog.Logger 43 47 n *notifier.Notifier 44 48 engs map[string]models.Engine ··· 53 57 func New(ctx context.Context, cfg *config.Config, engines map[string]models.Engine) (*Spindle, error) { 54 58 logger := log.FromContext(ctx) 55 59 56 - d, err := db.Make(cfg.Server.DBPath) 60 + if err := ensureGitVersion(); err != nil { 61 + return nil, fmt.Errorf("ensuring git version: %w", err) 62 + } 63 + 64 + d, err := db.Make(ctx, cfg.Server.DBPath) 57 65 if err != nil { 58 66 return nil, fmt.Errorf("failed to setup db: %w", err) 59 67 } 60 68 61 - e, err := rbac.NewEnforcer(cfg.Server.DBPath) 69 + e, err := rbac2.NewEnforcer(cfg.Server.DBPath) 62 70 if err != nil { 63 71 return nil, fmt.Errorf("failed to setup rbac enforcer: %w", err) 64 72 } 65 - e.E.EnableAutoSave(true) 66 73 67 74 n := notifier.New() 68 75 ··· 94 101 jq := queue.NewQueue(cfg.Server.QueueSize, cfg.Server.MaxJobCount) 95 102 logger.Info("initialized queue", "queueSize", cfg.Server.QueueSize, "numWorkers", cfg.Server.MaxJobCount) 96 103 97 - collections := []string{ 98 - tangled.SpindleMemberNSID, 99 - tangled.RepoNSID, 100 - tangled.RepoCollaboratorNSID, 101 - } 102 - jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, log.SubLogger(logger, "jetstream"), d, true, true) 103 - if err != nil { 104 - return nil, fmt.Errorf("failed to setup jetstream client: %w", err) 105 - } 106 - jc.AddDid(cfg.Server.Owner) 107 - 108 - // Check if the spindle knows about any Dids; 109 - dids, err := d.GetAllDids() 110 - if err != nil { 111 - return nil, fmt.Errorf("failed to get all dids: %w", err) 112 - } 113 - for _, d := range dids { 114 - jc.AddDid(d) 115 - } 104 + tap := tap.NewClient(cfg.Server.TapUrl, "") 116 105 117 106 resolver := idresolver.DefaultResolver(cfg.Server.PlcUrl) 118 107 119 108 spindle := &Spindle{ 120 - jc: jc, 109 + tap: &tap, 121 110 e: e, 122 111 db: d, 123 112 l: logger, ··· 129 118 vault: vault, 130 119 } 131 120 132 - err = e.AddSpindle(rbacDomain) 133 - if err != nil { 134 - return nil, fmt.Errorf("failed to set rbac domain: %w", err) 135 - } 136 - err = spindle.configureOwner() 121 + err = e.SetSpindleOwner(spindle.cfg.Server.Owner, spindle.cfg.Server.Did()) 137 122 if err != nil { 138 123 return nil, err 139 124 } ··· 142 127 cursorStore, err := cursor.NewSQLiteStore(cfg.Server.DBPath) 143 128 if err != nil { 144 129 return nil, fmt.Errorf("failed to setup sqlite3 cursor store: %w", err) 145 - } 146 - 147 - err = jc.StartJetstream(ctx, spindle.ingest()) 148 - if err != nil { 149 - return nil, fmt.Errorf("failed to start jetstream consumer: %w", err) 150 130 } 151 131 152 132 // for each incoming sh.tangled.pipeline, we execute ··· 196 176 } 197 177 198 178 // Enforcer returns the RBAC enforcer instance. 199 - func (s *Spindle) Enforcer() *rbac.Enforcer { 179 + func (s *Spindle) Enforcer() *rbac2.Enforcer { 200 180 return s.e 201 181 } 202 182 ··· 216 196 s.ks.Start(ctx) 217 197 }() 218 198 199 + go func() { 200 + s.l.Info("starting tap stream consumer") 201 + s.tap.Connect(ctx, &tap.SimpleIndexer{ 202 + EventHandler: s.processEvent, 203 + }) 204 + }() 205 + 219 206 s.l.Info("starting spindle server", "address", s.cfg.Server.ListenAddr) 220 207 return http.ListenAndServe(s.cfg.Server.ListenAddr, s.Router()) 221 208 } ··· 267 254 Config: s.cfg, 268 255 Resolver: s.res, 269 256 Vault: s.vault, 257 + Notifier: s.Notifier(), 270 258 ServiceAuth: serviceAuth, 271 259 } 272 260 ··· 274 262 } 275 263 276 264 func (s *Spindle) processPipeline(ctx context.Context, src eventconsumer.Source, msg eventconsumer.Message) error { 265 + l := log.FromContext(ctx).With("handler", "processKnotStream") 266 + l = l.With("src", src.Key(), "msg.Nsid", msg.Nsid, "msg.Rkey", msg.Rkey) 277 267 if msg.Nsid == tangled.PipelineNSID { 268 + return nil 278 269 tpl := tangled.Pipeline{} 279 270 err := json.Unmarshal(msg.EventJson, &tpl) 280 271 if err != nil { ··· 295 286 } 296 287 297 288 // filter by repos 298 - _, err = s.db.GetRepo( 299 - tpl.TriggerMetadata.Repo.Knot, 300 - tpl.TriggerMetadata.Repo.Did, 289 + _, err = s.db.GetRepoWithName( 290 + syntax.DID(tpl.TriggerMetadata.Repo.Did), 301 291 tpl.TriggerMetadata.Repo.Repo, 302 292 ) 303 293 if err != nil { 304 - return err 294 + return fmt.Errorf("failed to get repo: %w", err) 305 295 } 306 296 307 297 pipelineId := models.PipelineId{ ··· 310 300 } 311 301 312 302 workflows := make(map[models.Engine][]models.Workflow) 303 + 304 + // Build pipeline environment variables once for all workflows 305 + pipelineEnv := models.PipelineEnvVars(tpl.TriggerMetadata, pipelineId, s.cfg.Server.Dev) 313 306 314 307 for _, w := range tpl.Workflows { 315 308 if w != nil { ··· 319 312 Name: w.Name, 320 313 }, fmt.Sprintf("unknown engine %#v", w.Engine), -1, s.n) 321 314 if err != nil { 322 - return err 315 + return fmt.Errorf("db.StatusFailed: %w", err) 323 316 } 324 317 325 318 continue ··· 333 326 334 327 ewf, err := s.engs[w.Engine].InitWorkflow(*w, tpl) 335 328 if err != nil { 336 - return err 329 + return fmt.Errorf("init workflow: %w", err) 337 330 } 338 331 332 + // inject TANGLED_* env vars after InitWorkflow 333 + // This prevents user-defined env vars from overriding them 334 + if ewf.Environment == nil { 335 + ewf.Environment = make(map[string]string) 336 + } 337 + maps.Copy(ewf.Environment, pipelineEnv) 338 + 339 339 workflows[eng] = append(workflows[eng], *ewf) 340 340 341 341 err = s.db.StatusPending(models.WorkflowId{ ··· 343 343 Name: w.Name, 344 344 }, s.n) 345 345 if err != nil { 346 - return err 346 + return fmt.Errorf("db.StatusPending: %w", err) 347 347 } 348 348 } 349 349 } ··· 366 366 } else { 367 367 s.l.Error("failed to enqueue pipeline: queue is full") 368 368 } 369 + } else if msg.Nsid == tangled.GitRefUpdateNSID { 370 + event := tangled.GitRefUpdate{} 371 + if err := json.Unmarshal(msg.EventJson, &event); err != nil { 372 + l.Error("error unmarshalling", "err", err) 373 + return err 374 + } 375 + l = l.With("repoDid", event.RepoDid, "repoName", event.RepoName) 376 + 377 + // use event.RepoAt 378 + // sync git repos in {data}/repos/{did}/sh.tangled.repo/{rkey} 379 + // if it's nil, don't run pipeline. knot needs upgrade 380 + // we will leave sh.tangled.pipeline.trigger for backward compatibility 381 + 382 + // NOTE: we are blindly trusting the knot that it will return only repos it own 383 + repoCloneUri := s.newRepoCloneUrl(src.Key(), event.RepoDid, event.RepoName) 384 + repoPath := s.newRepoPath(event.RepoDid, event.RepoName) 385 + err := sparseSyncGitRepo(ctx, repoCloneUri, repoPath, event.NewSha) 386 + if err != nil { 387 + l.Error("failed to sync git repo", "err", err) 388 + return fmt.Errorf("sync git repo: %w", err) 389 + } 390 + l.Info("synced git repo") 391 + 392 + // TODO: plan the pipeline 369 393 } 370 394 371 395 return nil 372 396 } 373 397 374 - func (s *Spindle) configureOwner() error { 375 - cfgOwner := s.cfg.Server.Owner 398 + func (s *Spindle) newRepoPath(did, name string) string { 399 + return path.Join(s.cfg.Server.RepoDir(), did, name) 400 + } 376 401 377 - existing, err := s.e.GetSpindleUsersByRole("server:owner", rbacDomain) 402 + func (s *Spindle) newRepoCloneUrl(knot, did, name string) string { 403 + scheme := "https://" 404 + if s.cfg.Server.Dev { 405 + scheme = "http://" 406 + } 407 + return fmt.Sprintf("%s%s/%s/%s", scheme, knot, did, name) 408 + } 409 + 410 + const RequiredVersion = "2.49.0" 411 + 412 + func ensureGitVersion() error { 413 + v, err := gitVersion() 378 414 if err != nil { 379 - return err 415 + return fmt.Errorf("fetching git version: %w", err) 416 + } 417 + if v.LessThan(version.Must(version.NewVersion(RequiredVersion))) { 418 + return fmt.Errorf("installed git version %q is not supported, Spindle requires git version >= %q", v, RequiredVersion) 419 + } 420 + return nil 421 + } 422 + 423 + // TODO: move to "git" module shared between knot, appview & spindle 424 + func gitVersion() (*version.Version, error) { 425 + var buf bytes.Buffer 426 + cmd := exec.Command("git", "version") 427 + cmd.Stdout = &buf 428 + cmd.Stderr = os.Stderr 429 + err := cmd.Run() 430 + if err != nil { 431 + return nil, err 432 + } 433 + fields := strings.Fields(buf.String()) 434 + if len(fields) < 3 { 435 + return nil, fmt.Errorf("invalid git version: %s", buf) 380 436 } 381 437 382 - switch len(existing) { 383 - case 0: 384 - // no owner configured, continue 385 - case 1: 386 - // find existing owner 387 - existingOwner := existing[0] 438 + // version string is like: "git version 2.29.3" or "git version 2.29.3.windows.1" 439 + versionString := fields[2] 440 + if pos := strings.Index(versionString, "windows"); pos >= 1 { 441 + versionString = versionString[:pos-1] 442 + } 443 + return version.NewVersion(versionString) 444 + } 388 445 389 - // no ownership change, this is okay 390 - if existingOwner == s.cfg.Server.Owner { 391 - break 446 + func sparseSyncGitRepo(ctx context.Context, cloneUri, path, rev string) error { 447 + exist, err := isDir(path) 448 + if err != nil { 449 + return err 450 + } 451 + if !exist { 452 + if err := exec.Command("git", "clone", "--no-checkout", "--depth=1", "--filter=tree:0", "--revision="+rev, cloneUri, path).Run(); err != nil { 453 + return fmt.Errorf("git clone: %w", err) 454 + } 455 + if err := exec.Command("git", "-C", path, "sparse-checkout", "set", "--no-cone", `'/.tangled/workflows'`).Run(); err != nil { 456 + return fmt.Errorf("git sparse-checkout set: %w", err) 457 + } 458 + if err := exec.Command("git", "-C", path, "checkout", rev).Run(); err != nil { 459 + return fmt.Errorf("git checkout: %w", err) 392 460 } 393 - 394 - // remove existing owner 395 - err = s.e.RemoveSpindleOwner(rbacDomain, existingOwner) 396 - if err != nil { 397 - return nil 461 + } else { 462 + if err := exec.Command("git", "-C", path, "pull", "origin", rev).Run(); err != nil { 463 + return fmt.Errorf("git pull: %w", err) 398 464 } 399 - default: 400 - return fmt.Errorf("more than one owner in DB, try deleting %q and starting over", s.cfg.Server.DBPath) 401 465 } 466 + return nil 467 + } 402 468 403 - return s.e.AddSpindleOwner(rbacDomain, cfgOwner) 469 + func isDir(path string) (bool, error) { 470 + info, err := os.Stat(path) 471 + if err == nil && info.IsDir() { 472 + return true, nil 473 + } 474 + if os.IsNotExist(err) { 475 + return false, nil 476 + } 477 + return false, err 404 478 }
+281
spindle/tap.go
··· 1 + package spindle 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "time" 8 + 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + "tangled.org/core/api/tangled" 11 + "tangled.org/core/eventconsumer" 12 + "tangled.org/core/spindle/db" 13 + "tangled.org/core/tap" 14 + ) 15 + 16 + func (s *Spindle) processEvent(ctx context.Context, evt tap.Event) error { 17 + l := s.l.With("component", "tapIndexer") 18 + 19 + var err error 20 + switch evt.Type { 21 + case tap.EvtRecord: 22 + switch evt.Record.Collection.String() { 23 + case tangled.SpindleMemberNSID: 24 + err = s.processMember(ctx, evt) 25 + case tangled.RepoNSID: 26 + err = s.processRepo(ctx, evt) 27 + case tangled.RepoCollaboratorNSID: 28 + err = s.processCollaborator(ctx, evt) 29 + case tangled.RepoPullNSID: 30 + err = s.processPull(ctx, evt) 31 + } 32 + case tap.EvtIdentity: 33 + // no-op 34 + } 35 + 36 + if err != nil { 37 + l.Error("failed to process message. will retry later", "event.ID", evt.ID, "err", err) 38 + return err 39 + } 40 + return nil 41 + } 42 + 43 + // NOTE: make sure to return nil if we don't need to retry (e.g. forbidden, unrelated) 44 + 45 + func (s *Spindle) processMember(ctx context.Context, evt tap.Event) error { 46 + l := s.l.With("component", "tapIndexer", "record", evt.Record.AtUri()) 47 + 48 + l.Info("processing spindle.member record") 49 + 50 + // check perms for this user 51 + if ok, err := s.e.IsSpindleMemberInviteAllowed(evt.Record.Did, s.cfg.Server.Did()); !ok || err != nil { 52 + l.Warn("forbidden request", "did", evt.Record.Did, "error", err) 53 + return nil 54 + } 55 + 56 + switch evt.Record.Action { 57 + case tap.RecordCreateAction, tap.RecordUpdateAction: 58 + record := tangled.SpindleMember{} 59 + if err := json.Unmarshal(evt.Record.Record, &record); err != nil { 60 + return fmt.Errorf("parsing record: %w", err) 61 + } 62 + 63 + domain := s.cfg.Server.Hostname 64 + if record.Instance != domain { 65 + l.Info("domain mismatch", "domain", record.Instance, "expected", domain) 66 + return nil 67 + } 68 + 69 + created, err := time.Parse(record.CreatedAt, time.RFC3339) 70 + if err != nil { 71 + created = time.Now() 72 + } 73 + if err := db.AddSpindleMember(s.db, db.SpindleMember{ 74 + Did: evt.Record.Did, 75 + Rkey: evt.Record.Rkey.String(), 76 + Instance: record.Instance, 77 + Subject: syntax.DID(record.Subject), 78 + Created: created, 79 + }); err != nil { 80 + l.Error("failed to add member", "error", err) 81 + return fmt.Errorf("adding member to db: %w", err) 82 + } 83 + if err := s.e.AddSpindleMember(syntax.DID(record.Subject), s.cfg.Server.Did()); err != nil { 84 + return fmt.Errorf("adding member to rbac: %w", err) 85 + } 86 + if err := s.tap.AddRepos(ctx, []syntax.DID{syntax.DID(record.Subject)}); err != nil { 87 + return fmt.Errorf("adding did to tap", err) 88 + } 89 + 90 + l.Info("added member", "member", record.Subject) 91 + return nil 92 + 93 + case tap.RecordDeleteAction: 94 + var ( 95 + did = evt.Record.Did.String() 96 + rkey = evt.Record.Rkey.String() 97 + ) 98 + member, err := db.GetSpindleMember(s.db, did, rkey) 99 + if err != nil { 100 + return fmt.Errorf("finding member: %w", err) 101 + } 102 + 103 + if err := db.RemoveSpindleMember(s.db, did, rkey); err != nil { 104 + return fmt.Errorf("removing member from db: %w", err) 105 + } 106 + if err := s.e.RemoveSpindleMember(member.Subject, s.cfg.Server.Did()); err != nil { 107 + return fmt.Errorf("removing member from rbac: %w", err) 108 + } 109 + if err := s.tapSafeRemoveDid(ctx, member.Subject); err != nil { 110 + return fmt.Errorf("removing did from tap: %w", err) 111 + } 112 + 113 + l.Info("removed member", "member", member.Subject) 114 + return nil 115 + } 116 + return nil 117 + } 118 + 119 + func (s *Spindle) processCollaborator(ctx context.Context, evt tap.Event) error { 120 + l := s.l.With("component", "tapIndexer", "record", evt.Record.AtUri()) 121 + 122 + l.Info("processing collaborator record") 123 + switch evt.Record.Action { 124 + case tap.RecordCreateAction, tap.RecordUpdateAction: 125 + record := tangled.RepoCollaborator{} 126 + if err := json.Unmarshal(evt.Record.Record, &record); err != nil { 127 + l.Error("invalid record", "err", err) 128 + return fmt.Errorf("parsing record: %w", err) 129 + } 130 + 131 + // check perms for this user 132 + if ok, err := s.e.IsRepoCollaboratorInviteAllowed(evt.Record.Did, syntax.ATURI(record.Repo)); !ok || err != nil { 133 + l.Warn("forbidden request", "did", evt.Record.Did, "err", err) 134 + return nil 135 + } 136 + 137 + if err := s.db.PutRepoCollaborator(&db.RepoCollaborator{ 138 + Did: evt.Record.Did, 139 + Rkey: evt.Record.Rkey, 140 + Repo: syntax.ATURI(record.Repo), 141 + Subject: syntax.DID(record.Subject), 142 + }); err != nil { 143 + return fmt.Errorf("adding collaborator to db: %w", err) 144 + } 145 + if err := s.e.AddRepoCollaborator(syntax.DID(record.Subject), syntax.ATURI(record.Repo)); err != nil { 146 + return fmt.Errorf("adding collaborator to rbac: %w", err) 147 + } 148 + if err := s.tap.AddRepos(ctx, []syntax.DID{syntax.DID(record.Subject)}); err != nil { 149 + return fmt.Errorf("adding did to tap: %w", err) 150 + } 151 + 152 + l.Info("add repo collaborator", "subejct", record.Subject, "repo", record.Repo) 153 + return nil 154 + 155 + case tap.RecordDeleteAction: 156 + // get existing collaborator 157 + collaborator, err := s.db.GetRepoCollaborator(evt.Record.Did, evt.Record.Rkey) 158 + if err != nil { 159 + return fmt.Errorf("failed to get existing collaborator info: %w", err) 160 + } 161 + 162 + // check perms for this user 163 + if ok, err := s.e.IsRepoCollaboratorInviteAllowed(evt.Record.Did, collaborator.Repo); !ok || err != nil { 164 + l.Warn("forbidden request", "did", evt.Record.Did, "err", err) 165 + return nil 166 + } 167 + 168 + if err := s.db.RemoveRepoCollaborator(collaborator.Subject, collaborator.Rkey); err != nil { 169 + return fmt.Errorf("removing collaborator from db: %w", err) 170 + } 171 + if err := s.e.RemoveRepoCollaborator(collaborator.Subject, collaborator.Repo); err != nil { 172 + return fmt.Errorf("removing collaborator from rbac: %w", err) 173 + } 174 + if err := s.tapSafeRemoveDid(ctx, collaborator.Subject); err != nil { 175 + return fmt.Errorf("removing did from tap: %w", err) 176 + } 177 + 178 + l.Info("removed repo collaborator", "subejct", collaborator.Subject, "repo", collaborator.Repo) 179 + return nil 180 + } 181 + return nil 182 + } 183 + 184 + func (s *Spindle) processRepo(ctx context.Context, evt tap.Event) error { 185 + l := s.l.With("component", "tapIndexer", "record", evt.Record.AtUri()) 186 + 187 + l.Info("processing repo record") 188 + 189 + // check perms for this user 190 + if ok, err := s.e.IsSpindleMember(evt.Record.Did, s.cfg.Server.Did()); !ok || err != nil { 191 + l.Warn("forbidden request", "did", evt.Record.Did, "err", err) 192 + return nil 193 + } 194 + 195 + switch evt.Record.Action { 196 + case tap.RecordCreateAction, tap.RecordUpdateAction: 197 + record := tangled.Repo{} 198 + if err := json.Unmarshal(evt.Record.Record, &record); err != nil { 199 + return fmt.Errorf("parsing record: %w", err) 200 + } 201 + 202 + domain := s.cfg.Server.Hostname 203 + if record.Spindle == nil || *record.Spindle != domain { 204 + if record.Spindle == nil { 205 + l.Info("spindle isn't configured", "name", record.Name) 206 + } else { 207 + l.Info("different spindle configured", "name", record.Name, "spindle", *record.Spindle, "domain", domain) 208 + } 209 + if err := s.db.DeleteRepo(evt.Record.Did, evt.Record.Rkey); err != nil { 210 + return fmt.Errorf("deleting repo from db: %w", err) 211 + } 212 + return nil 213 + } 214 + 215 + if err := s.db.PutRepo(&db.Repo{ 216 + Did: evt.Record.Did, 217 + Rkey: evt.Record.Rkey, 218 + Name: record.Name, 219 + Knot: record.Knot, 220 + }); err != nil { 221 + return fmt.Errorf("adding repo to db: %w", err) 222 + } 223 + 224 + if err := s.e.AddRepo(evt.Record.AtUri()); err != nil { 225 + return fmt.Errorf("adding repo to rbac") 226 + } 227 + 228 + // add this knot to the event consumer 229 + src := eventconsumer.NewKnotSource(record.Knot) 230 + s.ks.AddSource(context.Background(), src) 231 + 232 + l.Info("added repo", "repo", evt.Record.AtUri()) 233 + return nil 234 + 235 + case tap.RecordDeleteAction: 236 + // check perms for this user 237 + if ok, err := s.e.IsRepoOwner(evt.Record.Did, evt.Record.AtUri()); !ok || err != nil { 238 + l.Warn("forbidden request", "did", evt.Record.Did, "err", err) 239 + return nil 240 + } 241 + 242 + if err := s.db.DeleteRepo(evt.Record.Did, evt.Record.Rkey); err != nil { 243 + return fmt.Errorf("deleting repo from db: %w", err) 244 + } 245 + 246 + if err := s.e.DeleteRepo(evt.Record.AtUri()); err != nil { 247 + return fmt.Errorf("deleting repo from rbac: %w", err) 248 + } 249 + 250 + l.Info("deleted repo", "repo", evt.Record.AtUri()) 251 + return nil 252 + } 253 + return nil 254 + } 255 + 256 + func (s *Spindle) processPull(ctx context.Context, evt tap.Event) error { 257 + l := s.l.With("component", "tapIndexer", "record", evt.Record.AtUri()) 258 + 259 + l.Info("processing pull record") 260 + 261 + switch evt.Record.Action { 262 + case tap.RecordCreateAction, tap.RecordUpdateAction: 263 + // TODO 264 + case tap.RecordDeleteAction: 265 + // TODO 266 + } 267 + return nil 268 + } 269 + 270 + func (s *Spindle) tapSafeRemoveDid(ctx context.Context, did syntax.DID) error { 271 + known, err := s.db.IsKnownDid(syntax.DID(did)) 272 + if err != nil { 273 + return fmt.Errorf("ensuring did known state: %w", err) 274 + } 275 + if !known { 276 + if err := s.tap.RemoveRepos(ctx, []syntax.DID{did}); err != nil { 277 + return fmt.Errorf("removing did from tap: %w", err) 278 + } 279 + } 280 + return nil 281 + }
+1 -2
spindle/xrpc/add_secret.go
··· 11 11 "github.com/bluesky-social/indigo/xrpc" 12 12 securejoin "github.com/cyphar/filepath-securejoin" 13 13 "tangled.org/core/api/tangled" 14 - "tangled.org/core/rbac" 15 14 "tangled.org/core/spindle/secrets" 16 15 xrpcerr "tangled.org/core/xrpc/errors" 17 16 ) ··· 68 67 return 69 68 } 70 69 71 - if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 70 + if ok, err := x.Enforcer.IsRepoSettingsWriteAllowed(actorDid, repoAt); !ok || err != nil { 72 71 l.Error("insufficent permissions", "did", actorDid.String()) 73 72 writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 74 73 return
+1 -2
spindle/xrpc/list_secrets.go
··· 11 11 "github.com/bluesky-social/indigo/xrpc" 12 12 securejoin "github.com/cyphar/filepath-securejoin" 13 13 "tangled.org/core/api/tangled" 14 - "tangled.org/core/rbac" 15 14 "tangled.org/core/spindle/secrets" 16 15 xrpcerr "tangled.org/core/xrpc/errors" 17 16 ) ··· 63 62 return 64 63 } 65 64 66 - if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 65 + if ok, err := x.Enforcer.IsRepoSettingsWriteAllowed(actorDid, repoAt); !ok || err != nil { 67 66 l.Error("insufficent permissions", "did", actorDid.String()) 68 67 writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 69 68 return
+1 -1
spindle/xrpc/owner.go
··· 9 9 ) 10 10 11 11 func (x *Xrpc) Owner(w http.ResponseWriter, r *http.Request) { 12 - owner := x.Config.Server.Owner 12 + owner := x.Config.Server.Owner.String() 13 13 if owner == "" { 14 14 writeError(w, xrpcerr.OwnerNotFoundError, http.StatusInternalServerError) 15 15 return
+72
spindle/xrpc/pipeline_cancelPipeline.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + "strings" 8 + 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + "tangled.org/core/api/tangled" 11 + "tangled.org/core/spindle/models" 12 + xrpcerr "tangled.org/core/xrpc/errors" 13 + ) 14 + 15 + func (x *Xrpc) CancelPipeline(w http.ResponseWriter, r *http.Request) { 16 + l := x.Logger 17 + fail := func(e xrpcerr.XrpcError) { 18 + l.Error("failed", "kind", e.Tag, "error", e.Message) 19 + writeError(w, e, http.StatusBadRequest) 20 + } 21 + l.Debug("cancel pipeline") 22 + 23 + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 24 + if !ok { 25 + fail(xrpcerr.MissingActorDidError) 26 + return 27 + } 28 + 29 + var input tangled.PipelineCancelPipeline_Input 30 + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { 31 + fail(xrpcerr.GenericError(err)) 32 + return 33 + } 34 + 35 + aturi := syntax.ATURI(input.Pipeline) 36 + wid := models.WorkflowId{ 37 + PipelineId: models.PipelineId{ 38 + Knot: strings.TrimPrefix(aturi.Authority().String(), "did:web:"), 39 + Rkey: aturi.RecordKey().String(), 40 + }, 41 + Name: input.Workflow, 42 + } 43 + l.Debug("cancel pipeline", "wid", wid) 44 + 45 + // unfortunately we have to resolve repo-at here 46 + repoAt, err := syntax.ParseATURI(input.Repo) 47 + if err != nil { 48 + fail(xrpcerr.InvalidRepoError(input.Repo)) 49 + return 50 + } 51 + 52 + isRepoOwner, err := x.Enforcer.IsRepoOwner(actorDid, repoAt) 53 + if err != nil || !isRepoOwner { 54 + fail(xrpcerr.AccessControlError(actorDid.String())) 55 + return 56 + } 57 + for _, engine := range x.Engines { 58 + l.Debug("destorying workflow", "wid", wid) 59 + err = engine.DestroyWorkflow(r.Context(), wid) 60 + if err != nil { 61 + fail(xrpcerr.GenericError(fmt.Errorf("dailed to destroy workflow: %w", err))) 62 + return 63 + } 64 + err = x.Db.StatusCancelled(wid, "User canceled the workflow", -1, x.Notifier) 65 + if err != nil { 66 + fail(xrpcerr.GenericError(fmt.Errorf("dailed to emit status failed: %w", err))) 67 + return 68 + } 69 + } 70 + 71 + w.WriteHeader(http.StatusOK) 72 + }
+1 -2
spindle/xrpc/remove_secret.go
··· 10 10 "github.com/bluesky-social/indigo/xrpc" 11 11 securejoin "github.com/cyphar/filepath-securejoin" 12 12 "tangled.org/core/api/tangled" 13 - "tangled.org/core/rbac" 14 13 "tangled.org/core/spindle/secrets" 15 14 xrpcerr "tangled.org/core/xrpc/errors" 16 15 ) ··· 62 61 return 63 62 } 64 63 65 - if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 64 + if ok, err := x.Enforcer.IsRepoSettingsWriteAllowed(actorDid, repoAt); !ok || err != nil { 66 65 l.Error("insufficent permissions", "did", actorDid.String()) 67 66 writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 68 67 return
+5 -2
spindle/xrpc/xrpc.go
··· 10 10 11 11 "tangled.org/core/api/tangled" 12 12 "tangled.org/core/idresolver" 13 - "tangled.org/core/rbac" 13 + "tangled.org/core/notifier" 14 + "tangled.org/core/rbac2" 14 15 "tangled.org/core/spindle/config" 15 16 "tangled.org/core/spindle/db" 16 17 "tangled.org/core/spindle/models" ··· 24 25 type Xrpc struct { 25 26 Logger *slog.Logger 26 27 Db *db.DB 27 - Enforcer *rbac.Enforcer 28 + Enforcer *rbac2.Enforcer 28 29 Engines map[string]models.Engine 29 30 Config *config.Config 30 31 Resolver *idresolver.Resolver 31 32 Vault secrets.Manager 33 + Notifier *notifier.Notifier 32 34 ServiceAuth *serviceauth.ServiceAuth 33 35 } 34 36 ··· 41 43 r.Post("/"+tangled.RepoAddSecretNSID, x.AddSecret) 42 44 r.Post("/"+tangled.RepoRemoveSecretNSID, x.RemoveSecret) 43 45 r.Get("/"+tangled.RepoListSecretsNSID, x.ListSecrets) 46 + r.Post("/"+tangled.PipelineCancelPipelineNSID, x.CancelPipeline) 44 47 }) 45 48 46 49 // service query endpoints (no auth required)
+18
tap/simpleIndexer.go
··· 1 + package tap 2 + 3 + import "context" 4 + 5 + type SimpleIndexer struct { 6 + EventHandler func(ctx context.Context, evt Event) error 7 + ErrorHandler func(ctx context.Context, err error) 8 + } 9 + 10 + var _ Handler = (*SimpleIndexer)(nil) 11 + 12 + func (i *SimpleIndexer) OnEvent(ctx context.Context, evt Event) error { 13 + return i.EventHandler(ctx, evt) 14 + } 15 + 16 + func (i *SimpleIndexer) OnError(ctx context.Context, err error) { 17 + i.ErrorHandler(ctx, err) 18 + }
+169
tap/tap.go
··· 1 + /// heavily inspired by <https://github.com/bluesky-social/atproto/blob/c7f5a868837d3e9b3289f988fee2267789327b06/packages/tap/README.md> 2 + 3 + package tap 4 + 5 + import ( 6 + "bytes" 7 + "context" 8 + "encoding/json" 9 + "fmt" 10 + "net/http" 11 + "net/url" 12 + 13 + "github.com/bluesky-social/indigo/atproto/syntax" 14 + "github.com/gorilla/websocket" 15 + "tangled.org/core/log" 16 + ) 17 + 18 + // type WebsocketOptions struct { 19 + // maxReconnectSeconds int 20 + // heartbeatIntervalMs int 21 + // // onReconnectError 22 + // } 23 + 24 + type Handler interface { 25 + OnEvent(ctx context.Context, evt Event) error 26 + OnError(ctx context.Context, err error) 27 + } 28 + 29 + type Client struct { 30 + Url string 31 + AdminPassword string 32 + HTTPClient *http.Client 33 + } 34 + 35 + func NewClient(url, adminPassword string) Client { 36 + return Client{ 37 + Url: url, 38 + AdminPassword: adminPassword, 39 + HTTPClient: &http.Client{}, 40 + } 41 + } 42 + 43 + func (c *Client) AddRepos(ctx context.Context, dids []syntax.DID) error { 44 + body, err := json.Marshal(map[string][]syntax.DID{"dids": dids}) 45 + if err != nil { 46 + return err 47 + } 48 + req, err := http.NewRequestWithContext(ctx, "POST", c.Url+"/repos/add", bytes.NewReader(body)) 49 + if err != nil { 50 + return err 51 + } 52 + req.SetBasicAuth("admin", c.AdminPassword) 53 + req.Header.Set("Content-Type", "application/json") 54 + 55 + resp, err := c.HTTPClient.Do(req) 56 + if err != nil { 57 + return err 58 + } 59 + defer resp.Body.Close() 60 + if resp.StatusCode != http.StatusOK { 61 + return fmt.Errorf("tap: /repos/add failed with status %d", resp.StatusCode) 62 + } 63 + return nil 64 + } 65 + 66 + func (c *Client) RemoveRepos(ctx context.Context, dids []syntax.DID) error { 67 + body, err := json.Marshal(map[string][]syntax.DID{"dids": dids}) 68 + if err != nil { 69 + return err 70 + } 71 + req, err := http.NewRequestWithContext(ctx, "POST", c.Url+"/repos/remove", bytes.NewReader(body)) 72 + if err != nil { 73 + return err 74 + } 75 + req.SetBasicAuth("admin", c.AdminPassword) 76 + req.Header.Set("Content-Type", "application/json") 77 + 78 + resp, err := c.HTTPClient.Do(req) 79 + if err != nil { 80 + return err 81 + } 82 + defer resp.Body.Close() 83 + if resp.StatusCode != http.StatusOK { 84 + return fmt.Errorf("tap: /repos/remove failed with status %d", resp.StatusCode) 85 + } 86 + return nil 87 + } 88 + 89 + func (c *Client) Connect(ctx context.Context, handler Handler) error { 90 + l := log.FromContext(ctx) 91 + 92 + u, err := url.Parse(c.Url) 93 + if err != nil { 94 + return err 95 + } 96 + if u.Scheme == "https" { 97 + u.Scheme = "wss" 98 + } else { 99 + u.Scheme = "ws" 100 + } 101 + u.Path = "/channel" 102 + 103 + // TODO: set auth on dial 104 + 105 + url := u.String() 106 + 107 + // var backoff int 108 + // for { 109 + // select { 110 + // case <-ctx.Done(): 111 + // return ctx.Err() 112 + // default: 113 + // } 114 + // 115 + // header := http.Header{ 116 + // "Authorization": []string{""}, 117 + // } 118 + // conn, res, err := websocket.DefaultDialer.DialContext(ctx, url, header) 119 + // if err != nil { 120 + // l.Warn("dialing failed", "url", url, "err", err, "backoff", backoff) 121 + // time.Sleep(time.Duration(5+backoff) * time.Second) 122 + // backoff++ 123 + // 124 + // continue 125 + // } else { 126 + // backoff = 0 127 + // } 128 + // 129 + // l.Info("event subscription response", "code", res.StatusCode) 130 + // } 131 + 132 + // TODO: keep websocket connection alive 133 + conn, _, err := websocket.DefaultDialer.DialContext(ctx, url, nil) 134 + if err != nil { 135 + return err 136 + } 137 + defer conn.Close() 138 + 139 + for { 140 + select { 141 + case <-ctx.Done(): 142 + return ctx.Err() 143 + default: 144 + } 145 + _, message, err := conn.ReadMessage() 146 + if err != nil { 147 + return err 148 + } 149 + 150 + var ev Event 151 + if err := json.Unmarshal(message, &ev); err != nil { 152 + handler.OnError(ctx, fmt.Errorf("failed to parse message: %w", err)) 153 + continue 154 + } 155 + if err := handler.OnEvent(ctx, ev); err != nil { 156 + handler.OnError(ctx, fmt.Errorf("failed to process event %d: %w", ev.ID, err)) 157 + continue 158 + } 159 + 160 + ack := map[string]any{ 161 + "type": "ack", 162 + "id": ev.ID, 163 + } 164 + if err := conn.WriteJSON(ack); err != nil { 165 + l.Warn("failed to send ack", "err", err) 166 + continue 167 + } 168 + } 169 + }
+62
tap/types.go
··· 1 + package tap 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 + ) 9 + 10 + type EventType string 11 + 12 + const ( 13 + EvtRecord EventType = "record" 14 + EvtIdentity EventType = "identity" 15 + ) 16 + 17 + type Event struct { 18 + ID int64 `json:"id"` 19 + Type EventType `json:"type"` 20 + Record *RecordEventData `json:"record,omitempty"` 21 + Identity *IdentityEventData `json:"identity,omitempty"` 22 + } 23 + 24 + type RecordEventData struct { 25 + Live bool `json:"live"` 26 + Did syntax.DID `json:"did"` 27 + Rev string `json:"rev"` 28 + Collection syntax.NSID `json:"collection"` 29 + Rkey syntax.RecordKey `json:"rkey"` 30 + Action RecordAction `json:"action"` 31 + Record json.RawMessage `json:"record,omitempty"` 32 + CID *syntax.CID `json:"cid,omitempty"` 33 + } 34 + 35 + func (r *RecordEventData) AtUri() syntax.ATURI { 36 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", r.Did, r.Collection, r.Rkey)) 37 + } 38 + 39 + type RecordAction string 40 + 41 + const ( 42 + RecordCreateAction RecordAction = "create" 43 + RecordUpdateAction RecordAction = "update" 44 + RecordDeleteAction RecordAction = "delete" 45 + ) 46 + 47 + type IdentityEventData struct { 48 + DID syntax.DID `json:"did"` 49 + Handle string `json:"handle"` 50 + IsActive bool `json:"is_active"` 51 + Status RepoStatus `json:"status"` 52 + } 53 + 54 + type RepoStatus string 55 + 56 + const ( 57 + RepoStatusActive RepoStatus = "active" 58 + RepoStatusTakendown RepoStatus = "takendown" 59 + RepoStatusSuspended RepoStatus = "suspended" 60 + RepoStatusDeactivated RepoStatus = "deactivated" 61 + RepoStatusDeleted RepoStatus = "deleted" 62 + )
+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"`
+17 -17
types/repo.go
··· 8 8 ) 9 9 10 10 type RepoIndexResponse struct { 11 - IsEmpty bool `json:"is_empty"` 12 - Ref string `json:"ref,omitempty"` 13 - Readme string `json:"readme,omitempty"` 14 - ReadmeFileName string `json:"readme_file_name,omitempty"` 15 - Commits []*object.Commit `json:"commits,omitempty"` 16 - Description string `json:"description,omitempty"` 17 - Files []NiceTree `json:"files,omitempty"` 18 - Branches []Branch `json:"branches,omitempty"` 19 - Tags []*TagReference `json:"tags,omitempty"` 20 - TotalCommits int `json:"total_commits,omitempty"` 11 + IsEmpty bool `json:"is_empty"` 12 + Ref string `json:"ref,omitempty"` 13 + Readme string `json:"readme,omitempty"` 14 + ReadmeFileName string `json:"readme_file_name,omitempty"` 15 + Commits []Commit `json:"commits,omitempty"` 16 + Description string `json:"description,omitempty"` 17 + Files []NiceTree `json:"files,omitempty"` 18 + Branches []Branch `json:"branches,omitempty"` 19 + Tags []*TagReference `json:"tags,omitempty"` 20 + TotalCommits int `json:"total_commits,omitempty"` 21 21 } 22 22 23 23 type RepoLogResponse struct { 24 - Commits []*object.Commit `json:"commits,omitempty"` 25 - Ref string `json:"ref,omitempty"` 26 - Description string `json:"description,omitempty"` 27 - Log bool `json:"log,omitempty"` 28 - Total int `json:"total,omitempty"` 29 - Page int `json:"page,omitempty"` 30 - PerPage int `json:"per_page,omitempty"` 24 + Commits []Commit `json:"commits,omitempty"` 25 + Ref string `json:"ref,omitempty"` 26 + Description string `json:"description,omitempty"` 27 + Log bool `json:"log,omitempty"` 28 + Total int `json:"total,omitempty"` 29 + Page int `json:"page,omitempty"` 30 + PerPage int `json:"per_page,omitempty"` 31 31 } 32 32 33 33 type RepoCommitResponse struct {