+1
-1
.air/knotserver.toml
+1
-1
.air/knotserver.toml
+6
.tangled/workflows/test.yml
+6
.tangled/workflows/test.yml
+1272
-170
api/tangled/cbor_gen.go
+1272
-170
api/tangled/cbor_gen.go
···
1499
1500
return nil
1501
}
1502
-
func (t *GitRefUpdate_LangBreakdown) MarshalCBOR(w io.Writer) error {
1503
if t == nil {
1504
_, err := w.Write(cbg.CborNull)
1505
return err
1506
}
1507
1508
cw := cbg.NewCborWriter(w)
1509
-
fieldCount := 1
1510
1511
-
if t.Inputs == nil {
1512
-
fieldCount--
1513
}
1514
1515
-
if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
1516
return err
1517
}
1518
1519
-
// t.Inputs ([]*tangled.GitRefUpdate_IndividualLanguageSize) (slice)
1520
-
if t.Inputs != nil {
1521
1522
-
if len("inputs") > 1000000 {
1523
-
return xerrors.Errorf("Value in field \"inputs\" was too long")
1524
-
}
1525
1526
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("inputs"))); err != nil {
1527
-
return err
1528
-
}
1529
-
if _, err := cw.WriteString(string("inputs")); err != nil {
1530
-
return err
1531
-
}
1532
1533
-
if len(t.Inputs) > 8192 {
1534
-
return xerrors.Errorf("Slice value in field t.Inputs was too long")
1535
-
}
1536
1537
-
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Inputs))); err != nil {
1538
return err
1539
}
1540
-
for _, v := range t.Inputs {
1541
-
if err := v.MarshalCBOR(cw); err != nil {
1542
-
return err
1543
-
}
1544
-
1545
}
1546
}
1547
return nil
1548
}
1549
1550
-
func (t *GitRefUpdate_LangBreakdown) UnmarshalCBOR(r io.Reader) (err error) {
1551
-
*t = GitRefUpdate_LangBreakdown{}
1552
1553
cr := cbg.NewCborReader(r)
1554
···
1567
}
1568
1569
if extra > cbg.MaxLength {
1570
-
return fmt.Errorf("GitRefUpdate_LangBreakdown: map struct too large (%d)", extra)
1571
}
1572
1573
n := extra
1574
1575
-
nameBuf := make([]byte, 6)
1576
for i := uint64(0); i < n; i++ {
1577
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
1578
if err != nil {
···
1588
}
1589
1590
switch string(nameBuf[:nameLen]) {
1591
-
// t.Inputs ([]*tangled.GitRefUpdate_IndividualLanguageSize) (slice)
1592
-
case "inputs":
1593
-
1594
-
maj, extra, err = cr.ReadHeader()
1595
-
if err != nil {
1596
-
return err
1597
-
}
1598
-
1599
-
if extra > 8192 {
1600
-
return fmt.Errorf("t.Inputs: array too large (%d)", extra)
1601
-
}
1602
1603
-
if maj != cbg.MajArray {
1604
-
return fmt.Errorf("expected cbor array")
1605
-
}
1606
1607
-
if extra > 0 {
1608
-
t.Inputs = make([]*GitRefUpdate_IndividualLanguageSize, extra)
1609
}
1610
-
1611
-
for i := 0; i < int(extra); i++ {
1612
-
{
1613
-
var maj byte
1614
-
var extra uint64
1615
-
var err error
1616
-
_ = maj
1617
-
_ = extra
1618
-
_ = err
1619
-
1620
-
{
1621
-
1622
-
b, err := cr.ReadByte()
1623
-
if err != nil {
1624
-
return err
1625
-
}
1626
-
if b != cbg.CborNull[0] {
1627
-
if err := cr.UnreadByte(); err != nil {
1628
-
return err
1629
-
}
1630
-
t.Inputs[i] = new(GitRefUpdate_IndividualLanguageSize)
1631
-
if err := t.Inputs[i].UnmarshalCBOR(cr); err != nil {
1632
-
return xerrors.Errorf("unmarshaling t.Inputs[i] pointer: %w", err)
1633
-
}
1634
-
}
1635
-
1636
}
1637
-
1638
}
1639
}
1640
1641
default:
···
1648
1649
return nil
1650
}
1651
-
func (t *GitRefUpdate_IndividualLanguageSize) MarshalCBOR(w io.Writer) error {
1652
if t == nil {
1653
_, err := w.Write(cbg.CborNull)
1654
return err
1655
}
1656
1657
cw := cbg.NewCborWriter(w)
1658
1659
-
if _, err := cw.Write([]byte{162}); err != nil {
1660
-
return err
1661
-
}
1662
-
1663
-
// t.Lang (string) (string)
1664
-
if len("lang") > 1000000 {
1665
-
return xerrors.Errorf("Value in field \"lang\" was too long")
1666
}
1667
1668
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("lang"))); err != nil {
1669
-
return err
1670
-
}
1671
-
if _, err := cw.WriteString(string("lang")); err != nil {
1672
return err
1673
}
1674
1675
-
if len(t.Lang) > 1000000 {
1676
-
return xerrors.Errorf("Value in field t.Lang was too long")
1677
-
}
1678
1679
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Lang))); err != nil {
1680
-
return err
1681
-
}
1682
-
if _, err := cw.WriteString(string(t.Lang)); err != nil {
1683
-
return err
1684
-
}
1685
1686
-
// t.Size (int64) (int64)
1687
-
if len("size") > 1000000 {
1688
-
return xerrors.Errorf("Value in field \"size\" was too long")
1689
-
}
1690
1691
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("size"))); err != nil {
1692
-
return err
1693
-
}
1694
-
if _, err := cw.WriteString(string("size")); err != nil {
1695
-
return err
1696
-
}
1697
1698
-
if t.Size >= 0 {
1699
-
if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.Size)); err != nil {
1700
return err
1701
}
1702
-
} else {
1703
-
if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.Size-1)); err != nil {
1704
-
return err
1705
}
1706
}
1707
-
1708
return nil
1709
}
1710
1711
-
func (t *GitRefUpdate_IndividualLanguageSize) UnmarshalCBOR(r io.Reader) (err error) {
1712
-
*t = GitRefUpdate_IndividualLanguageSize{}
1713
1714
cr := cbg.NewCborReader(r)
1715
···
1728
}
1729
1730
if extra > cbg.MaxLength {
1731
-
return fmt.Errorf("GitRefUpdate_IndividualLanguageSize: map struct too large (%d)", extra)
1732
}
1733
1734
n := extra
1735
1736
-
nameBuf := make([]byte, 4)
1737
for i := uint64(0); i < n; i++ {
1738
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
1739
if err != nil {
···
1749
}
1750
1751
switch string(nameBuf[:nameLen]) {
1752
-
// t.Lang (string) (string)
1753
-
case "lang":
1754
1755
-
{
1756
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
1757
-
if err != nil {
1758
-
return err
1759
-
}
1760
1761
-
t.Lang = string(sval)
1762
}
1763
-
// t.Size (int64) (int64)
1764
-
case "size":
1765
-
{
1766
-
maj, extra, err := cr.ReadHeader()
1767
-
if err != nil {
1768
-
return err
1769
-
}
1770
-
var extraI int64
1771
-
switch maj {
1772
-
case cbg.MajUnsignedInt:
1773
-
extraI = int64(extra)
1774
-
if extraI < 0 {
1775
-
return fmt.Errorf("int64 positive overflow")
1776
-
}
1777
-
case cbg.MajNegativeInt:
1778
-
extraI = int64(extra)
1779
-
if extraI < 0 {
1780
-
return fmt.Errorf("int64 negative overflow")
1781
}
1782
-
extraI = -1 - extraI
1783
-
default:
1784
-
return fmt.Errorf("wrong type for int64 field: %d", maj)
1785
}
1786
-
1787
-
t.Size = int64(extraI)
1788
}
1789
1790
default:
···
2469
2470
return nil
2471
}
2472
func (t *Pipeline) MarshalCBOR(w io.Writer) error {
2473
if t == nil {
2474
_, err := w.Write(cbg.CborNull)
···
4756
fieldCount--
4757
}
4758
4759
if t.Source == nil {
4760
fieldCount--
4761
}
···
4833
return err
4834
}
4835
4836
-
// t.Owner (string) (string)
4837
-
if len("owner") > 1000000 {
4838
-
return xerrors.Errorf("Value in field \"owner\" was too long")
4839
-
}
4840
4841
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("owner"))); err != nil {
4842
-
return err
4843
-
}
4844
-
if _, err := cw.WriteString(string("owner")); err != nil {
4845
-
return err
4846
-
}
4847
4848
-
if len(t.Owner) > 1000000 {
4849
-
return xerrors.Errorf("Value in field t.Owner was too long")
4850
-
}
4851
4852
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Owner))); err != nil {
4853
-
return err
4854
-
}
4855
-
if _, err := cw.WriteString(string(t.Owner)); err != nil {
4856
-
return err
4857
}
4858
4859
// t.Source (string) (string)
···
5051
5052
t.LexiconTypeID = string(sval)
5053
}
5054
-
// t.Owner (string) (string)
5055
-
case "owner":
5056
5057
-
{
5058
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
5059
-
if err != nil {
5060
-
return err
5061
}
5062
-
5063
-
t.Owner = string(sval)
5064
}
5065
// t.Source (string) (string)
5066
case "source":
···
1499
1500
return nil
1501
}
1502
+
func (t *GitRefUpdate_IndividualLanguageSize) MarshalCBOR(w io.Writer) error {
1503
if t == nil {
1504
_, err := w.Write(cbg.CborNull)
1505
return err
1506
}
1507
1508
cw := cbg.NewCborWriter(w)
1509
1510
+
if _, err := cw.Write([]byte{162}); err != nil {
1511
+
return err
1512
}
1513
1514
+
// t.Lang (string) (string)
1515
+
if len("lang") > 1000000 {
1516
+
return xerrors.Errorf("Value in field \"lang\" was too long")
1517
+
}
1518
+
1519
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("lang"))); err != nil {
1520
+
return err
1521
+
}
1522
+
if _, err := cw.WriteString(string("lang")); err != nil {
1523
return err
1524
}
1525
1526
+
if len(t.Lang) > 1000000 {
1527
+
return xerrors.Errorf("Value in field t.Lang was too long")
1528
+
}
1529
1530
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Lang))); err != nil {
1531
+
return err
1532
+
}
1533
+
if _, err := cw.WriteString(string(t.Lang)); err != nil {
1534
+
return err
1535
+
}
1536
1537
+
// t.Size (int64) (int64)
1538
+
if len("size") > 1000000 {
1539
+
return xerrors.Errorf("Value in field \"size\" was too long")
1540
+
}
1541
1542
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("size"))); err != nil {
1543
+
return err
1544
+
}
1545
+
if _, err := cw.WriteString(string("size")); err != nil {
1546
+
return err
1547
+
}
1548
1549
+
if t.Size >= 0 {
1550
+
if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.Size)); err != nil {
1551
return err
1552
}
1553
+
} else {
1554
+
if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.Size-1)); err != nil {
1555
+
return err
1556
}
1557
}
1558
+
1559
return nil
1560
}
1561
1562
+
func (t *GitRefUpdate_IndividualLanguageSize) UnmarshalCBOR(r io.Reader) (err error) {
1563
+
*t = GitRefUpdate_IndividualLanguageSize{}
1564
1565
cr := cbg.NewCborReader(r)
1566
···
1579
}
1580
1581
if extra > cbg.MaxLength {
1582
+
return fmt.Errorf("GitRefUpdate_IndividualLanguageSize: map struct too large (%d)", extra)
1583
}
1584
1585
n := extra
1586
1587
+
nameBuf := make([]byte, 4)
1588
for i := uint64(0); i < n; i++ {
1589
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
1590
if err != nil {
···
1600
}
1601
1602
switch string(nameBuf[:nameLen]) {
1603
+
// t.Lang (string) (string)
1604
+
case "lang":
1605
1606
+
{
1607
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
1608
+
if err != nil {
1609
+
return err
1610
+
}
1611
1612
+
t.Lang = string(sval)
1613
}
1614
+
// t.Size (int64) (int64)
1615
+
case "size":
1616
+
{
1617
+
maj, extra, err := cr.ReadHeader()
1618
+
if err != nil {
1619
+
return err
1620
+
}
1621
+
var extraI int64
1622
+
switch maj {
1623
+
case cbg.MajUnsignedInt:
1624
+
extraI = int64(extra)
1625
+
if extraI < 0 {
1626
+
return fmt.Errorf("int64 positive overflow")
1627
}
1628
+
case cbg.MajNegativeInt:
1629
+
extraI = int64(extra)
1630
+
if extraI < 0 {
1631
+
return fmt.Errorf("int64 negative overflow")
1632
+
}
1633
+
extraI = -1 - extraI
1634
+
default:
1635
+
return fmt.Errorf("wrong type for int64 field: %d", maj)
1636
}
1637
+
1638
+
t.Size = int64(extraI)
1639
}
1640
1641
default:
···
1648
1649
return nil
1650
}
1651
+
func (t *GitRefUpdate_LangBreakdown) MarshalCBOR(w io.Writer) error {
1652
if t == nil {
1653
_, err := w.Write(cbg.CborNull)
1654
return err
1655
}
1656
1657
cw := cbg.NewCborWriter(w)
1658
+
fieldCount := 1
1659
1660
+
if t.Inputs == nil {
1661
+
fieldCount--
1662
}
1663
1664
+
if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
1665
return err
1666
}
1667
1668
+
// t.Inputs ([]*tangled.GitRefUpdate_IndividualLanguageSize) (slice)
1669
+
if t.Inputs != nil {
1670
1671
+
if len("inputs") > 1000000 {
1672
+
return xerrors.Errorf("Value in field \"inputs\" was too long")
1673
+
}
1674
1675
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("inputs"))); err != nil {
1676
+
return err
1677
+
}
1678
+
if _, err := cw.WriteString(string("inputs")); err != nil {
1679
+
return err
1680
+
}
1681
1682
+
if len(t.Inputs) > 8192 {
1683
+
return xerrors.Errorf("Slice value in field t.Inputs was too long")
1684
+
}
1685
1686
+
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Inputs))); err != nil {
1687
return err
1688
}
1689
+
for _, v := range t.Inputs {
1690
+
if err := v.MarshalCBOR(cw); err != nil {
1691
+
return err
1692
+
}
1693
+
1694
}
1695
}
1696
return nil
1697
}
1698
1699
+
func (t *GitRefUpdate_LangBreakdown) UnmarshalCBOR(r io.Reader) (err error) {
1700
+
*t = GitRefUpdate_LangBreakdown{}
1701
1702
cr := cbg.NewCborReader(r)
1703
···
1716
}
1717
1718
if extra > cbg.MaxLength {
1719
+
return fmt.Errorf("GitRefUpdate_LangBreakdown: map struct too large (%d)", extra)
1720
}
1721
1722
n := extra
1723
1724
+
nameBuf := make([]byte, 6)
1725
for i := uint64(0); i < n; i++ {
1726
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
1727
if err != nil {
···
1737
}
1738
1739
switch string(nameBuf[:nameLen]) {
1740
+
// t.Inputs ([]*tangled.GitRefUpdate_IndividualLanguageSize) (slice)
1741
+
case "inputs":
1742
1743
+
maj, extra, err = cr.ReadHeader()
1744
+
if err != nil {
1745
+
return err
1746
+
}
1747
1748
+
if extra > 8192 {
1749
+
return fmt.Errorf("t.Inputs: array too large (%d)", extra)
1750
}
1751
+
1752
+
if maj != cbg.MajArray {
1753
+
return fmt.Errorf("expected cbor array")
1754
+
}
1755
+
1756
+
if extra > 0 {
1757
+
t.Inputs = make([]*GitRefUpdate_IndividualLanguageSize, extra)
1758
+
}
1759
+
1760
+
for i := 0; i < int(extra); i++ {
1761
+
{
1762
+
var maj byte
1763
+
var extra uint64
1764
+
var err error
1765
+
_ = maj
1766
+
_ = extra
1767
+
_ = err
1768
+
1769
+
{
1770
+
1771
+
b, err := cr.ReadByte()
1772
+
if err != nil {
1773
+
return err
1774
+
}
1775
+
if b != cbg.CborNull[0] {
1776
+
if err := cr.UnreadByte(); err != nil {
1777
+
return err
1778
+
}
1779
+
t.Inputs[i] = new(GitRefUpdate_IndividualLanguageSize)
1780
+
if err := t.Inputs[i].UnmarshalCBOR(cr); err != nil {
1781
+
return xerrors.Errorf("unmarshaling t.Inputs[i] pointer: %w", err)
1782
+
}
1783
+
}
1784
+
1785
}
1786
+
1787
}
1788
}
1789
1790
default:
···
2469
2470
return nil
2471
}
2472
+
func (t *LabelDefinition) MarshalCBOR(w io.Writer) error {
2473
+
if t == nil {
2474
+
_, err := w.Write(cbg.CborNull)
2475
+
return err
2476
+
}
2477
+
2478
+
cw := cbg.NewCborWriter(w)
2479
+
fieldCount := 7
2480
+
2481
+
if t.Color == nil {
2482
+
fieldCount--
2483
+
}
2484
+
2485
+
if t.Multiple == nil {
2486
+
fieldCount--
2487
+
}
2488
+
2489
+
if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
2490
+
return err
2491
+
}
2492
+
2493
+
// t.Name (string) (string)
2494
+
if len("name") > 1000000 {
2495
+
return xerrors.Errorf("Value in field \"name\" was too long")
2496
+
}
2497
+
2498
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("name"))); err != nil {
2499
+
return err
2500
+
}
2501
+
if _, err := cw.WriteString(string("name")); err != nil {
2502
+
return err
2503
+
}
2504
+
2505
+
if len(t.Name) > 1000000 {
2506
+
return xerrors.Errorf("Value in field t.Name was too long")
2507
+
}
2508
+
2509
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Name))); err != nil {
2510
+
return err
2511
+
}
2512
+
if _, err := cw.WriteString(string(t.Name)); err != nil {
2513
+
return err
2514
+
}
2515
+
2516
+
// t.LexiconTypeID (string) (string)
2517
+
if len("$type") > 1000000 {
2518
+
return xerrors.Errorf("Value in field \"$type\" was too long")
2519
+
}
2520
+
2521
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil {
2522
+
return err
2523
+
}
2524
+
if _, err := cw.WriteString(string("$type")); err != nil {
2525
+
return err
2526
+
}
2527
+
2528
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.label.definition"))); err != nil {
2529
+
return err
2530
+
}
2531
+
if _, err := cw.WriteString(string("sh.tangled.label.definition")); err != nil {
2532
+
return err
2533
+
}
2534
+
2535
+
// t.Color (string) (string)
2536
+
if t.Color != nil {
2537
+
2538
+
if len("color") > 1000000 {
2539
+
return xerrors.Errorf("Value in field \"color\" was too long")
2540
+
}
2541
+
2542
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("color"))); err != nil {
2543
+
return err
2544
+
}
2545
+
if _, err := cw.WriteString(string("color")); err != nil {
2546
+
return err
2547
+
}
2548
+
2549
+
if t.Color == nil {
2550
+
if _, err := cw.Write(cbg.CborNull); err != nil {
2551
+
return err
2552
+
}
2553
+
} else {
2554
+
if len(*t.Color) > 1000000 {
2555
+
return xerrors.Errorf("Value in field t.Color was too long")
2556
+
}
2557
+
2558
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Color))); err != nil {
2559
+
return err
2560
+
}
2561
+
if _, err := cw.WriteString(string(*t.Color)); err != nil {
2562
+
return err
2563
+
}
2564
+
}
2565
+
}
2566
+
2567
+
// t.Scope ([]string) (slice)
2568
+
if len("scope") > 1000000 {
2569
+
return xerrors.Errorf("Value in field \"scope\" was too long")
2570
+
}
2571
+
2572
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("scope"))); err != nil {
2573
+
return err
2574
+
}
2575
+
if _, err := cw.WriteString(string("scope")); err != nil {
2576
+
return err
2577
+
}
2578
+
2579
+
if len(t.Scope) > 8192 {
2580
+
return xerrors.Errorf("Slice value in field t.Scope was too long")
2581
+
}
2582
+
2583
+
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Scope))); err != nil {
2584
+
return err
2585
+
}
2586
+
for _, v := range t.Scope {
2587
+
if len(v) > 1000000 {
2588
+
return xerrors.Errorf("Value in field v was too long")
2589
+
}
2590
+
2591
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
2592
+
return err
2593
+
}
2594
+
if _, err := cw.WriteString(string(v)); err != nil {
2595
+
return err
2596
+
}
2597
+
2598
+
}
2599
+
2600
+
// t.Multiple (bool) (bool)
2601
+
if t.Multiple != nil {
2602
+
2603
+
if len("multiple") > 1000000 {
2604
+
return xerrors.Errorf("Value in field \"multiple\" was too long")
2605
+
}
2606
+
2607
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("multiple"))); err != nil {
2608
+
return err
2609
+
}
2610
+
if _, err := cw.WriteString(string("multiple")); err != nil {
2611
+
return err
2612
+
}
2613
+
2614
+
if t.Multiple == nil {
2615
+
if _, err := cw.Write(cbg.CborNull); err != nil {
2616
+
return err
2617
+
}
2618
+
} else {
2619
+
if err := cbg.WriteBool(w, *t.Multiple); err != nil {
2620
+
return err
2621
+
}
2622
+
}
2623
+
}
2624
+
2625
+
// t.CreatedAt (string) (string)
2626
+
if len("createdAt") > 1000000 {
2627
+
return xerrors.Errorf("Value in field \"createdAt\" was too long")
2628
+
}
2629
+
2630
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil {
2631
+
return err
2632
+
}
2633
+
if _, err := cw.WriteString(string("createdAt")); err != nil {
2634
+
return err
2635
+
}
2636
+
2637
+
if len(t.CreatedAt) > 1000000 {
2638
+
return xerrors.Errorf("Value in field t.CreatedAt was too long")
2639
+
}
2640
+
2641
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil {
2642
+
return err
2643
+
}
2644
+
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
2645
+
return err
2646
+
}
2647
+
2648
+
// t.ValueType (tangled.LabelDefinition_ValueType) (struct)
2649
+
if len("valueType") > 1000000 {
2650
+
return xerrors.Errorf("Value in field \"valueType\" was too long")
2651
+
}
2652
+
2653
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("valueType"))); err != nil {
2654
+
return err
2655
+
}
2656
+
if _, err := cw.WriteString(string("valueType")); err != nil {
2657
+
return err
2658
+
}
2659
+
2660
+
if err := t.ValueType.MarshalCBOR(cw); err != nil {
2661
+
return err
2662
+
}
2663
+
return nil
2664
+
}
2665
+
2666
+
func (t *LabelDefinition) UnmarshalCBOR(r io.Reader) (err error) {
2667
+
*t = LabelDefinition{}
2668
+
2669
+
cr := cbg.NewCborReader(r)
2670
+
2671
+
maj, extra, err := cr.ReadHeader()
2672
+
if err != nil {
2673
+
return err
2674
+
}
2675
+
defer func() {
2676
+
if err == io.EOF {
2677
+
err = io.ErrUnexpectedEOF
2678
+
}
2679
+
}()
2680
+
2681
+
if maj != cbg.MajMap {
2682
+
return fmt.Errorf("cbor input should be of type map")
2683
+
}
2684
+
2685
+
if extra > cbg.MaxLength {
2686
+
return fmt.Errorf("LabelDefinition: map struct too large (%d)", extra)
2687
+
}
2688
+
2689
+
n := extra
2690
+
2691
+
nameBuf := make([]byte, 9)
2692
+
for i := uint64(0); i < n; i++ {
2693
+
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
2694
+
if err != nil {
2695
+
return err
2696
+
}
2697
+
2698
+
if !ok {
2699
+
// Field doesn't exist on this type, so ignore it
2700
+
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
2701
+
return err
2702
+
}
2703
+
continue
2704
+
}
2705
+
2706
+
switch string(nameBuf[:nameLen]) {
2707
+
// t.Name (string) (string)
2708
+
case "name":
2709
+
2710
+
{
2711
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
2712
+
if err != nil {
2713
+
return err
2714
+
}
2715
+
2716
+
t.Name = string(sval)
2717
+
}
2718
+
// t.LexiconTypeID (string) (string)
2719
+
case "$type":
2720
+
2721
+
{
2722
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
2723
+
if err != nil {
2724
+
return err
2725
+
}
2726
+
2727
+
t.LexiconTypeID = string(sval)
2728
+
}
2729
+
// t.Color (string) (string)
2730
+
case "color":
2731
+
2732
+
{
2733
+
b, err := cr.ReadByte()
2734
+
if err != nil {
2735
+
return err
2736
+
}
2737
+
if b != cbg.CborNull[0] {
2738
+
if err := cr.UnreadByte(); err != nil {
2739
+
return err
2740
+
}
2741
+
2742
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
2743
+
if err != nil {
2744
+
return err
2745
+
}
2746
+
2747
+
t.Color = (*string)(&sval)
2748
+
}
2749
+
}
2750
+
// t.Scope ([]string) (slice)
2751
+
case "scope":
2752
+
2753
+
maj, extra, err = cr.ReadHeader()
2754
+
if err != nil {
2755
+
return err
2756
+
}
2757
+
2758
+
if extra > 8192 {
2759
+
return fmt.Errorf("t.Scope: array too large (%d)", extra)
2760
+
}
2761
+
2762
+
if maj != cbg.MajArray {
2763
+
return fmt.Errorf("expected cbor array")
2764
+
}
2765
+
2766
+
if extra > 0 {
2767
+
t.Scope = make([]string, extra)
2768
+
}
2769
+
2770
+
for i := 0; i < int(extra); i++ {
2771
+
{
2772
+
var maj byte
2773
+
var extra uint64
2774
+
var err error
2775
+
_ = maj
2776
+
_ = extra
2777
+
_ = err
2778
+
2779
+
{
2780
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
2781
+
if err != nil {
2782
+
return err
2783
+
}
2784
+
2785
+
t.Scope[i] = string(sval)
2786
+
}
2787
+
2788
+
}
2789
+
}
2790
+
// t.Multiple (bool) (bool)
2791
+
case "multiple":
2792
+
2793
+
{
2794
+
b, err := cr.ReadByte()
2795
+
if err != nil {
2796
+
return err
2797
+
}
2798
+
if b != cbg.CborNull[0] {
2799
+
if err := cr.UnreadByte(); err != nil {
2800
+
return err
2801
+
}
2802
+
2803
+
maj, extra, err = cr.ReadHeader()
2804
+
if err != nil {
2805
+
return err
2806
+
}
2807
+
if maj != cbg.MajOther {
2808
+
return fmt.Errorf("booleans must be major type 7")
2809
+
}
2810
+
2811
+
var val bool
2812
+
switch extra {
2813
+
case 20:
2814
+
val = false
2815
+
case 21:
2816
+
val = true
2817
+
default:
2818
+
return fmt.Errorf("booleans are either major type 7, value 20 or 21 (got %d)", extra)
2819
+
}
2820
+
t.Multiple = &val
2821
+
}
2822
+
}
2823
+
// t.CreatedAt (string) (string)
2824
+
case "createdAt":
2825
+
2826
+
{
2827
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
2828
+
if err != nil {
2829
+
return err
2830
+
}
2831
+
2832
+
t.CreatedAt = string(sval)
2833
+
}
2834
+
// t.ValueType (tangled.LabelDefinition_ValueType) (struct)
2835
+
case "valueType":
2836
+
2837
+
{
2838
+
2839
+
b, err := cr.ReadByte()
2840
+
if err != nil {
2841
+
return err
2842
+
}
2843
+
if b != cbg.CborNull[0] {
2844
+
if err := cr.UnreadByte(); err != nil {
2845
+
return err
2846
+
}
2847
+
t.ValueType = new(LabelDefinition_ValueType)
2848
+
if err := t.ValueType.UnmarshalCBOR(cr); err != nil {
2849
+
return xerrors.Errorf("unmarshaling t.ValueType pointer: %w", err)
2850
+
}
2851
+
}
2852
+
2853
+
}
2854
+
2855
+
default:
2856
+
// Field doesn't exist on this type, so ignore it
2857
+
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
2858
+
return err
2859
+
}
2860
+
}
2861
+
}
2862
+
2863
+
return nil
2864
+
}
2865
+
func (t *LabelDefinition_ValueType) MarshalCBOR(w io.Writer) error {
2866
+
if t == nil {
2867
+
_, err := w.Write(cbg.CborNull)
2868
+
return err
2869
+
}
2870
+
2871
+
cw := cbg.NewCborWriter(w)
2872
+
fieldCount := 3
2873
+
2874
+
if t.Enum == nil {
2875
+
fieldCount--
2876
+
}
2877
+
2878
+
if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
2879
+
return err
2880
+
}
2881
+
2882
+
// t.Enum ([]string) (slice)
2883
+
if t.Enum != nil {
2884
+
2885
+
if len("enum") > 1000000 {
2886
+
return xerrors.Errorf("Value in field \"enum\" was too long")
2887
+
}
2888
+
2889
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("enum"))); err != nil {
2890
+
return err
2891
+
}
2892
+
if _, err := cw.WriteString(string("enum")); err != nil {
2893
+
return err
2894
+
}
2895
+
2896
+
if len(t.Enum) > 8192 {
2897
+
return xerrors.Errorf("Slice value in field t.Enum was too long")
2898
+
}
2899
+
2900
+
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Enum))); err != nil {
2901
+
return err
2902
+
}
2903
+
for _, v := range t.Enum {
2904
+
if len(v) > 1000000 {
2905
+
return xerrors.Errorf("Value in field v was too long")
2906
+
}
2907
+
2908
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
2909
+
return err
2910
+
}
2911
+
if _, err := cw.WriteString(string(v)); err != nil {
2912
+
return err
2913
+
}
2914
+
2915
+
}
2916
+
}
2917
+
2918
+
// t.Type (string) (string)
2919
+
if len("type") > 1000000 {
2920
+
return xerrors.Errorf("Value in field \"type\" was too long")
2921
+
}
2922
+
2923
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("type"))); err != nil {
2924
+
return err
2925
+
}
2926
+
if _, err := cw.WriteString(string("type")); err != nil {
2927
+
return err
2928
+
}
2929
+
2930
+
if len(t.Type) > 1000000 {
2931
+
return xerrors.Errorf("Value in field t.Type was too long")
2932
+
}
2933
+
2934
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Type))); err != nil {
2935
+
return err
2936
+
}
2937
+
if _, err := cw.WriteString(string(t.Type)); err != nil {
2938
+
return err
2939
+
}
2940
+
2941
+
// t.Format (string) (string)
2942
+
if len("format") > 1000000 {
2943
+
return xerrors.Errorf("Value in field \"format\" was too long")
2944
+
}
2945
+
2946
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("format"))); err != nil {
2947
+
return err
2948
+
}
2949
+
if _, err := cw.WriteString(string("format")); err != nil {
2950
+
return err
2951
+
}
2952
+
2953
+
if len(t.Format) > 1000000 {
2954
+
return xerrors.Errorf("Value in field t.Format was too long")
2955
+
}
2956
+
2957
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Format))); err != nil {
2958
+
return err
2959
+
}
2960
+
if _, err := cw.WriteString(string(t.Format)); err != nil {
2961
+
return err
2962
+
}
2963
+
return nil
2964
+
}
2965
+
2966
+
func (t *LabelDefinition_ValueType) UnmarshalCBOR(r io.Reader) (err error) {
2967
+
*t = LabelDefinition_ValueType{}
2968
+
2969
+
cr := cbg.NewCborReader(r)
2970
+
2971
+
maj, extra, err := cr.ReadHeader()
2972
+
if err != nil {
2973
+
return err
2974
+
}
2975
+
defer func() {
2976
+
if err == io.EOF {
2977
+
err = io.ErrUnexpectedEOF
2978
+
}
2979
+
}()
2980
+
2981
+
if maj != cbg.MajMap {
2982
+
return fmt.Errorf("cbor input should be of type map")
2983
+
}
2984
+
2985
+
if extra > cbg.MaxLength {
2986
+
return fmt.Errorf("LabelDefinition_ValueType: map struct too large (%d)", extra)
2987
+
}
2988
+
2989
+
n := extra
2990
+
2991
+
nameBuf := make([]byte, 6)
2992
+
for i := uint64(0); i < n; i++ {
2993
+
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
2994
+
if err != nil {
2995
+
return err
2996
+
}
2997
+
2998
+
if !ok {
2999
+
// Field doesn't exist on this type, so ignore it
3000
+
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
3001
+
return err
3002
+
}
3003
+
continue
3004
+
}
3005
+
3006
+
switch string(nameBuf[:nameLen]) {
3007
+
// t.Enum ([]string) (slice)
3008
+
case "enum":
3009
+
3010
+
maj, extra, err = cr.ReadHeader()
3011
+
if err != nil {
3012
+
return err
3013
+
}
3014
+
3015
+
if extra > 8192 {
3016
+
return fmt.Errorf("t.Enum: array too large (%d)", extra)
3017
+
}
3018
+
3019
+
if maj != cbg.MajArray {
3020
+
return fmt.Errorf("expected cbor array")
3021
+
}
3022
+
3023
+
if extra > 0 {
3024
+
t.Enum = make([]string, extra)
3025
+
}
3026
+
3027
+
for i := 0; i < int(extra); i++ {
3028
+
{
3029
+
var maj byte
3030
+
var extra uint64
3031
+
var err error
3032
+
_ = maj
3033
+
_ = extra
3034
+
_ = err
3035
+
3036
+
{
3037
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
3038
+
if err != nil {
3039
+
return err
3040
+
}
3041
+
3042
+
t.Enum[i] = string(sval)
3043
+
}
3044
+
3045
+
}
3046
+
}
3047
+
// t.Type (string) (string)
3048
+
case "type":
3049
+
3050
+
{
3051
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
3052
+
if err != nil {
3053
+
return err
3054
+
}
3055
+
3056
+
t.Type = string(sval)
3057
+
}
3058
+
// t.Format (string) (string)
3059
+
case "format":
3060
+
3061
+
{
3062
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
3063
+
if err != nil {
3064
+
return err
3065
+
}
3066
+
3067
+
t.Format = string(sval)
3068
+
}
3069
+
3070
+
default:
3071
+
// Field doesn't exist on this type, so ignore it
3072
+
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
3073
+
return err
3074
+
}
3075
+
}
3076
+
}
3077
+
3078
+
return nil
3079
+
}
3080
+
func (t *LabelOp) MarshalCBOR(w io.Writer) error {
3081
+
if t == nil {
3082
+
_, err := w.Write(cbg.CborNull)
3083
+
return err
3084
+
}
3085
+
3086
+
cw := cbg.NewCborWriter(w)
3087
+
3088
+
if _, err := cw.Write([]byte{165}); err != nil {
3089
+
return err
3090
+
}
3091
+
3092
+
// t.Add ([]*tangled.LabelOp_Operand) (slice)
3093
+
if len("add") > 1000000 {
3094
+
return xerrors.Errorf("Value in field \"add\" was too long")
3095
+
}
3096
+
3097
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("add"))); err != nil {
3098
+
return err
3099
+
}
3100
+
if _, err := cw.WriteString(string("add")); err != nil {
3101
+
return err
3102
+
}
3103
+
3104
+
if len(t.Add) > 8192 {
3105
+
return xerrors.Errorf("Slice value in field t.Add was too long")
3106
+
}
3107
+
3108
+
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Add))); err != nil {
3109
+
return err
3110
+
}
3111
+
for _, v := range t.Add {
3112
+
if err := v.MarshalCBOR(cw); err != nil {
3113
+
return err
3114
+
}
3115
+
3116
+
}
3117
+
3118
+
// t.LexiconTypeID (string) (string)
3119
+
if len("$type") > 1000000 {
3120
+
return xerrors.Errorf("Value in field \"$type\" was too long")
3121
+
}
3122
+
3123
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil {
3124
+
return err
3125
+
}
3126
+
if _, err := cw.WriteString(string("$type")); err != nil {
3127
+
return err
3128
+
}
3129
+
3130
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.label.op"))); err != nil {
3131
+
return err
3132
+
}
3133
+
if _, err := cw.WriteString(string("sh.tangled.label.op")); err != nil {
3134
+
return err
3135
+
}
3136
+
3137
+
// t.Delete ([]*tangled.LabelOp_Operand) (slice)
3138
+
if len("delete") > 1000000 {
3139
+
return xerrors.Errorf("Value in field \"delete\" was too long")
3140
+
}
3141
+
3142
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("delete"))); err != nil {
3143
+
return err
3144
+
}
3145
+
if _, err := cw.WriteString(string("delete")); err != nil {
3146
+
return err
3147
+
}
3148
+
3149
+
if len(t.Delete) > 8192 {
3150
+
return xerrors.Errorf("Slice value in field t.Delete was too long")
3151
+
}
3152
+
3153
+
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Delete))); err != nil {
3154
+
return err
3155
+
}
3156
+
for _, v := range t.Delete {
3157
+
if err := v.MarshalCBOR(cw); err != nil {
3158
+
return err
3159
+
}
3160
+
3161
+
}
3162
+
3163
+
// t.Subject (string) (string)
3164
+
if len("subject") > 1000000 {
3165
+
return xerrors.Errorf("Value in field \"subject\" was too long")
3166
+
}
3167
+
3168
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("subject"))); err != nil {
3169
+
return err
3170
+
}
3171
+
if _, err := cw.WriteString(string("subject")); err != nil {
3172
+
return err
3173
+
}
3174
+
3175
+
if len(t.Subject) > 1000000 {
3176
+
return xerrors.Errorf("Value in field t.Subject was too long")
3177
+
}
3178
+
3179
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Subject))); err != nil {
3180
+
return err
3181
+
}
3182
+
if _, err := cw.WriteString(string(t.Subject)); err != nil {
3183
+
return err
3184
+
}
3185
+
3186
+
// t.PerformedAt (string) (string)
3187
+
if len("performedAt") > 1000000 {
3188
+
return xerrors.Errorf("Value in field \"performedAt\" was too long")
3189
+
}
3190
+
3191
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("performedAt"))); err != nil {
3192
+
return err
3193
+
}
3194
+
if _, err := cw.WriteString(string("performedAt")); err != nil {
3195
+
return err
3196
+
}
3197
+
3198
+
if len(t.PerformedAt) > 1000000 {
3199
+
return xerrors.Errorf("Value in field t.PerformedAt was too long")
3200
+
}
3201
+
3202
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.PerformedAt))); err != nil {
3203
+
return err
3204
+
}
3205
+
if _, err := cw.WriteString(string(t.PerformedAt)); err != nil {
3206
+
return err
3207
+
}
3208
+
return nil
3209
+
}
3210
+
3211
+
func (t *LabelOp) UnmarshalCBOR(r io.Reader) (err error) {
3212
+
*t = LabelOp{}
3213
+
3214
+
cr := cbg.NewCborReader(r)
3215
+
3216
+
maj, extra, err := cr.ReadHeader()
3217
+
if err != nil {
3218
+
return err
3219
+
}
3220
+
defer func() {
3221
+
if err == io.EOF {
3222
+
err = io.ErrUnexpectedEOF
3223
+
}
3224
+
}()
3225
+
3226
+
if maj != cbg.MajMap {
3227
+
return fmt.Errorf("cbor input should be of type map")
3228
+
}
3229
+
3230
+
if extra > cbg.MaxLength {
3231
+
return fmt.Errorf("LabelOp: map struct too large (%d)", extra)
3232
+
}
3233
+
3234
+
n := extra
3235
+
3236
+
nameBuf := make([]byte, 11)
3237
+
for i := uint64(0); i < n; i++ {
3238
+
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
3239
+
if err != nil {
3240
+
return err
3241
+
}
3242
+
3243
+
if !ok {
3244
+
// Field doesn't exist on this type, so ignore it
3245
+
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
3246
+
return err
3247
+
}
3248
+
continue
3249
+
}
3250
+
3251
+
switch string(nameBuf[:nameLen]) {
3252
+
// t.Add ([]*tangled.LabelOp_Operand) (slice)
3253
+
case "add":
3254
+
3255
+
maj, extra, err = cr.ReadHeader()
3256
+
if err != nil {
3257
+
return err
3258
+
}
3259
+
3260
+
if extra > 8192 {
3261
+
return fmt.Errorf("t.Add: array too large (%d)", extra)
3262
+
}
3263
+
3264
+
if maj != cbg.MajArray {
3265
+
return fmt.Errorf("expected cbor array")
3266
+
}
3267
+
3268
+
if extra > 0 {
3269
+
t.Add = make([]*LabelOp_Operand, extra)
3270
+
}
3271
+
3272
+
for i := 0; i < int(extra); i++ {
3273
+
{
3274
+
var maj byte
3275
+
var extra uint64
3276
+
var err error
3277
+
_ = maj
3278
+
_ = extra
3279
+
_ = err
3280
+
3281
+
{
3282
+
3283
+
b, err := cr.ReadByte()
3284
+
if err != nil {
3285
+
return err
3286
+
}
3287
+
if b != cbg.CborNull[0] {
3288
+
if err := cr.UnreadByte(); err != nil {
3289
+
return err
3290
+
}
3291
+
t.Add[i] = new(LabelOp_Operand)
3292
+
if err := t.Add[i].UnmarshalCBOR(cr); err != nil {
3293
+
return xerrors.Errorf("unmarshaling t.Add[i] pointer: %w", err)
3294
+
}
3295
+
}
3296
+
3297
+
}
3298
+
3299
+
}
3300
+
}
3301
+
// t.LexiconTypeID (string) (string)
3302
+
case "$type":
3303
+
3304
+
{
3305
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
3306
+
if err != nil {
3307
+
return err
3308
+
}
3309
+
3310
+
t.LexiconTypeID = string(sval)
3311
+
}
3312
+
// t.Delete ([]*tangled.LabelOp_Operand) (slice)
3313
+
case "delete":
3314
+
3315
+
maj, extra, err = cr.ReadHeader()
3316
+
if err != nil {
3317
+
return err
3318
+
}
3319
+
3320
+
if extra > 8192 {
3321
+
return fmt.Errorf("t.Delete: array too large (%d)", extra)
3322
+
}
3323
+
3324
+
if maj != cbg.MajArray {
3325
+
return fmt.Errorf("expected cbor array")
3326
+
}
3327
+
3328
+
if extra > 0 {
3329
+
t.Delete = make([]*LabelOp_Operand, extra)
3330
+
}
3331
+
3332
+
for i := 0; i < int(extra); i++ {
3333
+
{
3334
+
var maj byte
3335
+
var extra uint64
3336
+
var err error
3337
+
_ = maj
3338
+
_ = extra
3339
+
_ = err
3340
+
3341
+
{
3342
+
3343
+
b, err := cr.ReadByte()
3344
+
if err != nil {
3345
+
return err
3346
+
}
3347
+
if b != cbg.CborNull[0] {
3348
+
if err := cr.UnreadByte(); err != nil {
3349
+
return err
3350
+
}
3351
+
t.Delete[i] = new(LabelOp_Operand)
3352
+
if err := t.Delete[i].UnmarshalCBOR(cr); err != nil {
3353
+
return xerrors.Errorf("unmarshaling t.Delete[i] pointer: %w", err)
3354
+
}
3355
+
}
3356
+
3357
+
}
3358
+
3359
+
}
3360
+
}
3361
+
// t.Subject (string) (string)
3362
+
case "subject":
3363
+
3364
+
{
3365
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
3366
+
if err != nil {
3367
+
return err
3368
+
}
3369
+
3370
+
t.Subject = string(sval)
3371
+
}
3372
+
// t.PerformedAt (string) (string)
3373
+
case "performedAt":
3374
+
3375
+
{
3376
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
3377
+
if err != nil {
3378
+
return err
3379
+
}
3380
+
3381
+
t.PerformedAt = string(sval)
3382
+
}
3383
+
3384
+
default:
3385
+
// Field doesn't exist on this type, so ignore it
3386
+
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
3387
+
return err
3388
+
}
3389
+
}
3390
+
}
3391
+
3392
+
return nil
3393
+
}
3394
+
func (t *LabelOp_Operand) MarshalCBOR(w io.Writer) error {
3395
+
if t == nil {
3396
+
_, err := w.Write(cbg.CborNull)
3397
+
return err
3398
+
}
3399
+
3400
+
cw := cbg.NewCborWriter(w)
3401
+
3402
+
if _, err := cw.Write([]byte{162}); err != nil {
3403
+
return err
3404
+
}
3405
+
3406
+
// t.Key (string) (string)
3407
+
if len("key") > 1000000 {
3408
+
return xerrors.Errorf("Value in field \"key\" was too long")
3409
+
}
3410
+
3411
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("key"))); err != nil {
3412
+
return err
3413
+
}
3414
+
if _, err := cw.WriteString(string("key")); err != nil {
3415
+
return err
3416
+
}
3417
+
3418
+
if len(t.Key) > 1000000 {
3419
+
return xerrors.Errorf("Value in field t.Key was too long")
3420
+
}
3421
+
3422
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Key))); err != nil {
3423
+
return err
3424
+
}
3425
+
if _, err := cw.WriteString(string(t.Key)); err != nil {
3426
+
return err
3427
+
}
3428
+
3429
+
// t.Value (string) (string)
3430
+
if len("value") > 1000000 {
3431
+
return xerrors.Errorf("Value in field \"value\" was too long")
3432
+
}
3433
+
3434
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("value"))); err != nil {
3435
+
return err
3436
+
}
3437
+
if _, err := cw.WriteString(string("value")); err != nil {
3438
+
return err
3439
+
}
3440
+
3441
+
if len(t.Value) > 1000000 {
3442
+
return xerrors.Errorf("Value in field t.Value was too long")
3443
+
}
3444
+
3445
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Value))); err != nil {
3446
+
return err
3447
+
}
3448
+
if _, err := cw.WriteString(string(t.Value)); err != nil {
3449
+
return err
3450
+
}
3451
+
return nil
3452
+
}
3453
+
3454
+
func (t *LabelOp_Operand) UnmarshalCBOR(r io.Reader) (err error) {
3455
+
*t = LabelOp_Operand{}
3456
+
3457
+
cr := cbg.NewCborReader(r)
3458
+
3459
+
maj, extra, err := cr.ReadHeader()
3460
+
if err != nil {
3461
+
return err
3462
+
}
3463
+
defer func() {
3464
+
if err == io.EOF {
3465
+
err = io.ErrUnexpectedEOF
3466
+
}
3467
+
}()
3468
+
3469
+
if maj != cbg.MajMap {
3470
+
return fmt.Errorf("cbor input should be of type map")
3471
+
}
3472
+
3473
+
if extra > cbg.MaxLength {
3474
+
return fmt.Errorf("LabelOp_Operand: map struct too large (%d)", extra)
3475
+
}
3476
+
3477
+
n := extra
3478
+
3479
+
nameBuf := make([]byte, 5)
3480
+
for i := uint64(0); i < n; i++ {
3481
+
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
3482
+
if err != nil {
3483
+
return err
3484
+
}
3485
+
3486
+
if !ok {
3487
+
// Field doesn't exist on this type, so ignore it
3488
+
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
3489
+
return err
3490
+
}
3491
+
continue
3492
+
}
3493
+
3494
+
switch string(nameBuf[:nameLen]) {
3495
+
// t.Key (string) (string)
3496
+
case "key":
3497
+
3498
+
{
3499
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
3500
+
if err != nil {
3501
+
return err
3502
+
}
3503
+
3504
+
t.Key = string(sval)
3505
+
}
3506
+
// t.Value (string) (string)
3507
+
case "value":
3508
+
3509
+
{
3510
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
3511
+
if err != nil {
3512
+
return err
3513
+
}
3514
+
3515
+
t.Value = string(sval)
3516
+
}
3517
+
3518
+
default:
3519
+
// Field doesn't exist on this type, so ignore it
3520
+
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
3521
+
return err
3522
+
}
3523
+
}
3524
+
}
3525
+
3526
+
return nil
3527
+
}
3528
func (t *Pipeline) MarshalCBOR(w io.Writer) error {
3529
if t == nil {
3530
_, err := w.Write(cbg.CborNull)
···
5812
fieldCount--
5813
}
5814
5815
+
if t.Labels == nil {
5816
+
fieldCount--
5817
+
}
5818
+
5819
if t.Source == nil {
5820
fieldCount--
5821
}
···
5893
return err
5894
}
5895
5896
+
// t.Labels ([]string) (slice)
5897
+
if t.Labels != nil {
5898
+
5899
+
if len("labels") > 1000000 {
5900
+
return xerrors.Errorf("Value in field \"labels\" was too long")
5901
+
}
5902
+
5903
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("labels"))); err != nil {
5904
+
return err
5905
+
}
5906
+
if _, err := cw.WriteString(string("labels")); err != nil {
5907
+
return err
5908
+
}
5909
5910
+
if len(t.Labels) > 8192 {
5911
+
return xerrors.Errorf("Slice value in field t.Labels was too long")
5912
+
}
5913
+
5914
+
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Labels))); err != nil {
5915
+
return err
5916
+
}
5917
+
for _, v := range t.Labels {
5918
+
if len(v) > 1000000 {
5919
+
return xerrors.Errorf("Value in field v was too long")
5920
+
}
5921
5922
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
5923
+
return err
5924
+
}
5925
+
if _, err := cw.WriteString(string(v)); err != nil {
5926
+
return err
5927
+
}
5928
5929
+
}
5930
}
5931
5932
// t.Source (string) (string)
···
6124
6125
t.LexiconTypeID = string(sval)
6126
}
6127
+
// t.Labels ([]string) (slice)
6128
+
case "labels":
6129
+
6130
+
maj, extra, err = cr.ReadHeader()
6131
+
if err != nil {
6132
+
return err
6133
+
}
6134
+
6135
+
if extra > 8192 {
6136
+
return fmt.Errorf("t.Labels: array too large (%d)", extra)
6137
+
}
6138
+
6139
+
if maj != cbg.MajArray {
6140
+
return fmt.Errorf("expected cbor array")
6141
+
}
6142
+
6143
+
if extra > 0 {
6144
+
t.Labels = make([]string, extra)
6145
+
}
6146
+
6147
+
for i := 0; i < int(extra); i++ {
6148
+
{
6149
+
var maj byte
6150
+
var extra uint64
6151
+
var err error
6152
+
_ = maj
6153
+
_ = extra
6154
+
_ = err
6155
6156
+
{
6157
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
6158
+
if err != nil {
6159
+
return err
6160
+
}
6161
+
6162
+
t.Labels[i] = string(sval)
6163
+
}
6164
+
6165
}
6166
}
6167
// t.Source (string) (string)
6168
case "source":
+42
api/tangled/labeldefinition.go
+42
api/tangled/labeldefinition.go
···
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.label.definition
6
+
7
+
import (
8
+
"github.com/bluesky-social/indigo/lex/util"
9
+
)
10
+
11
+
const (
12
+
LabelDefinitionNSID = "sh.tangled.label.definition"
13
+
)
14
+
15
+
func init() {
16
+
util.RegisterType("sh.tangled.label.definition", &LabelDefinition{})
17
+
} //
18
+
// RECORDTYPE: LabelDefinition
19
+
type LabelDefinition struct {
20
+
LexiconTypeID string `json:"$type,const=sh.tangled.label.definition" cborgen:"$type,const=sh.tangled.label.definition"`
21
+
// color: The hex value for the background color for the label. Appviews may choose to respect this.
22
+
Color *string `json:"color,omitempty" cborgen:"color,omitempty"`
23
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
24
+
// multiple: Whether this label can be repeated for a given entity, eg.: [reviewer:foo, reviewer:bar]
25
+
Multiple *bool `json:"multiple,omitempty" cborgen:"multiple,omitempty"`
26
+
// name: The display name of this label.
27
+
Name string `json:"name" cborgen:"name"`
28
+
// scope: The areas of the repo this label may apply to, eg.: sh.tangled.repo.issue. Appviews may choose to respect this.
29
+
Scope []string `json:"scope" cborgen:"scope"`
30
+
// valueType: The type definition of this label. Appviews may allow sorting for certain types.
31
+
ValueType *LabelDefinition_ValueType `json:"valueType" cborgen:"valueType"`
32
+
}
33
+
34
+
// LabelDefinition_ValueType is a "valueType" in the sh.tangled.label.definition schema.
35
+
type LabelDefinition_ValueType struct {
36
+
// enum: Closed set of values that this label can take.
37
+
Enum []string `json:"enum,omitempty" cborgen:"enum,omitempty"`
38
+
// format: An optional constraint that can be applied on string concrete types.
39
+
Format string `json:"format" cborgen:"format"`
40
+
// type: The concrete type of this label's value.
41
+
Type string `json:"type" cborgen:"type"`
42
+
}
+34
api/tangled/labelop.go
+34
api/tangled/labelop.go
···
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.label.op
6
+
7
+
import (
8
+
"github.com/bluesky-social/indigo/lex/util"
9
+
)
10
+
11
+
const (
12
+
LabelOpNSID = "sh.tangled.label.op"
13
+
)
14
+
15
+
func init() {
16
+
util.RegisterType("sh.tangled.label.op", &LabelOp{})
17
+
} //
18
+
// RECORDTYPE: LabelOp
19
+
type LabelOp struct {
20
+
LexiconTypeID string `json:"$type,const=sh.tangled.label.op" cborgen:"$type,const=sh.tangled.label.op"`
21
+
Add []*LabelOp_Operand `json:"add" cborgen:"add"`
22
+
Delete []*LabelOp_Operand `json:"delete" cborgen:"delete"`
23
+
PerformedAt string `json:"performedAt" cborgen:"performedAt"`
24
+
// subject: The subject (task, pull or discussion) of this label. Appviews may apply a `scope` check and refuse this op.
25
+
Subject string `json:"subject" cborgen:"subject"`
26
+
}
27
+
28
+
// LabelOp_Operand is a "operand" in the sh.tangled.label.op schema.
29
+
type LabelOp_Operand struct {
30
+
// key: ATURI to the label definition
31
+
Key string `json:"key" cborgen:"key"`
32
+
// value: Stringified value of the label. This is first unstringed by appviews and then interpreted as a concrete value.
33
+
Value string `json:"value" cborgen:"value"`
34
+
}
+10
api/tangled/repotree.go
+10
api/tangled/repotree.go
···
31
Files []*RepoTree_TreeEntry `json:"files" cborgen:"files"`
32
// parent: The parent path in the tree
33
Parent *string `json:"parent,omitempty" cborgen:"parent,omitempty"`
34
// ref: The git reference used
35
Ref string `json:"ref" cborgen:"ref"`
36
}
37
38
// RepoTree_TreeEntry is a "treeEntry" in the sh.tangled.repo.tree schema.
···
31
Files []*RepoTree_TreeEntry `json:"files" cborgen:"files"`
32
// parent: The parent path in the tree
33
Parent *string `json:"parent,omitempty" cborgen:"parent,omitempty"`
34
+
// readme: Readme for this file tree
35
+
Readme *RepoTree_Readme `json:"readme,omitempty" cborgen:"readme,omitempty"`
36
// ref: The git reference used
37
Ref string `json:"ref" cborgen:"ref"`
38
+
}
39
+
40
+
// RepoTree_Readme is a "readme" in the sh.tangled.repo.tree schema.
41
+
type RepoTree_Readme struct {
42
+
// contents: Contents of the readme file
43
+
Contents string `json:"contents" cborgen:"contents"`
44
+
// filename: Name of the readme file
45
+
Filename string `json:"filename" cborgen:"filename"`
46
}
47
48
// RepoTree_TreeEntry is a "treeEntry" in the sh.tangled.repo.tree schema.
+3
-2
api/tangled/tangledrepo.go
+3
-2
api/tangled/tangledrepo.go
···
22
Description *string `json:"description,omitempty" cborgen:"description,omitempty"`
23
// knot: knot where the repo was created
24
Knot string `json:"knot" cborgen:"knot"`
25
// name: name of the repo
26
-
Name string `json:"name" cborgen:"name"`
27
-
Owner string `json:"owner" cborgen:"owner"`
28
// source: source of the repo
29
Source *string `json:"source,omitempty" cborgen:"source,omitempty"`
30
// spindle: CI runner to send jobs to and receive results from
···
22
Description *string `json:"description,omitempty" cborgen:"description,omitempty"`
23
// knot: knot where the repo was created
24
Knot string `json:"knot" cborgen:"knot"`
25
+
// labels: List of labels that this repo subscribes to
26
+
Labels []string `json:"labels,omitempty" cborgen:"labels,omitempty"`
27
// name: name of the repo
28
+
Name string `json:"name" cborgen:"name"`
29
// source: source of the repo
30
Source *string `json:"source,omitempty" cborgen:"source,omitempty"`
31
// spindle: CI runner to send jobs to and receive results from
+1
-1
appview/cache/session/store.go
+1
-1
appview/cache/session/store.go
+5
-4
appview/commitverify/verify.go
+5
-4
appview/commitverify/verify.go
···
4
"log"
5
6
"github.com/go-git/go-git/v5/plumbing/object"
7
-
"tangled.sh/tangled.sh/core/appview/db"
8
-
"tangled.sh/tangled.sh/core/crypto"
9
-
"tangled.sh/tangled.sh/core/types"
10
)
11
12
type verifiedCommit struct {
···
45
func GetVerifiedCommits(e db.Execer, emailToDid map[string]string, ndCommits []types.NiceDiff) (VerifiedCommits, error) {
46
vcs := VerifiedCommits{}
47
48
-
didPubkeyCache := make(map[string][]db.PublicKey)
49
50
for _, commit := range ndCommits {
51
c := commit.Commit
···
4
"log"
5
6
"github.com/go-git/go-git/v5/plumbing/object"
7
+
"tangled.org/core/appview/db"
8
+
"tangled.org/core/appview/models"
9
+
"tangled.org/core/crypto"
10
+
"tangled.org/core/types"
11
)
12
13
type verifiedCommit struct {
···
46
func GetVerifiedCommits(e db.Execer, emailToDid map[string]string, ndCommits []types.NiceDiff) (VerifiedCommits, error) {
47
vcs := VerifiedCommits{}
48
49
+
didPubkeyCache := make(map[string][]models.PublicKey)
50
51
for _, commit := range ndCommits {
52
c := commit.Commit
+4
-2
appview/config/config.go
+4
-2
appview/config/config.go
+5
-25
appview/db/artifact.go
+5
-25
appview/db/artifact.go
···
5
"strings"
6
"time"
7
8
-
"github.com/bluesky-social/indigo/atproto/syntax"
9
"github.com/go-git/go-git/v5/plumbing"
10
"github.com/ipfs/go-cid"
11
-
"tangled.sh/tangled.sh/core/api/tangled"
12
)
13
14
-
type Artifact struct {
15
-
Id uint64
16
-
Did string
17
-
Rkey string
18
-
19
-
RepoAt syntax.ATURI
20
-
Tag plumbing.Hash
21
-
CreatedAt time.Time
22
-
23
-
BlobCid cid.Cid
24
-
Name string
25
-
Size uint64
26
-
MimeType string
27
-
}
28
-
29
-
func (a *Artifact) ArtifactAt() syntax.ATURI {
30
-
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", a.Did, tangled.RepoArtifactNSID, a.Rkey))
31
-
}
32
-
33
-
func AddArtifact(e Execer, artifact Artifact) error {
34
_, err := e.Exec(
35
`insert or ignore into artifacts (
36
did,
···
57
return err
58
}
59
60
-
func GetArtifact(e Execer, filters ...filter) ([]Artifact, error) {
61
-
var artifacts []Artifact
62
63
var conditions []string
64
var args []any
···
94
defer rows.Close()
95
96
for rows.Next() {
97
-
var artifact Artifact
98
var createdAt string
99
var tag []byte
100
var blobCid string
···
5
"strings"
6
"time"
7
8
"github.com/go-git/go-git/v5/plumbing"
9
"github.com/ipfs/go-cid"
10
+
"tangled.org/core/appview/models"
11
)
12
13
+
func AddArtifact(e Execer, artifact models.Artifact) error {
14
_, err := e.Exec(
15
`insert or ignore into artifacts (
16
did,
···
37
return err
38
}
39
40
+
func GetArtifact(e Execer, filters ...filter) ([]models.Artifact, error) {
41
+
var artifacts []models.Artifact
42
43
var conditions []string
44
var args []any
···
74
defer rows.Close()
75
76
for rows.Next() {
77
+
var artifact models.Artifact
78
var createdAt string
79
var tag []byte
80
var blobCid string
+3
-18
appview/db/collaborators.go
+3
-18
appview/db/collaborators.go
···
3
import (
4
"fmt"
5
"strings"
6
-
"time"
7
8
-
"github.com/bluesky-social/indigo/atproto/syntax"
9
)
10
11
-
type Collaborator struct {
12
-
// identifiers for the record
13
-
Id int64
14
-
Did syntax.DID
15
-
Rkey string
16
-
17
-
// content
18
-
SubjectDid syntax.DID
19
-
RepoAt syntax.ATURI
20
-
21
-
// meta
22
-
Created time.Time
23
-
}
24
-
25
-
func AddCollaborator(e Execer, c Collaborator) error {
26
_, err := e.Exec(
27
`insert into collaborators (did, rkey, subject_did, repo_at) values (?, ?, ?, ?);`,
28
c.Did, c.Rkey, c.SubjectDid, c.RepoAt,
···
49
return err
50
}
51
52
-
func CollaboratingIn(e Execer, collaborator string) ([]Repo, error) {
53
rows, err := e.Query(`select repo_at from collaborators where subject_did = ?`, collaborator)
54
if err != nil {
55
return nil, err
···
3
import (
4
"fmt"
5
"strings"
6
7
+
"tangled.org/core/appview/models"
8
)
9
10
+
func AddCollaborator(e Execer, c models.Collaborator) error {
11
_, err := e.Exec(
12
`insert into collaborators (did, rkey, subject_did, repo_at) values (?, ?, ?, ?);`,
13
c.Did, c.Rkey, c.SubjectDid, c.RepoAt,
···
34
return err
35
}
36
37
+
func CollaboratingIn(e Execer, collaborator string) ([]models.Repo, error) {
38
rows, err := e.Query(`select repo_at from collaborators where subject_did = ?`, collaborator)
39
if err != nil {
40
return nil, err
+247
-16
appview/db/db.go
+247
-16
appview/db/db.go
···
466
primary key (did, rkey)
467
);
468
469
create table if not exists migrations (
470
id integer primary key autoincrement,
471
name text unique
472
);
473
474
-
-- indexes for better star query performance
475
create index if not exists idx_stars_created on stars(created);
476
create index if not exists idx_stars_repo_at_created on stars(repo_at, created);
477
`)
···
604
})
605
conn.ExecContext(ctx, "pragma foreign_keys = on;")
606
607
-
// run migrations
608
runMigration(conn, "add-spindle-to-repos", func(tx *sql.Tx) error {
609
tx.Exec(`
610
alter table repos add column spindle text;
···
724
_, err := tx.Exec(`
725
alter table spindles add column needs_upgrade integer not null default 0;
726
`)
727
-
if err != nil {
728
-
return err
729
-
}
730
-
731
-
_, err = tx.Exec(`
732
-
update spindles set needs_upgrade = 1;
733
-
`)
734
return err
735
})
736
···
868
return err
869
})
870
871
return &DB{db}, nil
872
}
873
···
932
}
933
}
934
935
-
func FilterEq(key string, arg any) filter { return newFilter(key, "=", arg) }
936
-
func FilterNotEq(key string, arg any) filter { return newFilter(key, "<>", arg) }
937
-
func FilterGte(key string, arg any) filter { return newFilter(key, ">=", arg) }
938
-
func FilterLte(key string, arg any) filter { return newFilter(key, "<=", arg) }
939
-
func FilterIs(key string, arg any) filter { return newFilter(key, "is", arg) }
940
-
func FilterIsNot(key string, arg any) filter { return newFilter(key, "is not", arg) }
941
-
func FilterIn(key string, arg any) filter { return newFilter(key, "in", arg) }
942
943
func (f filter) Condition() string {
944
rv := reflect.ValueOf(f.arg)
···
466
primary key (did, rkey)
467
);
468
469
+
create table if not exists label_definitions (
470
+
-- identifiers
471
+
id integer primary key autoincrement,
472
+
did text not null,
473
+
rkey text not null,
474
+
at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.label.definition' || '/' || rkey) stored,
475
+
476
+
-- content
477
+
name text not null,
478
+
value_type text not null check (value_type in (
479
+
"null",
480
+
"boolean",
481
+
"integer",
482
+
"string"
483
+
)),
484
+
value_format text not null default "any",
485
+
value_enum text, -- comma separated list
486
+
scope text not null, -- comma separated list of nsid
487
+
color text,
488
+
multiple integer not null default 0,
489
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
490
+
491
+
-- constraints
492
+
unique (did, rkey)
493
+
unique (at_uri)
494
+
);
495
+
496
+
-- ops are flattened, a record may contain several additions and deletions, but the table will include one row per add/del
497
+
create table if not exists label_ops (
498
+
-- identifiers
499
+
id integer primary key autoincrement,
500
+
did text not null,
501
+
rkey text not null,
502
+
at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.label.op' || '/' || rkey) stored,
503
+
504
+
-- content
505
+
subject text not null,
506
+
operation text not null check (operation in ("add", "del")),
507
+
operand_key text not null,
508
+
operand_value text not null,
509
+
-- we need two time values: performed is declared by the user, indexed is calculated by the av
510
+
performed text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
511
+
indexed text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
512
+
513
+
-- constraints
514
+
-- traditionally (did, rkey) pair should be unique, but not in this case
515
+
-- operand_key should reference a label definition
516
+
foreign key (operand_key) references label_definitions (at_uri) on delete cascade,
517
+
unique (did, rkey, subject, operand_key, operand_value)
518
+
);
519
+
520
+
create table if not exists repo_labels (
521
+
-- identifiers
522
+
id integer primary key autoincrement,
523
+
524
+
-- repo identifiers
525
+
repo_at text not null,
526
+
527
+
-- label to subscribe to
528
+
label_at text not null,
529
+
530
+
unique (repo_at, label_at)
531
+
);
532
+
533
+
create table if not exists notifications (
534
+
id integer primary key autoincrement,
535
+
recipient_did text not null,
536
+
actor_did text not null,
537
+
type text not null,
538
+
entity_type text not null,
539
+
entity_id text not null,
540
+
read integer not null default 0,
541
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
542
+
repo_id integer references repos(id),
543
+
issue_id integer references issues(id),
544
+
pull_id integer references pulls(id)
545
+
);
546
+
547
+
create table if not exists notification_preferences (
548
+
id integer primary key autoincrement,
549
+
user_did text not null unique,
550
+
repo_starred integer not null default 1,
551
+
issue_created integer not null default 1,
552
+
issue_commented integer not null default 1,
553
+
pull_created integer not null default 1,
554
+
pull_commented integer not null default 1,
555
+
followed integer not null default 1,
556
+
pull_merged integer not null default 1,
557
+
issue_closed integer not null default 1,
558
+
email_notifications integer not null default 0
559
+
);
560
+
561
create table if not exists migrations (
562
id integer primary key autoincrement,
563
name text unique
564
);
565
566
+
-- indexes for better performance
567
+
create index if not exists idx_notifications_recipient_created on notifications(recipient_did, created desc);
568
+
create index if not exists idx_notifications_recipient_read on notifications(recipient_did, read);
569
create index if not exists idx_stars_created on stars(created);
570
create index if not exists idx_stars_repo_at_created on stars(repo_at, created);
571
`)
···
698
})
699
conn.ExecContext(ctx, "pragma foreign_keys = on;")
700
701
runMigration(conn, "add-spindle-to-repos", func(tx *sql.Tx) error {
702
tx.Exec(`
703
alter table repos add column spindle text;
···
817
_, err := tx.Exec(`
818
alter table spindles add column needs_upgrade integer not null default 0;
819
`)
820
return err
821
})
822
···
954
return err
955
})
956
957
+
// add generated at_uri column to pulls table
958
+
//
959
+
// this requires a full table recreation because stored columns
960
+
// cannot be added via alter
961
+
//
962
+
// disable foreign-keys for the next migration
963
+
conn.ExecContext(ctx, "pragma foreign_keys = off;")
964
+
runMigration(conn, "add-at-uri-to-pulls", func(tx *sql.Tx) error {
965
+
_, err := tx.Exec(`
966
+
create table if not exists pulls_new (
967
+
-- identifiers
968
+
id integer primary key autoincrement,
969
+
pull_id integer not null,
970
+
at_uri text generated always as ('at://' || owner_did || '/' || 'sh.tangled.repo.pull' || '/' || rkey) stored,
971
+
972
+
-- at identifiers
973
+
repo_at text not null,
974
+
owner_did text not null,
975
+
rkey text not null,
976
+
977
+
-- content
978
+
title text not null,
979
+
body text not null,
980
+
target_branch text not null,
981
+
state integer not null default 0 check (state in (0, 1, 2, 3)), -- closed, open, merged, deleted
982
+
983
+
-- source info
984
+
source_branch text,
985
+
source_repo_at text,
986
+
987
+
-- stacking
988
+
stack_id text,
989
+
change_id text,
990
+
parent_change_id text,
991
+
992
+
-- meta
993
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
994
+
995
+
-- constraints
996
+
unique(repo_at, pull_id),
997
+
unique(at_uri),
998
+
foreign key (repo_at) references repos(at_uri) on delete cascade
999
+
);
1000
+
`)
1001
+
if err != nil {
1002
+
return err
1003
+
}
1004
+
1005
+
// transfer data
1006
+
_, err = tx.Exec(`
1007
+
insert into pulls_new (
1008
+
id, pull_id, repo_at, owner_did, rkey,
1009
+
title, body, target_branch, state,
1010
+
source_branch, source_repo_at,
1011
+
stack_id, change_id, parent_change_id,
1012
+
created
1013
+
)
1014
+
select
1015
+
id, pull_id, repo_at, owner_did, rkey,
1016
+
title, body, target_branch, state,
1017
+
source_branch, source_repo_at,
1018
+
stack_id, change_id, parent_change_id,
1019
+
created
1020
+
from pulls;
1021
+
`)
1022
+
if err != nil {
1023
+
return err
1024
+
}
1025
+
1026
+
// drop old table
1027
+
_, err = tx.Exec(`drop table pulls`)
1028
+
if err != nil {
1029
+
return err
1030
+
}
1031
+
1032
+
// rename new table
1033
+
_, err = tx.Exec(`alter table pulls_new rename to pulls`)
1034
+
return err
1035
+
})
1036
+
conn.ExecContext(ctx, "pragma foreign_keys = on;")
1037
+
1038
+
// remove repo_at and pull_id from pull_submissions and replace with pull_at
1039
+
//
1040
+
// this requires a full table recreation because stored columns
1041
+
// cannot be added via alter
1042
+
//
1043
+
// disable foreign-keys for the next migration
1044
+
conn.ExecContext(ctx, "pragma foreign_keys = off;")
1045
+
runMigration(conn, "remove-repo-at-pull-id-from-pull-submissions", func(tx *sql.Tx) error {
1046
+
_, err := tx.Exec(`
1047
+
create table if not exists pull_submissions_new (
1048
+
-- identifiers
1049
+
id integer primary key autoincrement,
1050
+
pull_at text not null,
1051
+
1052
+
-- content, these are immutable, and require a resubmission to update
1053
+
round_number integer not null default 0,
1054
+
patch text,
1055
+
source_rev text,
1056
+
1057
+
-- meta
1058
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
1059
+
1060
+
-- constraints
1061
+
unique(pull_at, round_number),
1062
+
foreign key (pull_at) references pulls(at_uri) on delete cascade
1063
+
);
1064
+
`)
1065
+
if err != nil {
1066
+
return err
1067
+
}
1068
+
1069
+
// transfer data, constructing pull_at from pulls table
1070
+
_, err = tx.Exec(`
1071
+
insert into pull_submissions_new (id, pull_at, round_number, patch, created)
1072
+
select
1073
+
ps.id,
1074
+
'at://' || p.owner_did || '/sh.tangled.repo.pull/' || p.rkey,
1075
+
ps.round_number,
1076
+
ps.patch,
1077
+
ps.created
1078
+
from pull_submissions ps
1079
+
join pulls p on ps.repo_at = p.repo_at and ps.pull_id = p.pull_id;
1080
+
`)
1081
+
if err != nil {
1082
+
return err
1083
+
}
1084
+
1085
+
// drop old table
1086
+
_, err = tx.Exec(`drop table pull_submissions`)
1087
+
if err != nil {
1088
+
return err
1089
+
}
1090
+
1091
+
// rename new table
1092
+
_, err = tx.Exec(`alter table pull_submissions_new rename to pull_submissions`)
1093
+
return err
1094
+
})
1095
+
conn.ExecContext(ctx, "pragma foreign_keys = on;")
1096
+
1097
return &DB{db}, nil
1098
}
1099
···
1158
}
1159
}
1160
1161
+
func FilterEq(key string, arg any) filter { return newFilter(key, "=", arg) }
1162
+
func FilterNotEq(key string, arg any) filter { return newFilter(key, "<>", arg) }
1163
+
func FilterGte(key string, arg any) filter { return newFilter(key, ">=", arg) }
1164
+
func FilterLte(key string, arg any) filter { return newFilter(key, "<=", arg) }
1165
+
func FilterIs(key string, arg any) filter { return newFilter(key, "is", arg) }
1166
+
func FilterIsNot(key string, arg any) filter { return newFilter(key, "is not", arg) }
1167
+
func FilterIn(key string, arg any) filter { return newFilter(key, "in", arg) }
1168
+
func FilterLike(key string, arg any) filter { return newFilter(key, "like", arg) }
1169
+
func FilterNotLike(key string, arg any) filter { return newFilter(key, "not like", arg) }
1170
+
func FilterContains(key string, arg any) filter {
1171
+
return newFilter(key, "like", fmt.Sprintf("%%%v%%", arg))
1172
+
}
1173
1174
func (f filter) Condition() string {
1175
rv := reflect.ValueOf(f.arg)
+29
-34
appview/db/email.go
+29
-34
appview/db/email.go
···
3
import (
4
"strings"
5
"time"
6
-
)
7
8
-
type Email struct {
9
-
ID int64
10
-
Did string
11
-
Address string
12
-
Verified bool
13
-
Primary bool
14
-
VerificationCode string
15
-
LastSent *time.Time
16
-
CreatedAt time.Time
17
-
}
18
19
-
func GetPrimaryEmail(e Execer, did string) (Email, error) {
20
query := `
21
select id, did, email, verified, is_primary, verification_code, last_sent, created
22
from emails
23
where did = ? and is_primary = true
24
`
25
-
var email Email
26
var createdStr string
27
var lastSent string
28
err := e.QueryRow(query, did).Scan(&email.ID, &email.Did, &email.Address, &email.Verified, &email.Primary, &email.VerificationCode, &lastSent, &createdStr)
29
if err != nil {
30
-
return Email{}, err
31
}
32
email.CreatedAt, err = time.Parse(time.RFC3339, createdStr)
33
if err != nil {
34
-
return Email{}, err
35
}
36
parsedTime, err := time.Parse(time.RFC3339, lastSent)
37
if err != nil {
38
-
return Email{}, err
39
}
40
email.LastSent = &parsedTime
41
return email, nil
42
}
43
44
-
func GetEmail(e Execer, did string, em string) (Email, error) {
45
query := `
46
select id, did, email, verified, is_primary, verification_code, last_sent, created
47
from emails
48
where did = ? and email = ?
49
`
50
-
var email Email
51
var createdStr string
52
var lastSent string
53
err := e.QueryRow(query, did, em).Scan(&email.ID, &email.Did, &email.Address, &email.Verified, &email.Primary, &email.VerificationCode, &lastSent, &createdStr)
54
if err != nil {
55
-
return Email{}, err
56
}
57
email.CreatedAt, err = time.Parse(time.RFC3339, createdStr)
58
if err != nil {
59
-
return Email{}, err
60
}
61
parsedTime, err := time.Parse(time.RFC3339, lastSent)
62
if err != nil {
63
-
return Email{}, err
64
}
65
email.LastSent = &parsedTime
66
return email, nil
···
80
return did, nil
81
}
82
83
-
func GetEmailToDid(e Execer, ems []string, isVerifiedFilter bool) (map[string]string, error) {
84
-
if len(ems) == 0 {
85
return make(map[string]string), nil
86
}
87
···
90
verifiedFilter = 1
91
}
92
93
// Create placeholders for the IN clause
94
-
placeholders := make([]string, len(ems))
95
-
args := make([]any, len(ems)+1)
96
97
args[0] = verifiedFilter
98
-
for i, em := range ems {
99
-
placeholders[i] = "?"
100
-
args[i+1] = em
101
}
102
103
query := `
···
113
return nil, err
114
}
115
defer rows.Close()
116
-
117
-
assoc := make(map[string]string)
118
119
for rows.Next() {
120
var email, did string
···
187
return count > 0, nil
188
}
189
190
-
func AddEmail(e Execer, email Email) error {
191
// Check if this is the first email for this DID
192
countQuery := `
193
select count(*)
···
254
return err
255
}
256
257
-
func GetAllEmails(e Execer, did string) ([]Email, error) {
258
query := `
259
select did, email, verified, is_primary, verification_code, last_sent, created
260
from emails
···
266
}
267
defer rows.Close()
268
269
-
var emails []Email
270
for rows.Next() {
271
-
var email Email
272
var createdStr string
273
var lastSent string
274
err := rows.Scan(&email.Did, &email.Address, &email.Verified, &email.Primary, &email.VerificationCode, &lastSent, &createdStr)
···
3
import (
4
"strings"
5
"time"
6
7
+
"tangled.org/core/appview/models"
8
+
)
9
10
+
func GetPrimaryEmail(e Execer, did string) (models.Email, error) {
11
query := `
12
select id, did, email, verified, is_primary, verification_code, last_sent, created
13
from emails
14
where did = ? and is_primary = true
15
`
16
+
var email models.Email
17
var createdStr string
18
var lastSent string
19
err := e.QueryRow(query, did).Scan(&email.ID, &email.Did, &email.Address, &email.Verified, &email.Primary, &email.VerificationCode, &lastSent, &createdStr)
20
if err != nil {
21
+
return models.Email{}, err
22
}
23
email.CreatedAt, err = time.Parse(time.RFC3339, createdStr)
24
if err != nil {
25
+
return models.Email{}, err
26
}
27
parsedTime, err := time.Parse(time.RFC3339, lastSent)
28
if err != nil {
29
+
return models.Email{}, err
30
}
31
email.LastSent = &parsedTime
32
return email, nil
33
}
34
35
+
func GetEmail(e Execer, did string, em string) (models.Email, error) {
36
query := `
37
select id, did, email, verified, is_primary, verification_code, last_sent, created
38
from emails
39
where did = ? and email = ?
40
`
41
+
var email models.Email
42
var createdStr string
43
var lastSent string
44
err := e.QueryRow(query, did, em).Scan(&email.ID, &email.Did, &email.Address, &email.Verified, &email.Primary, &email.VerificationCode, &lastSent, &createdStr)
45
if err != nil {
46
+
return models.Email{}, err
47
}
48
email.CreatedAt, err = time.Parse(time.RFC3339, createdStr)
49
if err != nil {
50
+
return models.Email{}, err
51
}
52
parsedTime, err := time.Parse(time.RFC3339, lastSent)
53
if err != nil {
54
+
return models.Email{}, err
55
}
56
email.LastSent = &parsedTime
57
return email, nil
···
71
return did, nil
72
}
73
74
+
func GetEmailToDid(e Execer, emails []string, isVerifiedFilter bool) (map[string]string, error) {
75
+
if len(emails) == 0 {
76
return make(map[string]string), nil
77
}
78
···
81
verifiedFilter = 1
82
}
83
84
+
assoc := make(map[string]string)
85
+
86
// Create placeholders for the IN clause
87
+
placeholders := make([]string, 0, len(emails))
88
+
args := make([]any, 1, len(emails)+1)
89
90
args[0] = verifiedFilter
91
+
for _, email := range emails {
92
+
if strings.HasPrefix(email, "did:") {
93
+
assoc[email] = email
94
+
continue
95
+
}
96
+
placeholders = append(placeholders, "?")
97
+
args = append(args, email)
98
}
99
100
query := `
···
110
return nil, err
111
}
112
defer rows.Close()
113
114
for rows.Next() {
115
var email, did string
···
182
return count > 0, nil
183
}
184
185
+
func AddEmail(e Execer, email models.Email) error {
186
// Check if this is the first email for this DID
187
countQuery := `
188
select count(*)
···
249
return err
250
}
251
252
+
func GetAllEmails(e Execer, did string) ([]models.Email, error) {
253
query := `
254
select did, email, verified, is_primary, verification_code, last_sent, created
255
from emails
···
261
}
262
defer rows.Close()
263
264
+
var emails []models.Email
265
for rows.Next() {
266
+
var email models.Email
267
var createdStr string
268
var lastSent string
269
err := rows.Scan(&email.Did, &email.Address, &email.Verified, &email.Primary, &email.VerificationCode, &lastSent, &createdStr)
+79
-50
appview/db/follow.go
+79
-50
appview/db/follow.go
···
5
"log"
6
"strings"
7
"time"
8
)
9
10
-
type Follow struct {
11
-
UserDid string
12
-
SubjectDid string
13
-
FollowedAt time.Time
14
-
Rkey string
15
-
}
16
-
17
-
func AddFollow(e Execer, follow *Follow) error {
18
query := `insert or ignore into follows (user_did, subject_did, rkey) values (?, ?, ?)`
19
_, err := e.Exec(query, follow.UserDid, follow.SubjectDid, follow.Rkey)
20
return err
21
}
22
23
// Get a follow record
24
-
func GetFollow(e Execer, userDid, subjectDid string) (*Follow, error) {
25
query := `select user_did, subject_did, followed_at, rkey from follows where user_did = ? and subject_did = ?`
26
row := e.QueryRow(query, userDid, subjectDid)
27
28
-
var follow Follow
29
var followedAt string
30
err := row.Scan(&follow.UserDid, &follow.SubjectDid, &followedAt, &follow.Rkey)
31
if err != nil {
···
55
return err
56
}
57
58
-
type FollowStats struct {
59
-
Followers int64
60
-
Following int64
61
-
}
62
-
63
-
func GetFollowerFollowingCount(e Execer, did string) (FollowStats, error) {
64
var followers, following int64
65
err := e.QueryRow(
66
`SELECT
···
68
COUNT(CASE WHEN user_did = ? THEN 1 END) AS following
69
FROM follows;`, did, did).Scan(&followers, &following)
70
if err != nil {
71
-
return FollowStats{}, err
72
}
73
-
return FollowStats{
74
Followers: followers,
75
Following: following,
76
}, nil
77
}
78
79
-
func GetFollowerFollowingCounts(e Execer, dids []string) (map[string]FollowStats, error) {
80
if len(dids) == 0 {
81
return nil, nil
82
}
···
112
) g on f.did = g.did`,
113
placeholderStr, placeholderStr)
114
115
-
result := make(map[string]FollowStats)
116
117
rows, err := e.Query(query, args...)
118
if err != nil {
···
126
if err := rows.Scan(&did, &followers, &following); err != nil {
127
return nil, err
128
}
129
-
result[did] = FollowStats{
130
Followers: followers,
131
Following: following,
132
}
···
134
135
for _, did := range dids {
136
if _, exists := result[did]; !exists {
137
-
result[did] = FollowStats{
138
Followers: 0,
139
Following: 0,
140
}
···
144
return result, nil
145
}
146
147
-
func GetFollows(e Execer, limit int, filters ...filter) ([]Follow, error) {
148
-
var follows []Follow
149
150
var conditions []string
151
var args []any
···
177
return nil, err
178
}
179
for rows.Next() {
180
-
var follow Follow
181
var followedAt string
182
err := rows.Scan(
183
&follow.UserDid,
···
200
return follows, nil
201
}
202
203
-
func GetFollowers(e Execer, did string) ([]Follow, error) {
204
return GetFollows(e, 0, FilterEq("subject_did", did))
205
}
206
207
-
func GetFollowing(e Execer, did string) ([]Follow, error) {
208
return GetFollows(e, 0, FilterEq("user_did", did))
209
}
210
211
-
type FollowStatus int
212
213
-
const (
214
-
IsNotFollowing FollowStatus = iota
215
-
IsFollowing
216
-
IsSelf
217
-
)
218
219
-
func (s FollowStatus) String() string {
220
-
switch s {
221
-
case IsNotFollowing:
222
-
return "IsNotFollowing"
223
-
case IsFollowing:
224
-
return "IsFollowing"
225
-
case IsSelf:
226
-
return "IsSelf"
227
-
default:
228
-
return "IsNotFollowing"
229
}
230
}
231
232
-
func GetFollowStatus(e Execer, userDid, subjectDid string) FollowStatus {
233
-
if userDid == subjectDid {
234
-
return IsSelf
235
-
} else if _, err := GetFollow(e, userDid, subjectDid); err != nil {
236
-
return IsNotFollowing
237
-
} else {
238
-
return IsFollowing
239
}
240
}
···
5
"log"
6
"strings"
7
"time"
8
+
9
+
"tangled.org/core/appview/models"
10
)
11
12
+
func AddFollow(e Execer, follow *models.Follow) error {
13
query := `insert or ignore into follows (user_did, subject_did, rkey) values (?, ?, ?)`
14
_, err := e.Exec(query, follow.UserDid, follow.SubjectDid, follow.Rkey)
15
return err
16
}
17
18
// Get a follow record
19
+
func GetFollow(e Execer, userDid, subjectDid string) (*models.Follow, error) {
20
query := `select user_did, subject_did, followed_at, rkey from follows where user_did = ? and subject_did = ?`
21
row := e.QueryRow(query, userDid, subjectDid)
22
23
+
var follow models.Follow
24
var followedAt string
25
err := row.Scan(&follow.UserDid, &follow.SubjectDid, &followedAt, &follow.Rkey)
26
if err != nil {
···
50
return err
51
}
52
53
+
func GetFollowerFollowingCount(e Execer, did string) (models.FollowStats, error) {
54
var followers, following int64
55
err := e.QueryRow(
56
`SELECT
···
58
COUNT(CASE WHEN user_did = ? THEN 1 END) AS following
59
FROM follows;`, did, did).Scan(&followers, &following)
60
if err != nil {
61
+
return models.FollowStats{}, err
62
}
63
+
return models.FollowStats{
64
Followers: followers,
65
Following: following,
66
}, nil
67
}
68
69
+
func GetFollowerFollowingCounts(e Execer, dids []string) (map[string]models.FollowStats, error) {
70
if len(dids) == 0 {
71
return nil, nil
72
}
···
102
) g on f.did = g.did`,
103
placeholderStr, placeholderStr)
104
105
+
result := make(map[string]models.FollowStats)
106
107
rows, err := e.Query(query, args...)
108
if err != nil {
···
116
if err := rows.Scan(&did, &followers, &following); err != nil {
117
return nil, err
118
}
119
+
result[did] = models.FollowStats{
120
Followers: followers,
121
Following: following,
122
}
···
124
125
for _, did := range dids {
126
if _, exists := result[did]; !exists {
127
+
result[did] = models.FollowStats{
128
Followers: 0,
129
Following: 0,
130
}
···
134
return result, nil
135
}
136
137
+
func GetFollows(e Execer, limit int, filters ...filter) ([]models.Follow, error) {
138
+
var follows []models.Follow
139
140
var conditions []string
141
var args []any
···
167
return nil, err
168
}
169
for rows.Next() {
170
+
var follow models.Follow
171
var followedAt string
172
err := rows.Scan(
173
&follow.UserDid,
···
190
return follows, nil
191
}
192
193
+
func GetFollowers(e Execer, did string) ([]models.Follow, error) {
194
return GetFollows(e, 0, FilterEq("subject_did", did))
195
}
196
197
+
func GetFollowing(e Execer, did string) ([]models.Follow, error) {
198
return GetFollows(e, 0, FilterEq("user_did", did))
199
}
200
201
+
func getFollowStatuses(e Execer, userDid string, subjectDids []string) (map[string]models.FollowStatus, error) {
202
+
if len(subjectDids) == 0 || userDid == "" {
203
+
return make(map[string]models.FollowStatus), nil
204
+
}
205
+
206
+
result := make(map[string]models.FollowStatus)
207
+
208
+
for _, subjectDid := range subjectDids {
209
+
if userDid == subjectDid {
210
+
result[subjectDid] = models.IsSelf
211
+
} else {
212
+
result[subjectDid] = models.IsNotFollowing
213
+
}
214
+
}
215
+
216
+
var querySubjects []string
217
+
for _, subjectDid := range subjectDids {
218
+
if userDid != subjectDid {
219
+
querySubjects = append(querySubjects, subjectDid)
220
+
}
221
+
}
222
223
+
if len(querySubjects) == 0 {
224
+
return result, nil
225
+
}
226
227
+
placeholders := make([]string, len(querySubjects))
228
+
args := make([]any, len(querySubjects)+1)
229
+
args[0] = userDid
230
+
231
+
for i, subjectDid := range querySubjects {
232
+
placeholders[i] = "?"
233
+
args[i+1] = subjectDid
234
}
235
+
236
+
query := fmt.Sprintf(`
237
+
SELECT subject_did
238
+
FROM follows
239
+
WHERE user_did = ? AND subject_did IN (%s)
240
+
`, strings.Join(placeholders, ","))
241
+
242
+
rows, err := e.Query(query, args...)
243
+
if err != nil {
244
+
return nil, err
245
+
}
246
+
defer rows.Close()
247
+
248
+
for rows.Next() {
249
+
var subjectDid string
250
+
if err := rows.Scan(&subjectDid); err != nil {
251
+
return nil, err
252
+
}
253
+
result[subjectDid] = models.IsFollowing
254
+
}
255
+
256
+
return result, nil
257
}
258
259
+
func GetFollowStatus(e Execer, userDid, subjectDid string) models.FollowStatus {
260
+
statuses, err := getFollowStatuses(e, userDid, []string{subjectDid})
261
+
if err != nil {
262
+
return models.IsNotFollowing
263
}
264
+
return statuses[subjectDid]
265
+
}
266
+
267
+
func GetFollowStatuses(e Execer, userDid string, subjectDids []string) (map[string]models.FollowStatus, error) {
268
+
return getFollowStatuses(e, userDid, subjectDids)
269
}
+43
-196
appview/db/issues.go
+43
-196
appview/db/issues.go
···
10
"time"
11
12
"github.com/bluesky-social/indigo/atproto/syntax"
13
-
"tangled.sh/tangled.sh/core/api/tangled"
14
-
"tangled.sh/tangled.sh/core/appview/pagination"
15
)
16
17
-
type Issue struct {
18
-
Id int64
19
-
Did string
20
-
Rkey string
21
-
RepoAt syntax.ATURI
22
-
IssueId int
23
-
Created time.Time
24
-
Edited *time.Time
25
-
Deleted *time.Time
26
-
Title string
27
-
Body string
28
-
Open bool
29
-
30
-
// optionally, populate this when querying for reverse mappings
31
-
// like comment counts, parent repo etc.
32
-
Comments []IssueComment
33
-
Repo *Repo
34
-
}
35
-
36
-
func (i *Issue) AtUri() syntax.ATURI {
37
-
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueNSID, i.Rkey))
38
-
}
39
-
40
-
func (i *Issue) AsRecord() tangled.RepoIssue {
41
-
return tangled.RepoIssue{
42
-
Repo: i.RepoAt.String(),
43
-
Title: i.Title,
44
-
Body: &i.Body,
45
-
CreatedAt: i.Created.Format(time.RFC3339),
46
-
}
47
-
}
48
-
49
-
func (i *Issue) State() string {
50
-
if i.Open {
51
-
return "open"
52
-
}
53
-
return "closed"
54
-
}
55
-
56
-
type CommentListItem struct {
57
-
Self *IssueComment
58
-
Replies []*IssueComment
59
-
}
60
-
61
-
func (i *Issue) CommentList() []CommentListItem {
62
-
// Create a map to quickly find comments by their aturi
63
-
toplevel := make(map[string]*CommentListItem)
64
-
var replies []*IssueComment
65
-
66
-
// collect top level comments into the map
67
-
for _, comment := range i.Comments {
68
-
if comment.IsTopLevel() {
69
-
toplevel[comment.AtUri().String()] = &CommentListItem{
70
-
Self: &comment,
71
-
}
72
-
} else {
73
-
replies = append(replies, &comment)
74
-
}
75
-
}
76
-
77
-
for _, r := range replies {
78
-
parentAt := *r.ReplyTo
79
-
if parent, exists := toplevel[parentAt]; exists {
80
-
parent.Replies = append(parent.Replies, r)
81
-
}
82
-
}
83
-
84
-
var listing []CommentListItem
85
-
for _, v := range toplevel {
86
-
listing = append(listing, *v)
87
-
}
88
-
89
-
// sort everything
90
-
sortFunc := func(a, b *IssueComment) bool {
91
-
return a.Created.Before(b.Created)
92
-
}
93
-
sort.Slice(listing, func(i, j int) bool {
94
-
return sortFunc(listing[i].Self, listing[j].Self)
95
-
})
96
-
for _, r := range listing {
97
-
sort.Slice(r.Replies, func(i, j int) bool {
98
-
return sortFunc(r.Replies[i], r.Replies[j])
99
-
})
100
-
}
101
-
102
-
return listing
103
-
}
104
-
105
-
func IssueFromRecord(did, rkey string, record tangled.RepoIssue) Issue {
106
-
created, err := time.Parse(time.RFC3339, record.CreatedAt)
107
-
if err != nil {
108
-
created = time.Now()
109
-
}
110
-
111
-
body := ""
112
-
if record.Body != nil {
113
-
body = *record.Body
114
-
}
115
-
116
-
return Issue{
117
-
RepoAt: syntax.ATURI(record.Repo),
118
-
Did: did,
119
-
Rkey: rkey,
120
-
Created: created,
121
-
Title: record.Title,
122
-
Body: body,
123
-
Open: true, // new issues are open by default
124
-
}
125
-
}
126
-
127
-
type IssueComment struct {
128
-
Id int64
129
-
Did string
130
-
Rkey string
131
-
IssueAt string
132
-
ReplyTo *string
133
-
Body string
134
-
Created time.Time
135
-
Edited *time.Time
136
-
Deleted *time.Time
137
-
}
138
-
139
-
func (i *IssueComment) AtUri() syntax.ATURI {
140
-
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueCommentNSID, i.Rkey))
141
-
}
142
-
143
-
func (i *IssueComment) AsRecord() tangled.RepoIssueComment {
144
-
return tangled.RepoIssueComment{
145
-
Body: i.Body,
146
-
Issue: i.IssueAt,
147
-
CreatedAt: i.Created.Format(time.RFC3339),
148
-
ReplyTo: i.ReplyTo,
149
-
}
150
-
}
151
-
152
-
func (i *IssueComment) IsTopLevel() bool {
153
-
return i.ReplyTo == nil
154
-
}
155
-
156
-
func IssueCommentFromRecord(e Execer, did, rkey string, record tangled.RepoIssueComment) (*IssueComment, error) {
157
-
created, err := time.Parse(time.RFC3339, record.CreatedAt)
158
-
if err != nil {
159
-
created = time.Now()
160
-
}
161
-
162
-
ownerDid := did
163
-
164
-
if _, err = syntax.ParseATURI(record.Issue); err != nil {
165
-
return nil, err
166
-
}
167
-
168
-
comment := IssueComment{
169
-
Did: ownerDid,
170
-
Rkey: rkey,
171
-
Body: record.Body,
172
-
IssueAt: record.Issue,
173
-
ReplyTo: record.ReplyTo,
174
-
Created: created,
175
-
}
176
-
177
-
return &comment, nil
178
-
}
179
-
180
-
func PutIssue(tx *sql.Tx, issue *Issue) error {
181
// ensure sequence exists
182
_, err := tx.Exec(`
183
insert or ignore into repo_issue_seqs (repo_at, next_issue_id)
···
212
}
213
}
214
215
-
func createNewIssue(tx *sql.Tx, issue *Issue) error {
216
// get next issue_id
217
var newIssueId int
218
err := tx.QueryRow(`
219
-
update repo_issue_seqs
220
-
set next_issue_id = next_issue_id + 1
221
-
where repo_at = ?
222
returning next_issue_id - 1
223
`, issue.RepoAt).Scan(&newIssueId)
224
if err != nil {
···
235
return row.Scan(&issue.Id, &issue.IssueId)
236
}
237
238
-
func updateIssue(tx *sql.Tx, issue *Issue) error {
239
// update existing issue
240
_, err := tx.Exec(`
241
update issues
···
245
return err
246
}
247
248
-
func GetIssuesPaginated(e Execer, page pagination.Page, filters ...filter) ([]Issue, error) {
249
-
issueMap := make(map[string]*Issue) // at-uri -> issue
250
251
var conditions []string
252
var args []any
···
301
defer rows.Close()
302
303
for rows.Next() {
304
-
var issue Issue
305
var createdAt string
306
var editedAt, deletedAt sql.Null[string]
307
var rowNum int64
···
354
return nil, fmt.Errorf("failed to build repo mappings: %w", err)
355
}
356
357
-
repoMap := make(map[string]*Repo)
358
for i := range repos {
359
repoMap[string(repos[i].RepoAt())] = &repos[i]
360
}
361
362
-
for issueAt := range issueMap {
363
-
i := issueMap[issueAt]
364
-
r := repoMap[string(i.RepoAt)]
365
-
i.Repo = r
366
}
367
368
// collect comments
369
issueAts := slices.Collect(maps.Keys(issueMap))
370
comments, err := GetIssueComments(e, FilterIn("issue_at", issueAts))
371
if err != nil {
372
return nil, fmt.Errorf("failed to query comments: %w", err)
373
}
374
-
375
for i := range comments {
376
issueAt := comments[i].IssueAt
377
if issue, ok := issueMap[issueAt]; ok {
···
379
}
380
}
381
382
-
var issues []Issue
383
for _, i := range issueMap {
384
issues = append(issues, *i)
385
}
···
391
return issues, nil
392
}
393
394
-
func GetIssues(e Execer, filters ...filter) ([]Issue, error) {
395
return GetIssuesPaginated(e, pagination.FirstPage(), filters...)
396
}
397
398
-
func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, error) {
399
query := `select id, owner_did, rkey, created, title, body, open from issues where repo_at = ? and issue_id = ?`
400
row := e.QueryRow(query, repoAt, issueId)
401
402
-
var issue Issue
403
var createdAt string
404
err := row.Scan(&issue.Id, &issue.Did, &issue.Rkey, &createdAt, &issue.Title, &issue.Body, &issue.Open)
405
if err != nil {
···
415
return &issue, nil
416
}
417
418
-
func AddIssueComment(e Execer, c IssueComment) (int64, error) {
419
result, err := e.Exec(
420
`insert into issue_comments (
421
did,
···
477
return err
478
}
479
480
-
func GetIssueComments(e Execer, filters ...filter) ([]IssueComment, error) {
481
-
var comments []IssueComment
482
483
var conditions []string
484
var args []any
···
514
}
515
516
for rows.Next() {
517
-
var comment IssueComment
518
var created string
519
var rkey, edited, deleted, replyTo sql.Null[string]
520
err := rows.Scan(
···
621
return err
622
}
623
624
-
type IssueCount struct {
625
-
Open int
626
-
Closed int
627
-
}
628
-
629
-
func GetIssueCount(e Execer, repoAt syntax.ATURI) (IssueCount, error) {
630
row := e.QueryRow(`
631
select
632
count(case when open = 1 then 1 end) as open_count,
···
636
repoAt,
637
)
638
639
-
var count IssueCount
640
if err := row.Scan(&count.Open, &count.Closed); err != nil {
641
-
return IssueCount{0, 0}, err
642
}
643
644
return count, nil
···
10
"time"
11
12
"github.com/bluesky-social/indigo/atproto/syntax"
13
+
"tangled.org/core/appview/models"
14
+
"tangled.org/core/appview/pagination"
15
)
16
17
+
func PutIssue(tx *sql.Tx, issue *models.Issue) error {
18
// ensure sequence exists
19
_, err := tx.Exec(`
20
insert or ignore into repo_issue_seqs (repo_at, next_issue_id)
···
49
}
50
}
51
52
+
func createNewIssue(tx *sql.Tx, issue *models.Issue) error {
53
// get next issue_id
54
var newIssueId int
55
err := tx.QueryRow(`
56
+
update repo_issue_seqs
57
+
set next_issue_id = next_issue_id + 1
58
+
where repo_at = ?
59
returning next_issue_id - 1
60
`, issue.RepoAt).Scan(&newIssueId)
61
if err != nil {
···
72
return row.Scan(&issue.Id, &issue.IssueId)
73
}
74
75
+
func updateIssue(tx *sql.Tx, issue *models.Issue) error {
76
// update existing issue
77
_, err := tx.Exec(`
78
update issues
···
82
return err
83
}
84
85
+
func GetIssuesPaginated(e Execer, page pagination.Page, filters ...filter) ([]models.Issue, error) {
86
+
issueMap := make(map[string]*models.Issue) // at-uri -> issue
87
88
var conditions []string
89
var args []any
···
138
defer rows.Close()
139
140
for rows.Next() {
141
+
var issue models.Issue
142
var createdAt string
143
var editedAt, deletedAt sql.Null[string]
144
var rowNum int64
···
191
return nil, fmt.Errorf("failed to build repo mappings: %w", err)
192
}
193
194
+
repoMap := make(map[string]*models.Repo)
195
for i := range repos {
196
repoMap[string(repos[i].RepoAt())] = &repos[i]
197
}
198
199
+
for issueAt, i := range issueMap {
200
+
if r, ok := repoMap[string(i.RepoAt)]; ok {
201
+
i.Repo = r
202
+
} else {
203
+
// do not show up the issue if the repo is deleted
204
+
// TODO: foreign key where?
205
+
delete(issueMap, issueAt)
206
+
}
207
}
208
209
// collect comments
210
issueAts := slices.Collect(maps.Keys(issueMap))
211
+
212
comments, err := GetIssueComments(e, FilterIn("issue_at", issueAts))
213
if err != nil {
214
return nil, fmt.Errorf("failed to query comments: %w", err)
215
}
216
for i := range comments {
217
issueAt := comments[i].IssueAt
218
if issue, ok := issueMap[issueAt]; ok {
···
220
}
221
}
222
223
+
// collect allLabels for each issue
224
+
allLabels, err := GetLabels(e, FilterIn("subject", issueAts))
225
+
if err != nil {
226
+
return nil, fmt.Errorf("failed to query labels: %w", err)
227
+
}
228
+
for issueAt, labels := range allLabels {
229
+
if issue, ok := issueMap[issueAt.String()]; ok {
230
+
issue.Labels = labels
231
+
}
232
+
}
233
+
234
+
var issues []models.Issue
235
for _, i := range issueMap {
236
issues = append(issues, *i)
237
}
···
243
return issues, nil
244
}
245
246
+
func GetIssues(e Execer, filters ...filter) ([]models.Issue, error) {
247
return GetIssuesPaginated(e, pagination.FirstPage(), filters...)
248
}
249
250
+
func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*models.Issue, error) {
251
query := `select id, owner_did, rkey, created, title, body, open from issues where repo_at = ? and issue_id = ?`
252
row := e.QueryRow(query, repoAt, issueId)
253
254
+
var issue models.Issue
255
var createdAt string
256
err := row.Scan(&issue.Id, &issue.Did, &issue.Rkey, &createdAt, &issue.Title, &issue.Body, &issue.Open)
257
if err != nil {
···
267
return &issue, nil
268
}
269
270
+
func AddIssueComment(e Execer, c models.IssueComment) (int64, error) {
271
result, err := e.Exec(
272
`insert into issue_comments (
273
did,
···
329
return err
330
}
331
332
+
func GetIssueComments(e Execer, filters ...filter) ([]models.IssueComment, error) {
333
+
var comments []models.IssueComment
334
335
var conditions []string
336
var args []any
···
366
}
367
368
for rows.Next() {
369
+
var comment models.IssueComment
370
var created string
371
var rkey, edited, deleted, replyTo sql.Null[string]
372
err := rows.Scan(
···
473
return err
474
}
475
476
+
func GetIssueCount(e Execer, repoAt syntax.ATURI) (models.IssueCount, error) {
477
row := e.QueryRow(`
478
select
479
count(case when open = 1 then 1 end) as open_count,
···
483
repoAt,
484
)
485
486
+
var count models.IssueCount
487
if err := row.Scan(&count.Open, &count.Closed); err != nil {
488
+
return models.IssueCount{}, err
489
}
490
491
return count, nil
+353
appview/db/label.go
+353
appview/db/label.go
···
···
1
+
package db
2
+
3
+
import (
4
+
"database/sql"
5
+
"fmt"
6
+
"maps"
7
+
"slices"
8
+
"strings"
9
+
"time"
10
+
11
+
"github.com/bluesky-social/indigo/atproto/syntax"
12
+
"tangled.org/core/appview/models"
13
+
)
14
+
15
+
// no updating type for now
16
+
func AddLabelDefinition(e Execer, l *models.LabelDefinition) (int64, error) {
17
+
result, err := e.Exec(
18
+
`insert into label_definitions (
19
+
did,
20
+
rkey,
21
+
name,
22
+
value_type,
23
+
value_format,
24
+
value_enum,
25
+
scope,
26
+
color,
27
+
multiple,
28
+
created
29
+
)
30
+
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
31
+
on conflict(did, rkey) do update set
32
+
name = excluded.name,
33
+
scope = excluded.scope,
34
+
color = excluded.color,
35
+
multiple = excluded.multiple`,
36
+
l.Did,
37
+
l.Rkey,
38
+
l.Name,
39
+
l.ValueType.Type,
40
+
l.ValueType.Format,
41
+
strings.Join(l.ValueType.Enum, ","),
42
+
strings.Join(l.Scope, ","),
43
+
l.Color,
44
+
l.Multiple,
45
+
l.Created.Format(time.RFC3339),
46
+
time.Now().Format(time.RFC3339),
47
+
)
48
+
if err != nil {
49
+
return 0, err
50
+
}
51
+
52
+
id, err := result.LastInsertId()
53
+
if err != nil {
54
+
return 0, err
55
+
}
56
+
57
+
l.Id = id
58
+
59
+
return id, nil
60
+
}
61
+
62
+
func DeleteLabelDefinition(e Execer, filters ...filter) error {
63
+
var conditions []string
64
+
var args []any
65
+
for _, filter := range filters {
66
+
conditions = append(conditions, filter.Condition())
67
+
args = append(args, filter.Arg()...)
68
+
}
69
+
whereClause := ""
70
+
if conditions != nil {
71
+
whereClause = " where " + strings.Join(conditions, " and ")
72
+
}
73
+
query := fmt.Sprintf(`delete from label_definitions %s`, whereClause)
74
+
_, err := e.Exec(query, args...)
75
+
return err
76
+
}
77
+
78
+
func GetLabelDefinitions(e Execer, filters ...filter) ([]models.LabelDefinition, error) {
79
+
var labelDefinitions []models.LabelDefinition
80
+
var conditions []string
81
+
var args []any
82
+
83
+
for _, filter := range filters {
84
+
conditions = append(conditions, filter.Condition())
85
+
args = append(args, filter.Arg()...)
86
+
}
87
+
88
+
whereClause := ""
89
+
if conditions != nil {
90
+
whereClause = " where " + strings.Join(conditions, " and ")
91
+
}
92
+
93
+
query := fmt.Sprintf(
94
+
`
95
+
select
96
+
id,
97
+
did,
98
+
rkey,
99
+
name,
100
+
value_type,
101
+
value_format,
102
+
value_enum,
103
+
scope,
104
+
color,
105
+
multiple,
106
+
created
107
+
from label_definitions
108
+
%s
109
+
order by created
110
+
`,
111
+
whereClause,
112
+
)
113
+
114
+
rows, err := e.Query(query, args...)
115
+
if err != nil {
116
+
return nil, err
117
+
}
118
+
defer rows.Close()
119
+
120
+
for rows.Next() {
121
+
var labelDefinition models.LabelDefinition
122
+
var createdAt, enumVariants, scopes string
123
+
var color sql.Null[string]
124
+
var multiple int
125
+
126
+
if err := rows.Scan(
127
+
&labelDefinition.Id,
128
+
&labelDefinition.Did,
129
+
&labelDefinition.Rkey,
130
+
&labelDefinition.Name,
131
+
&labelDefinition.ValueType.Type,
132
+
&labelDefinition.ValueType.Format,
133
+
&enumVariants,
134
+
&scopes,
135
+
&color,
136
+
&multiple,
137
+
&createdAt,
138
+
); err != nil {
139
+
return nil, err
140
+
}
141
+
142
+
labelDefinition.Created, err = time.Parse(time.RFC3339, createdAt)
143
+
if err != nil {
144
+
labelDefinition.Created = time.Now()
145
+
}
146
+
147
+
if color.Valid {
148
+
labelDefinition.Color = &color.V
149
+
}
150
+
151
+
if multiple != 0 {
152
+
labelDefinition.Multiple = true
153
+
}
154
+
155
+
if enumVariants != "" {
156
+
labelDefinition.ValueType.Enum = strings.Split(enumVariants, ",")
157
+
}
158
+
159
+
for s := range strings.SplitSeq(scopes, ",") {
160
+
labelDefinition.Scope = append(labelDefinition.Scope, s)
161
+
}
162
+
163
+
labelDefinitions = append(labelDefinitions, labelDefinition)
164
+
}
165
+
166
+
return labelDefinitions, nil
167
+
}
168
+
169
+
// helper to get exactly one label def
170
+
func GetLabelDefinition(e Execer, filters ...filter) (*models.LabelDefinition, error) {
171
+
labels, err := GetLabelDefinitions(e, filters...)
172
+
if err != nil {
173
+
return nil, err
174
+
}
175
+
176
+
if labels == nil {
177
+
return nil, sql.ErrNoRows
178
+
}
179
+
180
+
if len(labels) != 1 {
181
+
return nil, fmt.Errorf("too many rows returned")
182
+
}
183
+
184
+
return &labels[0], nil
185
+
}
186
+
187
+
func AddLabelOp(e Execer, l *models.LabelOp) (int64, error) {
188
+
now := time.Now()
189
+
result, err := e.Exec(
190
+
`insert into label_ops (
191
+
did,
192
+
rkey,
193
+
subject,
194
+
operation,
195
+
operand_key,
196
+
operand_value,
197
+
performed,
198
+
indexed
199
+
)
200
+
values (?, ?, ?, ?, ?, ?, ?, ?)
201
+
on conflict(did, rkey, subject, operand_key, operand_value) do update set
202
+
operation = excluded.operation,
203
+
operand_value = excluded.operand_value,
204
+
performed = excluded.performed,
205
+
indexed = excluded.indexed`,
206
+
l.Did,
207
+
l.Rkey,
208
+
l.Subject.String(),
209
+
string(l.Operation),
210
+
l.OperandKey,
211
+
l.OperandValue,
212
+
l.PerformedAt.Format(time.RFC3339),
213
+
now.Format(time.RFC3339),
214
+
)
215
+
if err != nil {
216
+
return 0, err
217
+
}
218
+
219
+
id, err := result.LastInsertId()
220
+
if err != nil {
221
+
return 0, err
222
+
}
223
+
224
+
l.Id = id
225
+
l.IndexedAt = now
226
+
227
+
return id, nil
228
+
}
229
+
230
+
func GetLabelOps(e Execer, filters ...filter) ([]models.LabelOp, error) {
231
+
var labelOps []models.LabelOp
232
+
var conditions []string
233
+
var args []any
234
+
235
+
for _, filter := range filters {
236
+
conditions = append(conditions, filter.Condition())
237
+
args = append(args, filter.Arg()...)
238
+
}
239
+
240
+
whereClause := ""
241
+
if conditions != nil {
242
+
whereClause = " where " + strings.Join(conditions, " and ")
243
+
}
244
+
245
+
query := fmt.Sprintf(
246
+
`
247
+
select
248
+
id,
249
+
did,
250
+
rkey,
251
+
subject,
252
+
operation,
253
+
operand_key,
254
+
operand_value,
255
+
performed,
256
+
indexed
257
+
from label_ops
258
+
%s
259
+
order by indexed
260
+
`,
261
+
whereClause,
262
+
)
263
+
264
+
rows, err := e.Query(query, args...)
265
+
if err != nil {
266
+
return nil, err
267
+
}
268
+
defer rows.Close()
269
+
270
+
for rows.Next() {
271
+
var labelOp models.LabelOp
272
+
var performedAt, indexedAt string
273
+
274
+
if err := rows.Scan(
275
+
&labelOp.Id,
276
+
&labelOp.Did,
277
+
&labelOp.Rkey,
278
+
&labelOp.Subject,
279
+
&labelOp.Operation,
280
+
&labelOp.OperandKey,
281
+
&labelOp.OperandValue,
282
+
&performedAt,
283
+
&indexedAt,
284
+
); err != nil {
285
+
return nil, err
286
+
}
287
+
288
+
labelOp.PerformedAt, err = time.Parse(time.RFC3339, performedAt)
289
+
if err != nil {
290
+
labelOp.PerformedAt = time.Now()
291
+
}
292
+
293
+
labelOp.IndexedAt, err = time.Parse(time.RFC3339, indexedAt)
294
+
if err != nil {
295
+
labelOp.IndexedAt = time.Now()
296
+
}
297
+
298
+
labelOps = append(labelOps, labelOp)
299
+
}
300
+
301
+
return labelOps, nil
302
+
}
303
+
304
+
// get labels for a given list of subject URIs
305
+
func GetLabels(e Execer, filters ...filter) (map[syntax.ATURI]models.LabelState, error) {
306
+
ops, err := GetLabelOps(e, filters...)
307
+
if err != nil {
308
+
return nil, err
309
+
}
310
+
311
+
// group ops by subject
312
+
opsBySubject := make(map[syntax.ATURI][]models.LabelOp)
313
+
for _, op := range ops {
314
+
subject := syntax.ATURI(op.Subject)
315
+
opsBySubject[subject] = append(opsBySubject[subject], op)
316
+
}
317
+
318
+
// get all unique labelats for creating the context
319
+
labelAtSet := make(map[string]bool)
320
+
for _, op := range ops {
321
+
labelAtSet[op.OperandKey] = true
322
+
}
323
+
labelAts := slices.Collect(maps.Keys(labelAtSet))
324
+
325
+
actx, err := NewLabelApplicationCtx(e, FilterIn("at_uri", labelAts))
326
+
if err != nil {
327
+
return nil, err
328
+
}
329
+
330
+
// apply label ops for each subject and collect results
331
+
results := make(map[syntax.ATURI]models.LabelState)
332
+
for subject, subjectOps := range opsBySubject {
333
+
state := models.NewLabelState()
334
+
actx.ApplyLabelOps(state, subjectOps)
335
+
results[subject] = state
336
+
}
337
+
338
+
return results, nil
339
+
}
340
+
341
+
func NewLabelApplicationCtx(e Execer, filters ...filter) (*models.LabelApplicationCtx, error) {
342
+
labels, err := GetLabelDefinitions(e, filters...)
343
+
if err != nil {
344
+
return nil, err
345
+
}
346
+
347
+
defs := make(map[string]*models.LabelDefinition)
348
+
for _, l := range labels {
349
+
defs[l.AtUri().String()] = &l
350
+
}
351
+
352
+
return &models.LabelApplicationCtx{Defs: defs}, nil
353
+
}
+38
-13
appview/db/language.go
+38
-13
appview/db/language.go
···
1
package db
2
3
import (
4
"fmt"
5
"strings"
6
7
"github.com/bluesky-social/indigo/atproto/syntax"
8
)
9
10
-
type RepoLanguage struct {
11
-
Id int64
12
-
RepoAt syntax.ATURI
13
-
Ref string
14
-
IsDefaultRef bool
15
-
Language string
16
-
Bytes int64
17
-
}
18
-
19
-
func GetRepoLanguages(e Execer, filters ...filter) ([]RepoLanguage, error) {
20
var conditions []string
21
var args []any
22
for _, filter := range filters {
···
39
return nil, fmt.Errorf("failed to execute query: %w ", err)
40
}
41
42
-
var langs []RepoLanguage
43
for rows.Next() {
44
-
var rl RepoLanguage
45
var isDefaultRef int
46
47
err := rows.Scan(
···
69
return langs, nil
70
}
71
72
-
func InsertRepoLanguages(e Execer, langs []RepoLanguage) error {
73
stmt, err := e.Prepare(
74
"insert or replace into repo_languages (repo_at, ref, is_default_ref, language, bytes) values (?, ?, ?, ?, ?)",
75
)
···
91
92
return nil
93
}
···
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/appview/models"
10
)
11
12
+
func GetRepoLanguages(e Execer, filters ...filter) ([]models.RepoLanguage, error) {
13
var conditions []string
14
var args []any
15
for _, filter := range filters {
···
32
return nil, fmt.Errorf("failed to execute query: %w ", err)
33
}
34
35
+
var langs []models.RepoLanguage
36
for rows.Next() {
37
+
var rl models.RepoLanguage
38
var isDefaultRef int
39
40
err := rows.Scan(
···
62
return langs, nil
63
}
64
65
+
func InsertRepoLanguages(e Execer, langs []models.RepoLanguage) error {
66
stmt, err := e.Prepare(
67
"insert or replace into repo_languages (repo_at, ref, is_default_ref, language, bytes) values (?, ?, ?, ?, ?)",
68
)
···
84
85
return nil
86
}
87
+
88
+
func DeleteRepoLanguages(e Execer, filters ...filter) error {
89
+
var conditions []string
90
+
var args []any
91
+
for _, filter := range filters {
92
+
conditions = append(conditions, filter.Condition())
93
+
args = append(args, filter.Arg()...)
94
+
}
95
+
96
+
whereClause := ""
97
+
if conditions != nil {
98
+
whereClause = " where " + strings.Join(conditions, " and ")
99
+
}
100
+
101
+
query := fmt.Sprintf(`delete from repo_languages %s`, whereClause)
102
+
103
+
_, err := e.Exec(query, args...)
104
+
return err
105
+
}
106
+
107
+
func UpdateRepoLanguages(tx *sql.Tx, repoAt syntax.ATURI, ref string, langs []models.RepoLanguage) error {
108
+
err := DeleteRepoLanguages(
109
+
tx,
110
+
FilterEq("repo_at", repoAt),
111
+
FilterEq("ref", ref),
112
+
)
113
+
if err != nil {
114
+
return fmt.Errorf("failed to delete existing languages: %w", err)
115
+
}
116
+
117
+
return InsertRepoLanguages(tx, langs)
118
+
}
+450
appview/db/notifications.go
+450
appview/db/notifications.go
···
···
1
+
package db
2
+
3
+
import (
4
+
"context"
5
+
"database/sql"
6
+
"errors"
7
+
"fmt"
8
+
"strings"
9
+
"time"
10
+
11
+
"tangled.org/core/appview/models"
12
+
"tangled.org/core/appview/pagination"
13
+
)
14
+
15
+
func (d *DB) CreateNotification(ctx context.Context, notification *models.Notification) error {
16
+
query := `
17
+
INSERT INTO notifications (recipient_did, actor_did, type, entity_type, entity_id, read, repo_id, issue_id, pull_id)
18
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
19
+
`
20
+
21
+
result, err := d.DB.ExecContext(ctx, query,
22
+
notification.RecipientDid,
23
+
notification.ActorDid,
24
+
string(notification.Type),
25
+
notification.EntityType,
26
+
notification.EntityId,
27
+
notification.Read,
28
+
notification.RepoId,
29
+
notification.IssueId,
30
+
notification.PullId,
31
+
)
32
+
if err != nil {
33
+
return fmt.Errorf("failed to create notification: %w", err)
34
+
}
35
+
36
+
id, err := result.LastInsertId()
37
+
if err != nil {
38
+
return fmt.Errorf("failed to get notification ID: %w", err)
39
+
}
40
+
41
+
notification.ID = id
42
+
return nil
43
+
}
44
+
45
+
// GetNotificationsPaginated retrieves notifications with filters and pagination
46
+
func GetNotificationsPaginated(e Execer, page pagination.Page, filters ...filter) ([]*models.Notification, error) {
47
+
var conditions []string
48
+
var args []any
49
+
50
+
for _, filter := range filters {
51
+
conditions = append(conditions, filter.Condition())
52
+
args = append(args, filter.Arg()...)
53
+
}
54
+
55
+
whereClause := ""
56
+
if len(conditions) > 0 {
57
+
whereClause = "WHERE " + conditions[0]
58
+
for _, condition := range conditions[1:] {
59
+
whereClause += " AND " + condition
60
+
}
61
+
}
62
+
63
+
query := fmt.Sprintf(`
64
+
select id, recipient_did, actor_did, type, entity_type, entity_id, read, created, repo_id, issue_id, pull_id
65
+
from notifications
66
+
%s
67
+
order by created desc
68
+
limit ? offset ?
69
+
`, whereClause)
70
+
71
+
args = append(args, page.Limit, page.Offset)
72
+
73
+
rows, err := e.QueryContext(context.Background(), query, args...)
74
+
if err != nil {
75
+
return nil, fmt.Errorf("failed to query notifications: %w", err)
76
+
}
77
+
defer rows.Close()
78
+
79
+
var notifications []*models.Notification
80
+
for rows.Next() {
81
+
var n models.Notification
82
+
var typeStr string
83
+
var createdStr string
84
+
err := rows.Scan(
85
+
&n.ID,
86
+
&n.RecipientDid,
87
+
&n.ActorDid,
88
+
&typeStr,
89
+
&n.EntityType,
90
+
&n.EntityId,
91
+
&n.Read,
92
+
&createdStr,
93
+
&n.RepoId,
94
+
&n.IssueId,
95
+
&n.PullId,
96
+
)
97
+
if err != nil {
98
+
return nil, fmt.Errorf("failed to scan notification: %w", err)
99
+
}
100
+
n.Type = models.NotificationType(typeStr)
101
+
n.Created, err = time.Parse(time.RFC3339, createdStr)
102
+
if err != nil {
103
+
return nil, fmt.Errorf("failed to parse created timestamp: %w", err)
104
+
}
105
+
notifications = append(notifications, &n)
106
+
}
107
+
108
+
return notifications, nil
109
+
}
110
+
111
+
// GetNotificationsWithEntities retrieves notifications with their related entities
112
+
func GetNotificationsWithEntities(e Execer, page pagination.Page, filters ...filter) ([]*models.NotificationWithEntity, error) {
113
+
var conditions []string
114
+
var args []any
115
+
116
+
for _, filter := range filters {
117
+
conditions = append(conditions, filter.Condition())
118
+
args = append(args, filter.Arg()...)
119
+
}
120
+
121
+
whereClause := ""
122
+
if len(conditions) > 0 {
123
+
whereClause = "WHERE " + conditions[0]
124
+
for _, condition := range conditions[1:] {
125
+
whereClause += " AND " + condition
126
+
}
127
+
}
128
+
129
+
query := fmt.Sprintf(`
130
+
select
131
+
n.id, n.recipient_did, n.actor_did, n.type, n.entity_type, n.entity_id,
132
+
n.read, n.created, n.repo_id, n.issue_id, n.pull_id,
133
+
r.id as r_id, r.did as r_did, r.name as r_name, r.description as r_description,
134
+
i.id as i_id, i.did as i_did, i.issue_id as i_issue_id, i.title as i_title, i.open as i_open,
135
+
p.id as p_id, p.owner_did as p_owner_did, p.pull_id as p_pull_id, p.title as p_title, p.state as p_state
136
+
from notifications n
137
+
left join repos r on n.repo_id = r.id
138
+
left join issues i on n.issue_id = i.id
139
+
left join pulls p on n.pull_id = p.id
140
+
%s
141
+
order by n.created desc
142
+
limit ? offset ?
143
+
`, whereClause)
144
+
145
+
args = append(args, page.Limit, page.Offset)
146
+
147
+
rows, err := e.QueryContext(context.Background(), query, args...)
148
+
if err != nil {
149
+
return nil, fmt.Errorf("failed to query notifications with entities: %w", err)
150
+
}
151
+
defer rows.Close()
152
+
153
+
var notifications []*models.NotificationWithEntity
154
+
for rows.Next() {
155
+
var n models.Notification
156
+
var typeStr string
157
+
var createdStr string
158
+
var repo models.Repo
159
+
var issue models.Issue
160
+
var pull models.Pull
161
+
var rId, iId, pId sql.NullInt64
162
+
var rDid, rName, rDescription sql.NullString
163
+
var iDid sql.NullString
164
+
var iIssueId sql.NullInt64
165
+
var iTitle sql.NullString
166
+
var iOpen sql.NullBool
167
+
var pOwnerDid sql.NullString
168
+
var pPullId sql.NullInt64
169
+
var pTitle sql.NullString
170
+
var pState sql.NullInt64
171
+
172
+
err := rows.Scan(
173
+
&n.ID, &n.RecipientDid, &n.ActorDid, &typeStr, &n.EntityType, &n.EntityId,
174
+
&n.Read, &createdStr, &n.RepoId, &n.IssueId, &n.PullId,
175
+
&rId, &rDid, &rName, &rDescription,
176
+
&iId, &iDid, &iIssueId, &iTitle, &iOpen,
177
+
&pId, &pOwnerDid, &pPullId, &pTitle, &pState,
178
+
)
179
+
if err != nil {
180
+
return nil, fmt.Errorf("failed to scan notification with entities: %w", err)
181
+
}
182
+
183
+
n.Type = models.NotificationType(typeStr)
184
+
n.Created, err = time.Parse(time.RFC3339, createdStr)
185
+
if err != nil {
186
+
return nil, fmt.Errorf("failed to parse created timestamp: %w", err)
187
+
}
188
+
189
+
nwe := &models.NotificationWithEntity{Notification: &n}
190
+
191
+
// populate repo if present
192
+
if rId.Valid {
193
+
repo.Id = rId.Int64
194
+
if rDid.Valid {
195
+
repo.Did = rDid.String
196
+
}
197
+
if rName.Valid {
198
+
repo.Name = rName.String
199
+
}
200
+
if rDescription.Valid {
201
+
repo.Description = rDescription.String
202
+
}
203
+
nwe.Repo = &repo
204
+
}
205
+
206
+
// populate issue if present
207
+
if iId.Valid {
208
+
issue.Id = iId.Int64
209
+
if iDid.Valid {
210
+
issue.Did = iDid.String
211
+
}
212
+
if iIssueId.Valid {
213
+
issue.IssueId = int(iIssueId.Int64)
214
+
}
215
+
if iTitle.Valid {
216
+
issue.Title = iTitle.String
217
+
}
218
+
if iOpen.Valid {
219
+
issue.Open = iOpen.Bool
220
+
}
221
+
nwe.Issue = &issue
222
+
}
223
+
224
+
// populate pull if present
225
+
if pId.Valid {
226
+
pull.ID = int(pId.Int64)
227
+
if pOwnerDid.Valid {
228
+
pull.OwnerDid = pOwnerDid.String
229
+
}
230
+
if pPullId.Valid {
231
+
pull.PullId = int(pPullId.Int64)
232
+
}
233
+
if pTitle.Valid {
234
+
pull.Title = pTitle.String
235
+
}
236
+
if pState.Valid {
237
+
pull.State = models.PullState(pState.Int64)
238
+
}
239
+
nwe.Pull = &pull
240
+
}
241
+
242
+
notifications = append(notifications, nwe)
243
+
}
244
+
245
+
return notifications, nil
246
+
}
247
+
248
+
// GetNotifications retrieves notifications with filters
249
+
func GetNotifications(e Execer, filters ...filter) ([]*models.Notification, error) {
250
+
return GetNotificationsPaginated(e, pagination.FirstPage(), filters...)
251
+
}
252
+
253
+
func CountNotifications(e Execer, filters ...filter) (int64, error) {
254
+
var conditions []string
255
+
var args []any
256
+
for _, filter := range filters {
257
+
conditions = append(conditions, filter.Condition())
258
+
args = append(args, filter.Arg()...)
259
+
}
260
+
261
+
whereClause := ""
262
+
if conditions != nil {
263
+
whereClause = " where " + strings.Join(conditions, " and ")
264
+
}
265
+
266
+
query := fmt.Sprintf(`select count(1) from notifications %s`, whereClause)
267
+
var count int64
268
+
err := e.QueryRow(query, args...).Scan(&count)
269
+
270
+
if !errors.Is(err, sql.ErrNoRows) && err != nil {
271
+
return 0, err
272
+
}
273
+
274
+
return count, nil
275
+
}
276
+
277
+
func (d *DB) MarkNotificationRead(ctx context.Context, notificationID int64, userDID string) error {
278
+
idFilter := FilterEq("id", notificationID)
279
+
recipientFilter := FilterEq("recipient_did", userDID)
280
+
281
+
query := fmt.Sprintf(`
282
+
UPDATE notifications
283
+
SET read = 1
284
+
WHERE %s AND %s
285
+
`, idFilter.Condition(), recipientFilter.Condition())
286
+
287
+
args := append(idFilter.Arg(), recipientFilter.Arg()...)
288
+
289
+
result, err := d.DB.ExecContext(ctx, query, args...)
290
+
if err != nil {
291
+
return fmt.Errorf("failed to mark notification as read: %w", err)
292
+
}
293
+
294
+
rowsAffected, err := result.RowsAffected()
295
+
if err != nil {
296
+
return fmt.Errorf("failed to get rows affected: %w", err)
297
+
}
298
+
299
+
if rowsAffected == 0 {
300
+
return fmt.Errorf("notification not found or access denied")
301
+
}
302
+
303
+
return nil
304
+
}
305
+
306
+
func (d *DB) MarkAllNotificationsRead(ctx context.Context, userDID string) error {
307
+
recipientFilter := FilterEq("recipient_did", userDID)
308
+
readFilter := FilterEq("read", 0)
309
+
310
+
query := fmt.Sprintf(`
311
+
UPDATE notifications
312
+
SET read = 1
313
+
WHERE %s AND %s
314
+
`, recipientFilter.Condition(), readFilter.Condition())
315
+
316
+
args := append(recipientFilter.Arg(), readFilter.Arg()...)
317
+
318
+
_, err := d.DB.ExecContext(ctx, query, args...)
319
+
if err != nil {
320
+
return fmt.Errorf("failed to mark all notifications as read: %w", err)
321
+
}
322
+
323
+
return nil
324
+
}
325
+
326
+
func (d *DB) DeleteNotification(ctx context.Context, notificationID int64, userDID string) error {
327
+
idFilter := FilterEq("id", notificationID)
328
+
recipientFilter := FilterEq("recipient_did", userDID)
329
+
330
+
query := fmt.Sprintf(`
331
+
DELETE FROM notifications
332
+
WHERE %s AND %s
333
+
`, idFilter.Condition(), recipientFilter.Condition())
334
+
335
+
args := append(idFilter.Arg(), recipientFilter.Arg()...)
336
+
337
+
result, err := d.DB.ExecContext(ctx, query, args...)
338
+
if err != nil {
339
+
return fmt.Errorf("failed to delete notification: %w", err)
340
+
}
341
+
342
+
rowsAffected, err := result.RowsAffected()
343
+
if err != nil {
344
+
return fmt.Errorf("failed to get rows affected: %w", err)
345
+
}
346
+
347
+
if rowsAffected == 0 {
348
+
return fmt.Errorf("notification not found or access denied")
349
+
}
350
+
351
+
return nil
352
+
}
353
+
354
+
func (d *DB) GetNotificationPreferences(ctx context.Context, userDID string) (*models.NotificationPreferences, error) {
355
+
userFilter := FilterEq("user_did", userDID)
356
+
357
+
query := fmt.Sprintf(`
358
+
SELECT id, user_did, repo_starred, issue_created, issue_commented, pull_created,
359
+
pull_commented, followed, pull_merged, issue_closed, email_notifications
360
+
FROM notification_preferences
361
+
WHERE %s
362
+
`, userFilter.Condition())
363
+
364
+
var prefs models.NotificationPreferences
365
+
err := d.DB.QueryRowContext(ctx, query, userFilter.Arg()...).Scan(
366
+
&prefs.ID,
367
+
&prefs.UserDid,
368
+
&prefs.RepoStarred,
369
+
&prefs.IssueCreated,
370
+
&prefs.IssueCommented,
371
+
&prefs.PullCreated,
372
+
&prefs.PullCommented,
373
+
&prefs.Followed,
374
+
&prefs.PullMerged,
375
+
&prefs.IssueClosed,
376
+
&prefs.EmailNotifications,
377
+
)
378
+
379
+
if err != nil {
380
+
if err == sql.ErrNoRows {
381
+
return &models.NotificationPreferences{
382
+
UserDid: userDID,
383
+
RepoStarred: true,
384
+
IssueCreated: true,
385
+
IssueCommented: true,
386
+
PullCreated: true,
387
+
PullCommented: true,
388
+
Followed: true,
389
+
PullMerged: true,
390
+
IssueClosed: true,
391
+
EmailNotifications: false,
392
+
}, nil
393
+
}
394
+
return nil, fmt.Errorf("failed to get notification preferences: %w", err)
395
+
}
396
+
397
+
return &prefs, nil
398
+
}
399
+
400
+
func (d *DB) UpdateNotificationPreferences(ctx context.Context, prefs *models.NotificationPreferences) error {
401
+
query := `
402
+
INSERT OR REPLACE INTO notification_preferences
403
+
(user_did, repo_starred, issue_created, issue_commented, pull_created,
404
+
pull_commented, followed, pull_merged, issue_closed, email_notifications)
405
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
406
+
`
407
+
408
+
result, err := d.DB.ExecContext(ctx, query,
409
+
prefs.UserDid,
410
+
prefs.RepoStarred,
411
+
prefs.IssueCreated,
412
+
prefs.IssueCommented,
413
+
prefs.PullCreated,
414
+
prefs.PullCommented,
415
+
prefs.Followed,
416
+
prefs.PullMerged,
417
+
prefs.IssueClosed,
418
+
prefs.EmailNotifications,
419
+
)
420
+
if err != nil {
421
+
return fmt.Errorf("failed to update notification preferences: %w", err)
422
+
}
423
+
424
+
if prefs.ID == 0 {
425
+
id, err := result.LastInsertId()
426
+
if err != nil {
427
+
return fmt.Errorf("failed to get preferences ID: %w", err)
428
+
}
429
+
prefs.ID = id
430
+
}
431
+
432
+
return nil
433
+
}
434
+
435
+
func (d *DB) ClearOldNotifications(ctx context.Context, olderThan time.Duration) error {
436
+
cutoff := time.Now().Add(-olderThan)
437
+
createdFilter := FilterLte("created", cutoff)
438
+
439
+
query := fmt.Sprintf(`
440
+
DELETE FROM notifications
441
+
WHERE %s
442
+
`, createdFilter.Condition())
443
+
444
+
_, err := d.DB.ExecContext(ctx, query, createdFilter.Arg()...)
445
+
if err != nil {
446
+
return fmt.Errorf("failed to cleanup old notifications: %w", err)
447
+
}
448
+
449
+
return nil
450
+
}
-173
appview/db/oauth.go
-173
appview/db/oauth.go
···
1
-
package db
2
-
3
-
type OAuthRequest struct {
4
-
ID uint
5
-
AuthserverIss string
6
-
Handle string
7
-
State string
8
-
Did string
9
-
PdsUrl string
10
-
PkceVerifier string
11
-
DpopAuthserverNonce string
12
-
DpopPrivateJwk string
13
-
}
14
-
15
-
func SaveOAuthRequest(e Execer, oauthRequest OAuthRequest) error {
16
-
_, err := e.Exec(`
17
-
insert into oauth_requests (
18
-
auth_server_iss,
19
-
state,
20
-
handle,
21
-
did,
22
-
pds_url,
23
-
pkce_verifier,
24
-
dpop_auth_server_nonce,
25
-
dpop_private_jwk
26
-
) values (?, ?, ?, ?, ?, ?, ?, ?)`,
27
-
oauthRequest.AuthserverIss,
28
-
oauthRequest.State,
29
-
oauthRequest.Handle,
30
-
oauthRequest.Did,
31
-
oauthRequest.PdsUrl,
32
-
oauthRequest.PkceVerifier,
33
-
oauthRequest.DpopAuthserverNonce,
34
-
oauthRequest.DpopPrivateJwk,
35
-
)
36
-
return err
37
-
}
38
-
39
-
func GetOAuthRequestByState(e Execer, state string) (OAuthRequest, error) {
40
-
var req OAuthRequest
41
-
err := e.QueryRow(`
42
-
select
43
-
id,
44
-
auth_server_iss,
45
-
handle,
46
-
state,
47
-
did,
48
-
pds_url,
49
-
pkce_verifier,
50
-
dpop_auth_server_nonce,
51
-
dpop_private_jwk
52
-
from oauth_requests
53
-
where state = ?`, state).Scan(
54
-
&req.ID,
55
-
&req.AuthserverIss,
56
-
&req.Handle,
57
-
&req.State,
58
-
&req.Did,
59
-
&req.PdsUrl,
60
-
&req.PkceVerifier,
61
-
&req.DpopAuthserverNonce,
62
-
&req.DpopPrivateJwk,
63
-
)
64
-
return req, err
65
-
}
66
-
67
-
func DeleteOAuthRequestByState(e Execer, state string) error {
68
-
_, err := e.Exec(`
69
-
delete from oauth_requests
70
-
where state = ?`, state)
71
-
return err
72
-
}
73
-
74
-
type OAuthSession struct {
75
-
ID uint
76
-
Handle string
77
-
Did string
78
-
PdsUrl string
79
-
AccessJwt string
80
-
RefreshJwt string
81
-
AuthServerIss string
82
-
DpopPdsNonce string
83
-
DpopAuthserverNonce string
84
-
DpopPrivateJwk string
85
-
Expiry string
86
-
}
87
-
88
-
func SaveOAuthSession(e Execer, session OAuthSession) error {
89
-
_, err := e.Exec(`
90
-
insert into oauth_sessions (
91
-
did,
92
-
handle,
93
-
pds_url,
94
-
access_jwt,
95
-
refresh_jwt,
96
-
auth_server_iss,
97
-
dpop_auth_server_nonce,
98
-
dpop_private_jwk,
99
-
expiry
100
-
) values (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
101
-
session.Did,
102
-
session.Handle,
103
-
session.PdsUrl,
104
-
session.AccessJwt,
105
-
session.RefreshJwt,
106
-
session.AuthServerIss,
107
-
session.DpopAuthserverNonce,
108
-
session.DpopPrivateJwk,
109
-
session.Expiry,
110
-
)
111
-
return err
112
-
}
113
-
114
-
func RefreshOAuthSession(e Execer, did string, accessJwt, refreshJwt, expiry string) error {
115
-
_, err := e.Exec(`
116
-
update oauth_sessions
117
-
set access_jwt = ?, refresh_jwt = ?, expiry = ?
118
-
where did = ?`,
119
-
accessJwt,
120
-
refreshJwt,
121
-
expiry,
122
-
did,
123
-
)
124
-
return err
125
-
}
126
-
127
-
func GetOAuthSessionByDid(e Execer, did string) (*OAuthSession, error) {
128
-
var session OAuthSession
129
-
err := e.QueryRow(`
130
-
select
131
-
id,
132
-
did,
133
-
handle,
134
-
pds_url,
135
-
access_jwt,
136
-
refresh_jwt,
137
-
auth_server_iss,
138
-
dpop_auth_server_nonce,
139
-
dpop_private_jwk,
140
-
expiry
141
-
from oauth_sessions
142
-
where did = ?`, did).Scan(
143
-
&session.ID,
144
-
&session.Did,
145
-
&session.Handle,
146
-
&session.PdsUrl,
147
-
&session.AccessJwt,
148
-
&session.RefreshJwt,
149
-
&session.AuthServerIss,
150
-
&session.DpopAuthserverNonce,
151
-
&session.DpopPrivateJwk,
152
-
&session.Expiry,
153
-
)
154
-
return &session, err
155
-
}
156
-
157
-
func DeleteOAuthSessionByDid(e Execer, did string) error {
158
-
_, err := e.Exec(`
159
-
delete from oauth_sessions
160
-
where did = ?`, did)
161
-
return err
162
-
}
163
-
164
-
func UpdateDpopPdsNonce(e Execer, did string, dpopPdsNonce string) error {
165
-
_, err := e.Exec(`
166
-
update oauth_sessions
167
-
set dpop_pds_nonce = ?
168
-
where did = ?`,
169
-
dpopPdsNonce,
170
-
did,
171
-
)
172
-
return err
173
-
}
···
+17
-139
appview/db/pipeline.go
+17
-139
appview/db/pipeline.go
···
6
"strings"
7
"time"
8
9
-
"github.com/bluesky-social/indigo/atproto/syntax"
10
-
"github.com/go-git/go-git/v5/plumbing"
11
-
spindle "tangled.sh/tangled.sh/core/spindle/models"
12
-
"tangled.sh/tangled.sh/core/workflow"
13
)
14
15
-
type Pipeline struct {
16
-
Id int
17
-
Rkey string
18
-
Knot string
19
-
RepoOwner syntax.DID
20
-
RepoName string
21
-
TriggerId int
22
-
Sha string
23
-
Created time.Time
24
-
25
-
// populate when querying for reverse mappings
26
-
Trigger *Trigger
27
-
Statuses map[string]WorkflowStatus
28
-
}
29
-
30
-
type WorkflowStatus struct {
31
-
Data []PipelineStatus
32
-
}
33
-
34
-
func (w WorkflowStatus) Latest() PipelineStatus {
35
-
return w.Data[len(w.Data)-1]
36
-
}
37
-
38
-
// time taken by this workflow to reach an "end state"
39
-
func (w WorkflowStatus) TimeTaken() time.Duration {
40
-
var start, end *time.Time
41
-
for _, s := range w.Data {
42
-
if s.Status.IsStart() {
43
-
start = &s.Created
44
-
}
45
-
if s.Status.IsFinish() {
46
-
end = &s.Created
47
-
}
48
-
}
49
-
50
-
if start != nil && end != nil && end.After(*start) {
51
-
return end.Sub(*start)
52
-
}
53
-
54
-
return 0
55
-
}
56
-
57
-
func (p Pipeline) Counts() map[string]int {
58
-
m := make(map[string]int)
59
-
for _, w := range p.Statuses {
60
-
m[w.Latest().Status.String()] += 1
61
-
}
62
-
return m
63
-
}
64
-
65
-
func (p Pipeline) TimeTaken() time.Duration {
66
-
var s time.Duration
67
-
for _, w := range p.Statuses {
68
-
s += w.TimeTaken()
69
-
}
70
-
return s
71
-
}
72
-
73
-
func (p Pipeline) Workflows() []string {
74
-
var ws []string
75
-
for v := range p.Statuses {
76
-
ws = append(ws, v)
77
-
}
78
-
slices.Sort(ws)
79
-
return ws
80
-
}
81
-
82
-
// if we know that a spindle has picked up this pipeline, then it is Responding
83
-
func (p Pipeline) IsResponding() bool {
84
-
return len(p.Statuses) != 0
85
-
}
86
-
87
-
type Trigger struct {
88
-
Id int
89
-
Kind workflow.TriggerKind
90
-
91
-
// push trigger fields
92
-
PushRef *string
93
-
PushNewSha *string
94
-
PushOldSha *string
95
-
96
-
// pull request trigger fields
97
-
PRSourceBranch *string
98
-
PRTargetBranch *string
99
-
PRSourceSha *string
100
-
PRAction *string
101
-
}
102
-
103
-
func (t *Trigger) IsPush() bool {
104
-
return t != nil && t.Kind == workflow.TriggerKindPush
105
-
}
106
-
107
-
func (t *Trigger) IsPullRequest() bool {
108
-
return t != nil && t.Kind == workflow.TriggerKindPullRequest
109
-
}
110
-
111
-
func (t *Trigger) TargetRef() string {
112
-
if t.IsPush() {
113
-
return plumbing.ReferenceName(*t.PushRef).Short()
114
-
} else if t.IsPullRequest() {
115
-
return *t.PRTargetBranch
116
-
}
117
-
118
-
return ""
119
-
}
120
-
121
-
type PipelineStatus struct {
122
-
ID int
123
-
Spindle string
124
-
Rkey string
125
-
PipelineKnot string
126
-
PipelineRkey string
127
-
Created time.Time
128
-
Workflow string
129
-
Status spindle.StatusKind
130
-
Error *string
131
-
ExitCode int
132
-
}
133
-
134
-
func GetPipelines(e Execer, filters ...filter) ([]Pipeline, error) {
135
-
var pipelines []Pipeline
136
137
var conditions []string
138
var args []any
···
156
defer rows.Close()
157
158
for rows.Next() {
159
-
var pipeline Pipeline
160
var createdAt string
161
err = rows.Scan(
162
&pipeline.Id,
···
185
return pipelines, nil
186
}
187
188
-
func AddPipeline(e Execer, pipeline Pipeline) error {
189
args := []any{
190
pipeline.Rkey,
191
pipeline.Knot,
···
216
return err
217
}
218
219
-
func AddTrigger(e Execer, trigger Trigger) (int64, error) {
220
args := []any{
221
trigger.Kind,
222
trigger.PushRef,
···
252
return res.LastInsertId()
253
}
254
255
-
func AddPipelineStatus(e Execer, status PipelineStatus) error {
256
args := []any{
257
status.Spindle,
258
status.Rkey,
···
290
291
// this is a mega query, but the most useful one:
292
// get N pipelines, for each one get the latest status of its N workflows
293
-
func GetPipelineStatuses(e Execer, filters ...filter) ([]Pipeline, error) {
294
var conditions []string
295
var args []any
296
for _, filter := range filters {
···
335
}
336
defer rows.Close()
337
338
-
pipelines := make(map[string]Pipeline)
339
for rows.Next() {
340
-
var p Pipeline
341
-
var t Trigger
342
var created string
343
344
err := rows.Scan(
···
370
371
t.Id = p.TriggerId
372
p.Trigger = &t
373
-
p.Statuses = make(map[string]WorkflowStatus)
374
375
k := fmt.Sprintf("%s/%s", p.Knot, p.Rkey)
376
pipelines[k] = p
···
409
defer rows.Close()
410
411
for rows.Next() {
412
-
var ps PipelineStatus
413
var created string
414
415
err := rows.Scan(
···
442
}
443
statuses, _ := pipeline.Statuses[ps.Workflow]
444
if !ok {
445
-
pipeline.Statuses[ps.Workflow] = WorkflowStatus{}
446
}
447
448
// append
···
453
pipelines[key] = pipeline
454
}
455
456
-
var all []Pipeline
457
for _, p := range pipelines {
458
for _, s := range p.Statuses {
459
-
slices.SortFunc(s.Data, func(a, b PipelineStatus) int {
460
if a.Created.After(b.Created) {
461
return 1
462
}
···
476
}
477
478
// sort pipelines by date
479
-
slices.SortFunc(all, func(a, b Pipeline) int {
480
if a.Created.After(b.Created) {
481
return -1
482
}
···
6
"strings"
7
"time"
8
9
+
"tangled.org/core/appview/models"
10
)
11
12
+
func GetPipelines(e Execer, filters ...filter) ([]models.Pipeline, error) {
13
+
var pipelines []models.Pipeline
14
15
var conditions []string
16
var args []any
···
34
defer rows.Close()
35
36
for rows.Next() {
37
+
var pipeline models.Pipeline
38
var createdAt string
39
err = rows.Scan(
40
&pipeline.Id,
···
63
return pipelines, nil
64
}
65
66
+
func AddPipeline(e Execer, pipeline models.Pipeline) error {
67
args := []any{
68
pipeline.Rkey,
69
pipeline.Knot,
···
94
return err
95
}
96
97
+
func AddTrigger(e Execer, trigger models.Trigger) (int64, error) {
98
args := []any{
99
trigger.Kind,
100
trigger.PushRef,
···
130
return res.LastInsertId()
131
}
132
133
+
func AddPipelineStatus(e Execer, status models.PipelineStatus) error {
134
args := []any{
135
status.Spindle,
136
status.Rkey,
···
168
169
// this is a mega query, but the most useful one:
170
// get N pipelines, for each one get the latest status of its N workflows
171
+
func GetPipelineStatuses(e Execer, filters ...filter) ([]models.Pipeline, error) {
172
var conditions []string
173
var args []any
174
for _, filter := range filters {
···
213
}
214
defer rows.Close()
215
216
+
pipelines := make(map[string]models.Pipeline)
217
for rows.Next() {
218
+
var p models.Pipeline
219
+
var t models.Trigger
220
var created string
221
222
err := rows.Scan(
···
248
249
t.Id = p.TriggerId
250
p.Trigger = &t
251
+
p.Statuses = make(map[string]models.WorkflowStatus)
252
253
k := fmt.Sprintf("%s/%s", p.Knot, p.Rkey)
254
pipelines[k] = p
···
287
defer rows.Close()
288
289
for rows.Next() {
290
+
var ps models.PipelineStatus
291
var created string
292
293
err := rows.Scan(
···
320
}
321
statuses, _ := pipeline.Statuses[ps.Workflow]
322
if !ok {
323
+
pipeline.Statuses[ps.Workflow] = models.WorkflowStatus{}
324
}
325
326
// append
···
331
pipelines[key] = pipeline
332
}
333
334
+
var all []models.Pipeline
335
for _, p := range pipelines {
336
for _, s := range p.Statuses {
337
+
slices.SortFunc(s.Data, func(a, b models.PipelineStatus) int {
338
if a.Created.After(b.Created) {
339
return 1
340
}
···
354
}
355
356
// sort pipelines by date
357
+
slices.SortFunc(all, func(a, b models.Pipeline) int {
358
if a.Created.After(b.Created) {
359
return -1
360
}
+27
-196
appview/db/profile.go
+27
-196
appview/db/profile.go
···
10
"time"
11
12
"github.com/bluesky-social/indigo/atproto/syntax"
13
-
"tangled.sh/tangled.sh/core/api/tangled"
14
)
15
16
-
type RepoEvent struct {
17
-
Repo *Repo
18
-
Source *Repo
19
-
}
20
-
21
-
type ProfileTimeline struct {
22
-
ByMonth []ByMonth
23
-
}
24
-
25
-
func (p *ProfileTimeline) IsEmpty() bool {
26
-
if p == nil {
27
-
return true
28
-
}
29
-
30
-
for _, m := range p.ByMonth {
31
-
if !m.IsEmpty() {
32
-
return false
33
-
}
34
-
}
35
-
36
-
return true
37
-
}
38
-
39
-
type ByMonth struct {
40
-
RepoEvents []RepoEvent
41
-
IssueEvents IssueEvents
42
-
PullEvents PullEvents
43
-
}
44
-
45
-
func (b ByMonth) IsEmpty() bool {
46
-
return len(b.RepoEvents) == 0 &&
47
-
len(b.IssueEvents.Items) == 0 &&
48
-
len(b.PullEvents.Items) == 0
49
-
}
50
-
51
-
type IssueEvents struct {
52
-
Items []*Issue
53
-
}
54
-
55
-
type IssueEventStats struct {
56
-
Open int
57
-
Closed int
58
-
}
59
-
60
-
func (i IssueEvents) Stats() IssueEventStats {
61
-
var open, closed int
62
-
for _, issue := range i.Items {
63
-
if issue.Open {
64
-
open += 1
65
-
} else {
66
-
closed += 1
67
-
}
68
-
}
69
-
70
-
return IssueEventStats{
71
-
Open: open,
72
-
Closed: closed,
73
-
}
74
-
}
75
-
76
-
type PullEvents struct {
77
-
Items []*Pull
78
-
}
79
-
80
-
func (p PullEvents) Stats() PullEventStats {
81
-
var open, merged, closed int
82
-
for _, pull := range p.Items {
83
-
switch pull.State {
84
-
case PullOpen:
85
-
open += 1
86
-
case PullMerged:
87
-
merged += 1
88
-
case PullClosed:
89
-
closed += 1
90
-
}
91
-
}
92
-
93
-
return PullEventStats{
94
-
Open: open,
95
-
Merged: merged,
96
-
Closed: closed,
97
-
}
98
-
}
99
-
100
-
type PullEventStats struct {
101
-
Closed int
102
-
Open int
103
-
Merged int
104
-
}
105
-
106
const TimeframeMonths = 7
107
108
-
func MakeProfileTimeline(e Execer, forDid string) (*ProfileTimeline, error) {
109
-
timeline := ProfileTimeline{
110
-
ByMonth: make([]ByMonth, TimeframeMonths),
111
}
112
currentMonth := time.Now().Month()
113
timeframe := fmt.Sprintf("-%d months", TimeframeMonths)
···
162
163
for _, repo := range repos {
164
// TODO: get this in the original query; requires COALESCE because nullable
165
-
var sourceRepo *Repo
166
if repo.Source != "" {
167
sourceRepo, err = GetRepoByAtUri(e, repo.Source)
168
if err != nil {
···
180
idx := currentMonth - repoMonth
181
182
items := &timeline.ByMonth[idx].RepoEvents
183
-
*items = append(*items, RepoEvent{
184
Repo: &repo,
185
Source: sourceRepo,
186
})
···
189
return &timeline, nil
190
}
191
192
-
type Profile struct {
193
-
// ids
194
-
ID int
195
-
Did string
196
-
197
-
// data
198
-
Description string
199
-
IncludeBluesky bool
200
-
Location string
201
-
Links [5]string
202
-
Stats [2]VanityStat
203
-
PinnedRepos [6]syntax.ATURI
204
-
}
205
-
206
-
func (p Profile) IsLinksEmpty() bool {
207
-
for _, l := range p.Links {
208
-
if l != "" {
209
-
return false
210
-
}
211
-
}
212
-
return true
213
-
}
214
-
215
-
func (p Profile) IsStatsEmpty() bool {
216
-
for _, s := range p.Stats {
217
-
if s.Kind != "" {
218
-
return false
219
-
}
220
-
}
221
-
return true
222
-
}
223
-
224
-
func (p Profile) IsPinnedReposEmpty() bool {
225
-
for _, r := range p.PinnedRepos {
226
-
if r != "" {
227
-
return false
228
-
}
229
-
}
230
-
return true
231
-
}
232
-
233
-
type VanityStatKind string
234
-
235
-
const (
236
-
VanityStatMergedPRCount VanityStatKind = "merged-pull-request-count"
237
-
VanityStatClosedPRCount VanityStatKind = "closed-pull-request-count"
238
-
VanityStatOpenPRCount VanityStatKind = "open-pull-request-count"
239
-
VanityStatOpenIssueCount VanityStatKind = "open-issue-count"
240
-
VanityStatClosedIssueCount VanityStatKind = "closed-issue-count"
241
-
VanityStatRepositoryCount VanityStatKind = "repository-count"
242
-
)
243
-
244
-
func (v VanityStatKind) String() string {
245
-
switch v {
246
-
case VanityStatMergedPRCount:
247
-
return "Merged PRs"
248
-
case VanityStatClosedPRCount:
249
-
return "Closed PRs"
250
-
case VanityStatOpenPRCount:
251
-
return "Open PRs"
252
-
case VanityStatOpenIssueCount:
253
-
return "Open Issues"
254
-
case VanityStatClosedIssueCount:
255
-
return "Closed Issues"
256
-
case VanityStatRepositoryCount:
257
-
return "Repositories"
258
-
}
259
-
return ""
260
-
}
261
-
262
-
type VanityStat struct {
263
-
Kind VanityStatKind
264
-
Value uint64
265
-
}
266
-
267
-
func (p *Profile) ProfileAt() syntax.ATURI {
268
-
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.Did, tangled.ActorProfileNSID, "self"))
269
-
}
270
-
271
-
func UpsertProfile(tx *sql.Tx, profile *Profile) error {
272
defer tx.Rollback()
273
274
// update links
···
366
return tx.Commit()
367
}
368
369
-
func GetProfiles(e Execer, filters ...filter) (map[string]*Profile, error) {
370
var conditions []string
371
var args []any
372
for _, filter := range filters {
···
396
return nil, err
397
}
398
399
-
profileMap := make(map[string]*Profile)
400
for rows.Next() {
401
-
var profile Profile
402
var includeBluesky int
403
404
err = rows.Scan(&profile.ID, &profile.Did, &profile.Description, &includeBluesky, &profile.Location)
···
469
return profileMap, nil
470
}
471
472
-
func GetProfile(e Execer, did string) (*Profile, error) {
473
-
var profile Profile
474
profile.Did = did
475
476
includeBluesky := 0
···
479
did,
480
).Scan(&profile.Description, &includeBluesky, &profile.Location)
481
if err == sql.ErrNoRows {
482
-
profile := Profile{}
483
profile.Did = did
484
return &profile, nil
485
}
···
539
return &profile, nil
540
}
541
542
-
func GetVanityStat(e Execer, did string, stat VanityStatKind) (uint64, error) {
543
query := ""
544
var args []any
545
switch stat {
546
-
case VanityStatMergedPRCount:
547
query = `select count(id) from pulls where owner_did = ? and state = ?`
548
-
args = append(args, did, PullMerged)
549
-
case VanityStatClosedPRCount:
550
query = `select count(id) from pulls where owner_did = ? and state = ?`
551
-
args = append(args, did, PullClosed)
552
-
case VanityStatOpenPRCount:
553
query = `select count(id) from pulls where owner_did = ? and state = ?`
554
-
args = append(args, did, PullOpen)
555
-
case VanityStatOpenIssueCount:
556
-
query = `select count(id) from issues where owner_did = ? and open = 1`
557
args = append(args, did)
558
-
case VanityStatClosedIssueCount:
559
-
query = `select count(id) from issues where owner_did = ? and open = 0`
560
args = append(args, did)
561
-
case VanityStatRepositoryCount:
562
query = `select count(id) from repos where did = ?`
563
args = append(args, did)
564
}
···
572
return result, nil
573
}
574
575
-
func ValidateProfile(e Execer, profile *Profile) error {
576
// ensure description is not too long
577
if len(profile.Description) > 256 {
578
return fmt.Errorf("Entered bio is too long.")
···
620
return nil
621
}
622
623
-
func validateLinks(profile *Profile) error {
624
for i, link := range profile.Links {
625
if link == "" {
626
continue
···
10
"time"
11
12
"github.com/bluesky-social/indigo/atproto/syntax"
13
+
"tangled.org/core/appview/models"
14
)
15
16
const TimeframeMonths = 7
17
18
+
func MakeProfileTimeline(e Execer, forDid string) (*models.ProfileTimeline, error) {
19
+
timeline := models.ProfileTimeline{
20
+
ByMonth: make([]models.ByMonth, TimeframeMonths),
21
}
22
currentMonth := time.Now().Month()
23
timeframe := fmt.Sprintf("-%d months", TimeframeMonths)
···
72
73
for _, repo := range repos {
74
// TODO: get this in the original query; requires COALESCE because nullable
75
+
var sourceRepo *models.Repo
76
if repo.Source != "" {
77
sourceRepo, err = GetRepoByAtUri(e, repo.Source)
78
if err != nil {
···
90
idx := currentMonth - repoMonth
91
92
items := &timeline.ByMonth[idx].RepoEvents
93
+
*items = append(*items, models.RepoEvent{
94
Repo: &repo,
95
Source: sourceRepo,
96
})
···
99
return &timeline, nil
100
}
101
102
+
func UpsertProfile(tx *sql.Tx, profile *models.Profile) error {
103
defer tx.Rollback()
104
105
// update links
···
197
return tx.Commit()
198
}
199
200
+
func GetProfiles(e Execer, filters ...filter) (map[string]*models.Profile, error) {
201
var conditions []string
202
var args []any
203
for _, filter := range filters {
···
227
return nil, err
228
}
229
230
+
profileMap := make(map[string]*models.Profile)
231
for rows.Next() {
232
+
var profile models.Profile
233
var includeBluesky int
234
235
err = rows.Scan(&profile.ID, &profile.Did, &profile.Description, &includeBluesky, &profile.Location)
···
300
return profileMap, nil
301
}
302
303
+
func GetProfile(e Execer, did string) (*models.Profile, error) {
304
+
var profile models.Profile
305
profile.Did = did
306
307
includeBluesky := 0
···
310
did,
311
).Scan(&profile.Description, &includeBluesky, &profile.Location)
312
if err == sql.ErrNoRows {
313
+
profile := models.Profile{}
314
profile.Did = did
315
return &profile, nil
316
}
···
370
return &profile, nil
371
}
372
373
+
func GetVanityStat(e Execer, did string, stat models.VanityStatKind) (uint64, error) {
374
query := ""
375
var args []any
376
switch stat {
377
+
case models.VanityStatMergedPRCount:
378
query = `select count(id) from pulls where owner_did = ? and state = ?`
379
+
args = append(args, did, models.PullMerged)
380
+
case models.VanityStatClosedPRCount:
381
query = `select count(id) from pulls where owner_did = ? and state = ?`
382
+
args = append(args, did, models.PullClosed)
383
+
case models.VanityStatOpenPRCount:
384
query = `select count(id) from pulls where owner_did = ? and state = ?`
385
+
args = append(args, did, models.PullOpen)
386
+
case models.VanityStatOpenIssueCount:
387
+
query = `select count(id) from issues where did = ? and open = 1`
388
args = append(args, did)
389
+
case models.VanityStatClosedIssueCount:
390
+
query = `select count(id) from issues where did = ? and open = 0`
391
args = append(args, did)
392
+
case models.VanityStatRepositoryCount:
393
query = `select count(id) from repos where did = ?`
394
args = append(args, did)
395
}
···
403
return result, nil
404
}
405
406
+
func ValidateProfile(e Execer, profile *models.Profile) error {
407
// ensure description is not too long
408
if len(profile.Description) > 256 {
409
return fmt.Errorf("Entered bio is too long.")
···
451
return nil
452
}
453
454
+
func validateLinks(profile *models.Profile) error {
455
for i, link := range profile.Links {
456
if link == "" {
457
continue
+7
-26
appview/db/pubkeys.go
+7
-26
appview/db/pubkeys.go
···
1
package db
2
3
import (
4
-
"encoding/json"
5
"time"
6
)
7
···
29
return err
30
}
31
32
-
type PublicKey struct {
33
-
Did string `json:"did"`
34
-
Key string `json:"key"`
35
-
Name string `json:"name"`
36
-
Rkey string `json:"rkey"`
37
-
Created *time.Time
38
-
}
39
-
40
-
func (p PublicKey) MarshalJSON() ([]byte, error) {
41
-
type Alias PublicKey
42
-
return json.Marshal(&struct {
43
-
Created string `json:"created"`
44
-
*Alias
45
-
}{
46
-
Created: p.Created.Format(time.RFC3339),
47
-
Alias: (*Alias)(&p),
48
-
})
49
-
}
50
-
51
-
func GetAllPublicKeys(e Execer) ([]PublicKey, error) {
52
-
var keys []PublicKey
53
54
rows, err := e.Query(`select key, name, did, rkey, created from public_keys`)
55
if err != nil {
···
58
defer rows.Close()
59
60
for rows.Next() {
61
-
var publicKey PublicKey
62
var createdAt string
63
if err := rows.Scan(&publicKey.Key, &publicKey.Name, &publicKey.Did, &publicKey.Rkey, &createdAt); err != nil {
64
return nil, err
···
75
return keys, nil
76
}
77
78
-
func GetPublicKeysForDid(e Execer, did string) ([]PublicKey, error) {
79
-
var keys []PublicKey
80
81
rows, err := e.Query(`select did, key, name, rkey, created from public_keys where did = ?`, did)
82
if err != nil {
···
85
defer rows.Close()
86
87
for rows.Next() {
88
-
var publicKey PublicKey
89
var createdAt string
90
if err := rows.Scan(&publicKey.Did, &publicKey.Key, &publicKey.Name, &publicKey.Rkey, &createdAt); err != nil {
91
return nil, err
···
1
package db
2
3
import (
4
+
"tangled.org/core/appview/models"
5
"time"
6
)
7
···
29
return err
30
}
31
32
+
func GetAllPublicKeys(e Execer) ([]models.PublicKey, error) {
33
+
var keys []models.PublicKey
34
35
rows, err := e.Query(`select key, name, did, rkey, created from public_keys`)
36
if err != nil {
···
39
defer rows.Close()
40
41
for rows.Next() {
42
+
var publicKey models.PublicKey
43
var createdAt string
44
if err := rows.Scan(&publicKey.Key, &publicKey.Name, &publicKey.Did, &publicKey.Rkey, &createdAt); err != nil {
45
return nil, err
···
56
return keys, nil
57
}
58
59
+
func GetPublicKeysForDid(e Execer, did string) ([]models.PublicKey, error) {
60
+
var keys []models.PublicKey
61
62
rows, err := e.Query(`select did, key, name, rkey, created from public_keys where did = ?`, did)
63
if err != nil {
···
66
defer rows.Close()
67
68
for rows.Next() {
69
+
var publicKey models.PublicKey
70
var createdAt string
71
if err := rows.Scan(&publicKey.Did, &publicKey.Key, &publicKey.Name, &publicKey.Rkey, &createdAt); err != nil {
72
return nil, err
+193
-572
appview/db/pulls.go
+193
-572
appview/db/pulls.go
···
1
package db
2
3
import (
4
"database/sql"
5
"fmt"
6
-
"log"
7
"slices"
8
"sort"
9
"strings"
10
"time"
11
12
"github.com/bluesky-social/indigo/atproto/syntax"
13
-
"tangled.sh/tangled.sh/core/api/tangled"
14
-
"tangled.sh/tangled.sh/core/patchutil"
15
-
"tangled.sh/tangled.sh/core/types"
16
-
)
17
-
18
-
type PullState int
19
-
20
-
const (
21
-
PullClosed PullState = iota
22
-
PullOpen
23
-
PullMerged
24
-
PullDeleted
25
)
26
27
-
func (p PullState) String() string {
28
-
switch p {
29
-
case PullOpen:
30
-
return "open"
31
-
case PullMerged:
32
-
return "merged"
33
-
case PullClosed:
34
-
return "closed"
35
-
case PullDeleted:
36
-
return "deleted"
37
-
default:
38
-
return "closed"
39
-
}
40
-
}
41
-
42
-
func (p PullState) IsOpen() bool {
43
-
return p == PullOpen
44
-
}
45
-
func (p PullState) IsMerged() bool {
46
-
return p == PullMerged
47
-
}
48
-
func (p PullState) IsClosed() bool {
49
-
return p == PullClosed
50
-
}
51
-
func (p PullState) IsDeleted() bool {
52
-
return p == PullDeleted
53
-
}
54
-
55
-
type Pull struct {
56
-
// ids
57
-
ID int
58
-
PullId int
59
-
60
-
// at ids
61
-
RepoAt syntax.ATURI
62
-
OwnerDid string
63
-
Rkey string
64
-
65
-
// content
66
-
Title string
67
-
Body string
68
-
TargetBranch string
69
-
State PullState
70
-
Submissions []*PullSubmission
71
-
72
-
// stacking
73
-
StackId string // nullable string
74
-
ChangeId string // nullable string
75
-
ParentChangeId string // nullable string
76
-
77
-
// meta
78
-
Created time.Time
79
-
PullSource *PullSource
80
-
81
-
// optionally, populate this when querying for reverse mappings
82
-
Repo *Repo
83
-
}
84
-
85
-
func (p Pull) AsRecord() tangled.RepoPull {
86
-
var source *tangled.RepoPull_Source
87
-
if p.PullSource != nil {
88
-
s := p.PullSource.AsRecord()
89
-
source = &s
90
-
source.Sha = p.LatestSha()
91
-
}
92
-
93
-
record := tangled.RepoPull{
94
-
Title: p.Title,
95
-
Body: &p.Body,
96
-
CreatedAt: p.Created.Format(time.RFC3339),
97
-
Target: &tangled.RepoPull_Target{
98
-
Repo: p.RepoAt.String(),
99
-
Branch: p.TargetBranch,
100
-
},
101
-
Patch: p.LatestPatch(),
102
-
Source: source,
103
-
}
104
-
return record
105
-
}
106
-
107
-
type PullSource struct {
108
-
Branch string
109
-
RepoAt *syntax.ATURI
110
-
111
-
// optionally populate this for reverse mappings
112
-
Repo *Repo
113
-
}
114
-
115
-
func (p PullSource) AsRecord() tangled.RepoPull_Source {
116
-
var repoAt *string
117
-
if p.RepoAt != nil {
118
-
s := p.RepoAt.String()
119
-
repoAt = &s
120
-
}
121
-
record := tangled.RepoPull_Source{
122
-
Branch: p.Branch,
123
-
Repo: repoAt,
124
-
}
125
-
return record
126
-
}
127
-
128
-
type PullSubmission struct {
129
-
// ids
130
-
ID int
131
-
PullId int
132
-
133
-
// at ids
134
-
RepoAt syntax.ATURI
135
-
136
-
// content
137
-
RoundNumber int
138
-
Patch string
139
-
Comments []PullComment
140
-
SourceRev string // include the rev that was used to create this submission: only for branch/fork PRs
141
-
142
-
// meta
143
-
Created time.Time
144
-
}
145
-
146
-
type PullComment struct {
147
-
// ids
148
-
ID int
149
-
PullId int
150
-
SubmissionId int
151
-
152
-
// at ids
153
-
RepoAt string
154
-
OwnerDid string
155
-
CommentAt string
156
-
157
-
// content
158
-
Body string
159
-
160
-
// meta
161
-
Created time.Time
162
-
}
163
-
164
-
func (p *Pull) LatestPatch() string {
165
-
latestSubmission := p.Submissions[p.LastRoundNumber()]
166
-
return latestSubmission.Patch
167
-
}
168
-
169
-
func (p *Pull) LatestSha() string {
170
-
latestSubmission := p.Submissions[p.LastRoundNumber()]
171
-
return latestSubmission.SourceRev
172
-
}
173
-
174
-
func (p *Pull) PullAt() syntax.ATURI {
175
-
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.OwnerDid, tangled.RepoPullNSID, p.Rkey))
176
-
}
177
-
178
-
func (p *Pull) LastRoundNumber() int {
179
-
return len(p.Submissions) - 1
180
-
}
181
-
182
-
func (p *Pull) IsPatchBased() bool {
183
-
return p.PullSource == nil
184
-
}
185
-
186
-
func (p *Pull) IsBranchBased() bool {
187
-
if p.PullSource != nil {
188
-
if p.PullSource.RepoAt != nil {
189
-
return p.PullSource.RepoAt == &p.RepoAt
190
-
} else {
191
-
// no repo specified
192
-
return true
193
-
}
194
-
}
195
-
return false
196
-
}
197
-
198
-
func (p *Pull) IsForkBased() bool {
199
-
if p.PullSource != nil {
200
-
if p.PullSource.RepoAt != nil {
201
-
// make sure repos are different
202
-
return p.PullSource.RepoAt != &p.RepoAt
203
-
}
204
-
}
205
-
return false
206
-
}
207
-
208
-
func (p *Pull) IsStacked() bool {
209
-
return p.StackId != ""
210
-
}
211
-
212
-
func (s PullSubmission) IsFormatPatch() bool {
213
-
return patchutil.IsFormatPatch(s.Patch)
214
-
}
215
-
216
-
func (s PullSubmission) AsFormatPatch() []types.FormatPatch {
217
-
patches, err := patchutil.ExtractPatches(s.Patch)
218
-
if err != nil {
219
-
log.Println("error extracting patches from submission:", err)
220
-
return []types.FormatPatch{}
221
-
}
222
-
223
-
return patches
224
-
}
225
-
226
-
func NewPull(tx *sql.Tx, pull *Pull) error {
227
_, err := tx.Exec(`
228
insert or ignore into repo_pull_seqs (repo_at, next_pull_id)
229
values (?, 1)
···
244
}
245
246
pull.PullId = nextId
247
-
pull.State = PullOpen
248
249
var sourceBranch, sourceRepoAt *string
250
if pull.PullSource != nil {
···
266
parentChangeId = &pull.ParentChangeId
267
}
268
269
-
_, err = tx.Exec(
270
`
271
insert into pulls (
272
repo_at, owner_did, pull_id, title, target_branch, body, rkey, state, source_branch, source_repo_at, stack_id, change_id, parent_change_id
···
290
return err
291
}
292
293
_, err = tx.Exec(`
294
-
insert into pull_submissions (pull_id, repo_at, round_number, patch, source_rev)
295
-
values (?, ?, ?, ?, ?)
296
-
`, pull.PullId, pull.RepoAt, 0, pull.Submissions[0].Patch, pull.Submissions[0].SourceRev)
297
return err
298
}
299
···
311
return pullId - 1, err
312
}
313
314
-
func GetPullsWithLimit(e Execer, limit int, filters ...filter) ([]*Pull, error) {
315
-
pulls := make(map[int]*Pull)
316
317
var conditions []string
318
var args []any
···
332
333
query := fmt.Sprintf(`
334
select
335
owner_did,
336
repo_at,
337
pull_id,
···
361
defer rows.Close()
362
363
for rows.Next() {
364
-
var pull Pull
365
var createdAt string
366
var sourceBranch, sourceRepoAt, stackId, changeId, parentChangeId sql.NullString
367
err := rows.Scan(
368
&pull.OwnerDid,
369
&pull.RepoAt,
370
&pull.PullId,
···
391
pull.Created = createdTime
392
393
if sourceBranch.Valid {
394
-
pull.PullSource = &PullSource{
395
Branch: sourceBranch.String,
396
}
397
if sourceRepoAt.Valid {
···
413
pull.ParentChangeId = parentChangeId.String
414
}
415
416
-
pulls[pull.PullId] = &pull
417
}
418
419
-
// get latest round no. for each pull
420
-
inClause := strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ")
421
-
submissionsQuery := fmt.Sprintf(`
422
-
select
423
-
id, pull_id, round_number, patch, created, source_rev
424
-
from
425
-
pull_submissions
426
-
where
427
-
repo_at in (%s) and pull_id in (%s)
428
-
`, inClause, inClause)
429
-
430
-
args = make([]any, len(pulls)*2)
431
-
idx := 0
432
-
for _, p := range pulls {
433
-
args[idx] = p.RepoAt
434
-
idx += 1
435
-
}
436
for _, p := range pulls {
437
-
args[idx] = p.PullId
438
-
idx += 1
439
}
440
-
submissionsRows, err := e.Query(submissionsQuery, args...)
441
if err != nil {
442
-
return nil, err
443
}
444
-
defer submissionsRows.Close()
445
446
-
for submissionsRows.Next() {
447
-
var s PullSubmission
448
-
var sourceRev sql.NullString
449
-
var createdAt string
450
-
err := submissionsRows.Scan(
451
-
&s.ID,
452
-
&s.PullId,
453
-
&s.RoundNumber,
454
-
&s.Patch,
455
-
&createdAt,
456
-
&sourceRev,
457
-
)
458
-
if err != nil {
459
-
return nil, err
460
}
461
-
462
-
createdTime, err := time.Parse(time.RFC3339, createdAt)
463
-
if err != nil {
464
-
return nil, err
465
-
}
466
-
s.Created = createdTime
467
468
-
if sourceRev.Valid {
469
-
s.SourceRev = sourceRev.String
470
}
471
472
-
if p, ok := pulls[s.PullId]; ok {
473
-
p.Submissions = make([]*PullSubmission, s.RoundNumber+1)
474
-
p.Submissions[s.RoundNumber] = &s
475
}
476
}
477
-
if err := rows.Err(); err != nil {
478
-
return nil, err
479
}
480
-
481
-
// get comment count on latest submission on each pull
482
-
inClause = strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ")
483
-
commentsQuery := fmt.Sprintf(`
484
-
select
485
-
count(id), pull_id
486
-
from
487
-
pull_comments
488
-
where
489
-
submission_id in (%s)
490
-
group by
491
-
submission_id
492
-
`, inClause)
493
-
494
-
args = []any{}
495
for _, p := range pulls {
496
-
args = append(args, p.Submissions[p.LastRoundNumber()].ID)
497
-
}
498
-
commentsRows, err := e.Query(commentsQuery, args...)
499
-
if err != nil {
500
-
return nil, err
501
-
}
502
-
defer commentsRows.Close()
503
-
504
-
for commentsRows.Next() {
505
-
var commentCount, pullId int
506
-
err := commentsRows.Scan(
507
-
&commentCount,
508
-
&pullId,
509
-
)
510
-
if err != nil {
511
-
return nil, err
512
}
513
-
if p, ok := pulls[pullId]; ok {
514
-
p.Submissions[p.LastRoundNumber()].Comments = make([]PullComment, commentCount)
515
-
}
516
-
}
517
-
if err := rows.Err(); err != nil {
518
-
return nil, err
519
}
520
521
-
orderedByPullId := []*Pull{}
522
for _, p := range pulls {
523
orderedByPullId = append(orderedByPullId, p)
524
}
···
529
return orderedByPullId, nil
530
}
531
532
-
func GetPulls(e Execer, filters ...filter) ([]*Pull, error) {
533
return GetPullsWithLimit(e, 0, filters...)
534
}
535
536
-
func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*Pull, error) {
537
-
query := `
538
-
select
539
-
owner_did,
540
-
pull_id,
541
-
created,
542
-
title,
543
-
state,
544
-
target_branch,
545
-
repo_at,
546
-
body,
547
-
rkey,
548
-
source_branch,
549
-
source_repo_at,
550
-
stack_id,
551
-
change_id,
552
-
parent_change_id
553
-
from
554
-
pulls
555
-
where
556
-
repo_at = ? and pull_id = ?
557
-
`
558
-
row := e.QueryRow(query, repoAt, pullId)
559
-
560
-
var pull Pull
561
-
var createdAt string
562
-
var sourceBranch, sourceRepoAt, stackId, changeId, parentChangeId sql.NullString
563
-
err := row.Scan(
564
-
&pull.OwnerDid,
565
-
&pull.PullId,
566
-
&createdAt,
567
-
&pull.Title,
568
-
&pull.State,
569
-
&pull.TargetBranch,
570
-
&pull.RepoAt,
571
-
&pull.Body,
572
-
&pull.Rkey,
573
-
&sourceBranch,
574
-
&sourceRepoAt,
575
-
&stackId,
576
-
&changeId,
577
-
&parentChangeId,
578
-
)
579
if err != nil {
580
return nil, err
581
}
582
-
583
-
createdTime, err := time.Parse(time.RFC3339, createdAt)
584
-
if err != nil {
585
-
return nil, err
586
}
587
-
pull.Created = createdTime
588
589
-
// populate source
590
-
if sourceBranch.Valid {
591
-
pull.PullSource = &PullSource{
592
-
Branch: sourceBranch.String,
593
-
}
594
-
if sourceRepoAt.Valid {
595
-
sourceRepoAtParsed, err := syntax.ParseATURI(sourceRepoAt.String)
596
-
if err != nil {
597
-
return nil, err
598
-
}
599
-
pull.PullSource.RepoAt = &sourceRepoAtParsed
600
-
}
601
-
}
602
603
-
if stackId.Valid {
604
-
pull.StackId = stackId.String
605
-
}
606
-
if changeId.Valid {
607
-
pull.ChangeId = changeId.String
608
}
609
-
if parentChangeId.Valid {
610
-
pull.ParentChangeId = parentChangeId.String
611
}
612
613
-
submissionsQuery := `
614
select
615
-
id, pull_id, repo_at, round_number, patch, created, source_rev
616
from
617
pull_submissions
618
-
where
619
-
repo_at = ? and pull_id = ?
620
-
`
621
-
submissionsRows, err := e.Query(submissionsQuery, repoAt, pullId)
622
if err != nil {
623
return nil, err
624
}
625
-
defer submissionsRows.Close()
626
627
-
submissionsMap := make(map[int]*PullSubmission)
628
629
-
for submissionsRows.Next() {
630
-
var submission PullSubmission
631
-
var submissionCreatedStr string
632
-
var submissionSourceRev sql.NullString
633
-
err := submissionsRows.Scan(
634
&submission.ID,
635
-
&submission.PullId,
636
-
&submission.RepoAt,
637
&submission.RoundNumber,
638
&submission.Patch,
639
-
&submissionCreatedStr,
640
-
&submissionSourceRev,
641
)
642
if err != nil {
643
return nil, err
644
}
645
646
-
submissionCreatedTime, err := time.Parse(time.RFC3339, submissionCreatedStr)
647
if err != nil {
648
return nil, err
649
}
650
-
submission.Created = submissionCreatedTime
651
652
-
if submissionSourceRev.Valid {
653
-
submission.SourceRev = submissionSourceRev.String
654
}
655
656
-
submissionsMap[submission.ID] = &submission
657
}
658
-
if err = submissionsRows.Close(); err != nil {
659
return nil, err
660
}
661
-
if len(submissionsMap) == 0 {
662
-
return &pull, nil
663
}
664
665
var args []any
666
-
for k := range submissionsMap {
667
-
args = append(args, k)
668
}
669
-
inClause := strings.TrimSuffix(strings.Repeat("?, ", len(submissionsMap)), ", ")
670
-
commentsQuery := fmt.Sprintf(`
671
select
672
id,
673
pull_id,
···
679
created
680
from
681
pull_comments
682
-
where
683
-
submission_id IN (%s)
684
order by
685
created asc
686
-
`, inClause)
687
-
commentsRows, err := e.Query(commentsQuery, args...)
688
if err != nil {
689
return nil, err
690
}
691
-
defer commentsRows.Close()
692
693
-
for commentsRows.Next() {
694
-
var comment PullComment
695
-
var commentCreatedStr string
696
-
err := commentsRows.Scan(
697
&comment.ID,
698
&comment.PullId,
699
&comment.SubmissionId,
···
701
&comment.OwnerDid,
702
&comment.CommentAt,
703
&comment.Body,
704
-
&commentCreatedStr,
705
)
706
if err != nil {
707
return nil, err
708
}
709
710
-
commentCreatedTime, err := time.Parse(time.RFC3339, commentCreatedStr)
711
-
if err != nil {
712
-
return nil, err
713
-
}
714
-
comment.Created = commentCreatedTime
715
-
716
-
// Add the comment to its submission
717
-
if submission, ok := submissionsMap[comment.SubmissionId]; ok {
718
-
submission.Comments = append(submission.Comments, comment)
719
}
720
721
-
}
722
-
if err = commentsRows.Err(); err != nil {
723
-
return nil, err
724
-
}
725
-
726
-
var pullSourceRepo *Repo
727
-
if pull.PullSource != nil {
728
-
if pull.PullSource.RepoAt != nil {
729
-
pullSourceRepo, err = GetRepoByAtUri(e, pull.PullSource.RepoAt.String())
730
-
if err != nil {
731
-
log.Printf("failed to get repo by at uri: %v", err)
732
-
} else {
733
-
pull.PullSource.Repo = pullSourceRepo
734
-
}
735
-
}
736
}
737
738
-
pull.Submissions = make([]*PullSubmission, len(submissionsMap))
739
-
for _, submission := range submissionsMap {
740
-
pull.Submissions[submission.RoundNumber] = submission
741
}
742
743
-
return &pull, nil
744
}
745
746
// timeframe here is directly passed into the sql query filter, and any
747
// timeframe in the past should be negative; e.g.: "-3 months"
748
-
func GetPullsByOwnerDid(e Execer, did, timeframe string) ([]Pull, error) {
749
-
var pulls []Pull
750
751
rows, err := e.Query(`
752
select
···
775
defer rows.Close()
776
777
for rows.Next() {
778
-
var pull Pull
779
-
var repo Repo
780
var pullCreatedAt, repoCreatedAt string
781
err := rows.Scan(
782
&pull.OwnerDid,
···
819
return pulls, nil
820
}
821
822
-
func NewPullComment(e Execer, comment *PullComment) (int64, error) {
823
query := `insert into pull_comments (owner_did, repo_at, submission_id, comment_at, pull_id, body) values (?, ?, ?, ?, ?, ?)`
824
res, err := e.Exec(
825
query,
···
842
return i, nil
843
}
844
845
-
func SetPullState(e Execer, repoAt syntax.ATURI, pullId int, pullState PullState) error {
846
_, err := e.Exec(
847
`update pulls set state = ? where repo_at = ? and pull_id = ? and (state <> ? or state <> ?)`,
848
pullState,
849
repoAt,
850
pullId,
851
-
PullDeleted, // only update state of non-deleted pulls
852
-
PullMerged, // only update state of non-merged pulls
853
)
854
return err
855
}
856
857
func ClosePull(e Execer, repoAt syntax.ATURI, pullId int) error {
858
-
err := SetPullState(e, repoAt, pullId, PullClosed)
859
return err
860
}
861
862
func ReopenPull(e Execer, repoAt syntax.ATURI, pullId int) error {
863
-
err := SetPullState(e, repoAt, pullId, PullOpen)
864
return err
865
}
866
867
func MergePull(e Execer, repoAt syntax.ATURI, pullId int) error {
868
-
err := SetPullState(e, repoAt, pullId, PullMerged)
869
return err
870
}
871
872
func DeletePull(e Execer, repoAt syntax.ATURI, pullId int) error {
873
-
err := SetPullState(e, repoAt, pullId, PullDeleted)
874
return err
875
}
876
877
-
func ResubmitPull(e Execer, pull *Pull, newPatch, sourceRev string) error {
878
newRoundNumber := len(pull.Submissions)
879
_, err := e.Exec(`
880
-
insert into pull_submissions (pull_id, repo_at, round_number, patch, source_rev)
881
-
values (?, ?, ?, ?, ?)
882
-
`, pull.PullId, pull.RepoAt, newRoundNumber, newPatch, sourceRev)
883
884
return err
885
}
···
931
return err
932
}
933
934
-
type PullCount struct {
935
-
Open int
936
-
Merged int
937
-
Closed int
938
-
Deleted int
939
-
}
940
-
941
-
func GetPullCount(e Execer, repoAt syntax.ATURI) (PullCount, error) {
942
row := e.QueryRow(`
943
select
944
count(case when state = ? then 1 end) as open_count,
···
947
count(case when state = ? then 1 end) as deleted_count
948
from pulls
949
where repo_at = ?`,
950
-
PullOpen,
951
-
PullMerged,
952
-
PullClosed,
953
-
PullDeleted,
954
repoAt,
955
)
956
957
-
var count PullCount
958
if err := row.Scan(&count.Open, &count.Merged, &count.Closed, &count.Deleted); err != nil {
959
-
return PullCount{0, 0, 0, 0}, err
960
}
961
962
return count, nil
963
}
964
-
965
-
type Stack []*Pull
966
967
// change-id parent-change-id
968
//
···
972
// 1 x <------' nil (BOT)
973
//
974
// `w` is parent of none, so it is the top of the stack
975
-
func GetStack(e Execer, stackId string) (Stack, error) {
976
unorderedPulls, err := GetPulls(
977
e,
978
FilterEq("stack_id", stackId),
979
-
FilterNotEq("state", PullDeleted),
980
)
981
if err != nil {
982
return nil, err
983
}
984
// map of parent-change-id to pull
985
-
changeIdMap := make(map[string]*Pull, len(unorderedPulls))
986
-
parentMap := make(map[string]*Pull, len(unorderedPulls))
987
for _, p := range unorderedPulls {
988
changeIdMap[p.ChangeId] = p
989
if p.ParentChangeId != "" {
···
992
}
993
994
// the top of the stack is the pull that is not a parent of any pull
995
-
var topPull *Pull
996
for _, maybeTop := range unorderedPulls {
997
if _, ok := parentMap[maybeTop.ChangeId]; !ok {
998
topPull = maybeTop
···
1000
}
1001
}
1002
1003
-
pulls := []*Pull{}
1004
for {
1005
pulls = append(pulls, topPull)
1006
if topPull.ParentChangeId != "" {
···
1017
return pulls, nil
1018
}
1019
1020
-
func GetAbandonedPulls(e Execer, stackId string) ([]*Pull, error) {
1021
pulls, err := GetPulls(
1022
e,
1023
FilterEq("stack_id", stackId),
1024
-
FilterEq("state", PullDeleted),
1025
)
1026
if err != nil {
1027
return nil, err
···
1029
1030
return pulls, nil
1031
}
1032
-
1033
-
// position of this pull in the stack
1034
-
func (stack Stack) Position(pull *Pull) int {
1035
-
return slices.IndexFunc(stack, func(p *Pull) bool {
1036
-
return p.ChangeId == pull.ChangeId
1037
-
})
1038
-
}
1039
-
1040
-
// all pulls below this pull (including self) in this stack
1041
-
//
1042
-
// nil if this pull does not belong to this stack
1043
-
func (stack Stack) Below(pull *Pull) Stack {
1044
-
position := stack.Position(pull)
1045
-
1046
-
if position < 0 {
1047
-
return nil
1048
-
}
1049
-
1050
-
return stack[position:]
1051
-
}
1052
-
1053
-
// all pulls below this pull (excluding self) in this stack
1054
-
func (stack Stack) StrictlyBelow(pull *Pull) Stack {
1055
-
below := stack.Below(pull)
1056
-
1057
-
if len(below) > 0 {
1058
-
return below[1:]
1059
-
}
1060
-
1061
-
return nil
1062
-
}
1063
-
1064
-
// all pulls above this pull (including self) in this stack
1065
-
func (stack Stack) Above(pull *Pull) Stack {
1066
-
position := stack.Position(pull)
1067
-
1068
-
if position < 0 {
1069
-
return nil
1070
-
}
1071
-
1072
-
return stack[:position+1]
1073
-
}
1074
-
1075
-
// all pulls below this pull (excluding self) in this stack
1076
-
func (stack Stack) StrictlyAbove(pull *Pull) Stack {
1077
-
above := stack.Above(pull)
1078
-
1079
-
if len(above) > 0 {
1080
-
return above[:len(above)-1]
1081
-
}
1082
-
1083
-
return nil
1084
-
}
1085
-
1086
-
// the combined format-patches of all the newest submissions in this stack
1087
-
func (stack Stack) CombinedPatch() string {
1088
-
// go in reverse order because the bottom of the stack is the last element in the slice
1089
-
var combined strings.Builder
1090
-
for idx := range stack {
1091
-
pull := stack[len(stack)-1-idx]
1092
-
combined.WriteString(pull.LatestPatch())
1093
-
combined.WriteString("\n")
1094
-
}
1095
-
return combined.String()
1096
-
}
1097
-
1098
-
// filter out PRs that are "active"
1099
-
//
1100
-
// PRs that are still open are active
1101
-
func (stack Stack) Mergeable() Stack {
1102
-
var mergeable Stack
1103
-
1104
-
for _, p := range stack {
1105
-
// stop at the first merged PR
1106
-
if p.State == PullMerged || p.State == PullClosed {
1107
-
break
1108
-
}
1109
-
1110
-
// skip over deleted PRs
1111
-
if p.State != PullDeleted {
1112
-
mergeable = append(mergeable, p)
1113
-
}
1114
-
}
1115
-
1116
-
return mergeable
1117
-
}
···
1
package db
2
3
import (
4
+
"cmp"
5
"database/sql"
6
+
"errors"
7
"fmt"
8
+
"maps"
9
"slices"
10
"sort"
11
"strings"
12
"time"
13
14
"github.com/bluesky-social/indigo/atproto/syntax"
15
+
"tangled.org/core/appview/models"
16
)
17
18
+
func NewPull(tx *sql.Tx, pull *models.Pull) error {
19
_, err := tx.Exec(`
20
insert or ignore into repo_pull_seqs (repo_at, next_pull_id)
21
values (?, 1)
···
36
}
37
38
pull.PullId = nextId
39
+
pull.State = models.PullOpen
40
41
var sourceBranch, sourceRepoAt *string
42
if pull.PullSource != nil {
···
58
parentChangeId = &pull.ParentChangeId
59
}
60
61
+
result, err := tx.Exec(
62
`
63
insert into pulls (
64
repo_at, owner_did, pull_id, title, target_branch, body, rkey, state, source_branch, source_repo_at, stack_id, change_id, parent_change_id
···
82
return err
83
}
84
85
+
// Set the database primary key ID
86
+
id, err := result.LastInsertId()
87
+
if err != nil {
88
+
return err
89
+
}
90
+
pull.ID = int(id)
91
+
92
_, err = tx.Exec(`
93
+
insert into pull_submissions (pull_at, round_number, patch, source_rev)
94
+
values (?, ?, ?, ?)
95
+
`, pull.PullAt(), 0, pull.Submissions[0].Patch, pull.Submissions[0].SourceRev)
96
return err
97
}
98
···
110
return pullId - 1, err
111
}
112
113
+
func GetPullsWithLimit(e Execer, limit int, filters ...filter) ([]*models.Pull, error) {
114
+
pulls := make(map[syntax.ATURI]*models.Pull)
115
116
var conditions []string
117
var args []any
···
131
132
query := fmt.Sprintf(`
133
select
134
+
id,
135
owner_did,
136
repo_at,
137
pull_id,
···
161
defer rows.Close()
162
163
for rows.Next() {
164
+
var pull models.Pull
165
var createdAt string
166
var sourceBranch, sourceRepoAt, stackId, changeId, parentChangeId sql.NullString
167
err := rows.Scan(
168
+
&pull.ID,
169
&pull.OwnerDid,
170
&pull.RepoAt,
171
&pull.PullId,
···
192
pull.Created = createdTime
193
194
if sourceBranch.Valid {
195
+
pull.PullSource = &models.PullSource{
196
Branch: sourceBranch.String,
197
}
198
if sourceRepoAt.Valid {
···
214
pull.ParentChangeId = parentChangeId.String
215
}
216
217
+
pulls[pull.PullAt()] = &pull
218
}
219
220
+
var pullAts []syntax.ATURI
221
for _, p := range pulls {
222
+
pullAts = append(pullAts, p.PullAt())
223
}
224
+
submissionsMap, err := GetPullSubmissions(e, FilterIn("pull_at", pullAts))
225
if err != nil {
226
+
return nil, fmt.Errorf("failed to get submissions: %w", err)
227
}
228
229
+
for pullAt, submissions := range submissionsMap {
230
+
if p, ok := pulls[pullAt]; ok {
231
+
p.Submissions = submissions
232
}
233
+
}
234
235
+
// collect allLabels for each issue
236
+
allLabels, err := GetLabels(e, FilterIn("subject", pullAts))
237
+
if err != nil {
238
+
return nil, fmt.Errorf("failed to query labels: %w", err)
239
+
}
240
+
for pullAt, labels := range allLabels {
241
+
if p, ok := pulls[pullAt]; ok {
242
+
p.Labels = labels
243
}
244
+
}
245
246
+
// collect pull source for all pulls that need it
247
+
var sourceAts []syntax.ATURI
248
+
for _, p := range pulls {
249
+
if p.PullSource != nil && p.PullSource.RepoAt != nil {
250
+
sourceAts = append(sourceAts, *p.PullSource.RepoAt)
251
}
252
}
253
+
sourceRepos, err := GetRepos(e, 0, FilterIn("at_uri", sourceAts))
254
+
if err != nil && !errors.Is(err, sql.ErrNoRows) {
255
+
return nil, fmt.Errorf("failed to get source repos: %w", err)
256
}
257
+
sourceRepoMap := make(map[syntax.ATURI]*models.Repo)
258
+
for _, r := range sourceRepos {
259
+
sourceRepoMap[r.RepoAt()] = &r
260
+
}
261
for _, p := range pulls {
262
+
if p.PullSource != nil && p.PullSource.RepoAt != nil {
263
+
if sourceRepo, ok := sourceRepoMap[*p.PullSource.RepoAt]; ok {
264
+
p.PullSource.Repo = sourceRepo
265
+
}
266
}
267
}
268
269
+
orderedByPullId := []*models.Pull{}
270
for _, p := range pulls {
271
orderedByPullId = append(orderedByPullId, p)
272
}
···
277
return orderedByPullId, nil
278
}
279
280
+
func GetPulls(e Execer, filters ...filter) ([]*models.Pull, error) {
281
return GetPullsWithLimit(e, 0, filters...)
282
}
283
284
+
func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*models.Pull, error) {
285
+
pulls, err := GetPullsWithLimit(e, 1, FilterEq("repo_at", repoAt), FilterEq("pull_id", pullId))
286
if err != nil {
287
return nil, err
288
}
289
+
if pulls == nil {
290
+
return nil, sql.ErrNoRows
291
}
292
293
+
return pulls[0], nil
294
+
}
295
296
+
// mapping from pull -> pull submissions
297
+
func GetPullSubmissions(e Execer, filters ...filter) (map[syntax.ATURI][]*models.PullSubmission, error) {
298
+
var conditions []string
299
+
var args []any
300
+
for _, filter := range filters {
301
+
conditions = append(conditions, filter.Condition())
302
+
args = append(args, filter.Arg()...)
303
}
304
+
305
+
whereClause := ""
306
+
if conditions != nil {
307
+
whereClause = " where " + strings.Join(conditions, " and ")
308
}
309
310
+
query := fmt.Sprintf(`
311
select
312
+
id,
313
+
pull_at,
314
+
round_number,
315
+
patch,
316
+
created,
317
+
source_rev
318
from
319
pull_submissions
320
+
%s
321
+
order by
322
+
round_number asc
323
+
`, whereClause)
324
+
325
+
rows, err := e.Query(query, args...)
326
if err != nil {
327
return nil, err
328
}
329
+
defer rows.Close()
330
331
+
submissionMap := make(map[int]*models.PullSubmission)
332
333
+
for rows.Next() {
334
+
var submission models.PullSubmission
335
+
var createdAt string
336
+
var sourceRev sql.NullString
337
+
err := rows.Scan(
338
&submission.ID,
339
+
&submission.PullAt,
340
&submission.RoundNumber,
341
&submission.Patch,
342
+
&createdAt,
343
+
&sourceRev,
344
)
345
if err != nil {
346
return nil, err
347
}
348
349
+
createdTime, err := time.Parse(time.RFC3339, createdAt)
350
if err != nil {
351
return nil, err
352
}
353
+
submission.Created = createdTime
354
355
+
if sourceRev.Valid {
356
+
submission.SourceRev = sourceRev.String
357
}
358
359
+
submissionMap[submission.ID] = &submission
360
}
361
+
362
+
if err := rows.Err(); err != nil {
363
+
return nil, err
364
+
}
365
+
366
+
// Get comments for all submissions using GetPullComments
367
+
submissionIds := slices.Collect(maps.Keys(submissionMap))
368
+
comments, err := GetPullComments(e, FilterIn("submission_id", submissionIds))
369
+
if err != nil {
370
return nil, err
371
}
372
+
for _, comment := range comments {
373
+
if submission, ok := submissionMap[comment.SubmissionId]; ok {
374
+
submission.Comments = append(submission.Comments, comment)
375
+
}
376
+
}
377
+
378
+
// group the submissions by pull_at
379
+
m := make(map[syntax.ATURI][]*models.PullSubmission)
380
+
for _, s := range submissionMap {
381
+
m[s.PullAt] = append(m[s.PullAt], s)
382
+
}
383
+
384
+
// sort each one by round number
385
+
for _, s := range m {
386
+
slices.SortFunc(s, func(a, b *models.PullSubmission) int {
387
+
return cmp.Compare(a.RoundNumber, b.RoundNumber)
388
+
})
389
}
390
391
+
return m, nil
392
+
}
393
+
394
+
func GetPullComments(e Execer, filters ...filter) ([]models.PullComment, error) {
395
+
var conditions []string
396
var args []any
397
+
for _, filter := range filters {
398
+
conditions = append(conditions, filter.Condition())
399
+
args = append(args, filter.Arg()...)
400
}
401
+
402
+
whereClause := ""
403
+
if conditions != nil {
404
+
whereClause = " where " + strings.Join(conditions, " and ")
405
+
}
406
+
407
+
query := fmt.Sprintf(`
408
select
409
id,
410
pull_id,
···
416
created
417
from
418
pull_comments
419
+
%s
420
order by
421
created asc
422
+
`, whereClause)
423
+
424
+
rows, err := e.Query(query, args...)
425
if err != nil {
426
return nil, err
427
}
428
+
defer rows.Close()
429
430
+
var comments []models.PullComment
431
+
for rows.Next() {
432
+
var comment models.PullComment
433
+
var createdAt string
434
+
err := rows.Scan(
435
&comment.ID,
436
&comment.PullId,
437
&comment.SubmissionId,
···
439
&comment.OwnerDid,
440
&comment.CommentAt,
441
&comment.Body,
442
+
&createdAt,
443
)
444
if err != nil {
445
return nil, err
446
}
447
448
+
if t, err := time.Parse(time.RFC3339, createdAt); err == nil {
449
+
comment.Created = t
450
}
451
452
+
comments = append(comments, comment)
453
}
454
455
+
if err := rows.Err(); err != nil {
456
+
return nil, err
457
}
458
459
+
return comments, nil
460
}
461
462
// timeframe here is directly passed into the sql query filter, and any
463
// timeframe in the past should be negative; e.g.: "-3 months"
464
+
func GetPullsByOwnerDid(e Execer, did, timeframe string) ([]models.Pull, error) {
465
+
var pulls []models.Pull
466
467
rows, err := e.Query(`
468
select
···
491
defer rows.Close()
492
493
for rows.Next() {
494
+
var pull models.Pull
495
+
var repo models.Repo
496
var pullCreatedAt, repoCreatedAt string
497
err := rows.Scan(
498
&pull.OwnerDid,
···
535
return pulls, nil
536
}
537
538
+
func NewPullComment(e Execer, comment *models.PullComment) (int64, error) {
539
query := `insert into pull_comments (owner_did, repo_at, submission_id, comment_at, pull_id, body) values (?, ?, ?, ?, ?, ?)`
540
res, err := e.Exec(
541
query,
···
558
return i, nil
559
}
560
561
+
func SetPullState(e Execer, repoAt syntax.ATURI, pullId int, pullState models.PullState) error {
562
_, err := e.Exec(
563
`update pulls set state = ? where repo_at = ? and pull_id = ? and (state <> ? or state <> ?)`,
564
pullState,
565
repoAt,
566
pullId,
567
+
models.PullDeleted, // only update state of non-deleted pulls
568
+
models.PullMerged, // only update state of non-merged pulls
569
)
570
return err
571
}
572
573
func ClosePull(e Execer, repoAt syntax.ATURI, pullId int) error {
574
+
err := SetPullState(e, repoAt, pullId, models.PullClosed)
575
return err
576
}
577
578
func ReopenPull(e Execer, repoAt syntax.ATURI, pullId int) error {
579
+
err := SetPullState(e, repoAt, pullId, models.PullOpen)
580
return err
581
}
582
583
func MergePull(e Execer, repoAt syntax.ATURI, pullId int) error {
584
+
err := SetPullState(e, repoAt, pullId, models.PullMerged)
585
return err
586
}
587
588
func DeletePull(e Execer, repoAt syntax.ATURI, pullId int) error {
589
+
err := SetPullState(e, repoAt, pullId, models.PullDeleted)
590
return err
591
}
592
593
+
func ResubmitPull(e Execer, pull *models.Pull, newPatch, sourceRev string) error {
594
newRoundNumber := len(pull.Submissions)
595
_, err := e.Exec(`
596
+
insert into pull_submissions (pull_at, round_number, patch, source_rev)
597
+
values (?, ?, ?, ?)
598
+
`, pull.PullAt(), newRoundNumber, newPatch, sourceRev)
599
600
return err
601
}
···
647
return err
648
}
649
650
+
func GetPullCount(e Execer, repoAt syntax.ATURI) (models.PullCount, error) {
651
row := e.QueryRow(`
652
select
653
count(case when state = ? then 1 end) as open_count,
···
656
count(case when state = ? then 1 end) as deleted_count
657
from pulls
658
where repo_at = ?`,
659
+
models.PullOpen,
660
+
models.PullMerged,
661
+
models.PullClosed,
662
+
models.PullDeleted,
663
repoAt,
664
)
665
666
+
var count models.PullCount
667
if err := row.Scan(&count.Open, &count.Merged, &count.Closed, &count.Deleted); err != nil {
668
+
return models.PullCount{Open: 0, Merged: 0, Closed: 0, Deleted: 0}, err
669
}
670
671
return count, nil
672
}
673
674
// change-id parent-change-id
675
//
···
679
// 1 x <------' nil (BOT)
680
//
681
// `w` is parent of none, so it is the top of the stack
682
+
func GetStack(e Execer, stackId string) (models.Stack, error) {
683
unorderedPulls, err := GetPulls(
684
e,
685
FilterEq("stack_id", stackId),
686
+
FilterNotEq("state", models.PullDeleted),
687
)
688
if err != nil {
689
return nil, err
690
}
691
// map of parent-change-id to pull
692
+
changeIdMap := make(map[string]*models.Pull, len(unorderedPulls))
693
+
parentMap := make(map[string]*models.Pull, len(unorderedPulls))
694
for _, p := range unorderedPulls {
695
changeIdMap[p.ChangeId] = p
696
if p.ParentChangeId != "" {
···
699
}
700
701
// the top of the stack is the pull that is not a parent of any pull
702
+
var topPull *models.Pull
703
for _, maybeTop := range unorderedPulls {
704
if _, ok := parentMap[maybeTop.ChangeId]; !ok {
705
topPull = maybeTop
···
707
}
708
}
709
710
+
pulls := []*models.Pull{}
711
for {
712
pulls = append(pulls, topPull)
713
if topPull.ParentChangeId != "" {
···
724
return pulls, nil
725
}
726
727
+
func GetAbandonedPulls(e Execer, stackId string) ([]*models.Pull, error) {
728
pulls, err := GetPulls(
729
e,
730
FilterEq("stack_id", stackId),
731
+
FilterEq("state", models.PullDeleted),
732
)
733
if err != nil {
734
return nil, err
···
736
737
return pulls, nil
738
}
+7
-16
appview/db/punchcard.go
+7
-16
appview/db/punchcard.go
···
5
"fmt"
6
"strings"
7
"time"
8
)
9
10
-
type Punch struct {
11
-
Did string
12
-
Date time.Time
13
-
Count int
14
-
}
15
-
16
// this adds to the existing count
17
-
func AddPunch(e Execer, punch Punch) error {
18
_, err := e.Exec(`
19
insert into punchcard (did, date, count)
20
values (?, ?, ?)
···
24
return err
25
}
26
27
-
type Punchcard struct {
28
-
Total int
29
-
Punches []Punch
30
-
}
31
-
32
-
func MakePunchcard(e Execer, filters ...filter) (*Punchcard, error) {
33
-
punchcard := &Punchcard{}
34
now := time.Now()
35
startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC)
36
endOfYear := time.Date(now.Year(), 12, 31, 0, 0, 0, 0, time.UTC)
37
for d := startOfYear; d.Before(endOfYear) || d.Equal(endOfYear); d = d.AddDate(0, 0, 1) {
38
-
punchcard.Punches = append(punchcard.Punches, Punch{
39
Date: d,
40
Count: 0,
41
})
···
68
defer rows.Close()
69
70
for rows.Next() {
71
-
var punch Punch
72
var date string
73
var count sql.NullInt64
74
if err := rows.Scan(&date, &count); err != nil {
···
5
"fmt"
6
"strings"
7
"time"
8
+
9
+
"tangled.org/core/appview/models"
10
)
11
12
// this adds to the existing count
13
+
func AddPunch(e Execer, punch models.Punch) error {
14
_, err := e.Exec(`
15
insert into punchcard (did, date, count)
16
values (?, ?, ?)
···
20
return err
21
}
22
23
+
func MakePunchcard(e Execer, filters ...filter) (*models.Punchcard, error) {
24
+
punchcard := &models.Punchcard{}
25
now := time.Now()
26
startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC)
27
endOfYear := time.Date(now.Year(), 12, 31, 0, 0, 0, 0, time.UTC)
28
for d := startOfYear; d.Before(endOfYear) || d.Equal(endOfYear); d = d.AddDate(0, 0, 1) {
29
+
punchcard.Punches = append(punchcard.Punches, models.Punch{
30
Date: d,
31
Count: 0,
32
})
···
59
defer rows.Close()
60
61
for rows.Next() {
62
+
var punch models.Punch
63
var date string
64
var count sql.NullInt64
65
if err := rows.Scan(&date, &count); err != nil {
+14
-63
appview/db/reaction.go
+14
-63
appview/db/reaction.go
···
5
"time"
6
7
"github.com/bluesky-social/indigo/atproto/syntax"
8
-
)
9
-
10
-
type ReactionKind string
11
-
12
-
const (
13
-
Like ReactionKind = "👍"
14
-
Unlike ReactionKind = "👎"
15
-
Laugh ReactionKind = "😆"
16
-
Celebration ReactionKind = "🎉"
17
-
Confused ReactionKind = "🫤"
18
-
Heart ReactionKind = "❤️"
19
-
Rocket ReactionKind = "🚀"
20
-
Eyes ReactionKind = "👀"
21
)
22
23
-
func (rk ReactionKind) String() string {
24
-
return string(rk)
25
-
}
26
-
27
-
var OrderedReactionKinds = []ReactionKind{
28
-
Like,
29
-
Unlike,
30
-
Laugh,
31
-
Celebration,
32
-
Confused,
33
-
Heart,
34
-
Rocket,
35
-
Eyes,
36
-
}
37
-
38
-
func ParseReactionKind(raw string) (ReactionKind, bool) {
39
-
k, ok := (map[string]ReactionKind{
40
-
"👍": Like,
41
-
"👎": Unlike,
42
-
"😆": Laugh,
43
-
"🎉": Celebration,
44
-
"🫤": Confused,
45
-
"❤️": Heart,
46
-
"🚀": Rocket,
47
-
"👀": Eyes,
48
-
})[raw]
49
-
return k, ok
50
-
}
51
-
52
-
type Reaction struct {
53
-
ReactedByDid string
54
-
ThreadAt syntax.ATURI
55
-
Created time.Time
56
-
Rkey string
57
-
Kind ReactionKind
58
-
}
59
-
60
-
func AddReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind ReactionKind, rkey string) error {
61
query := `insert or ignore into reactions (reacted_by_did, thread_at, kind, rkey) values (?, ?, ?, ?)`
62
_, err := e.Exec(query, reactedByDid, threadAt, kind, rkey)
63
return err
64
}
65
66
// Get a reaction record
67
-
func GetReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind ReactionKind) (*Reaction, error) {
68
query := `
69
select reacted_by_did, thread_at, created, rkey
70
from reactions
71
where reacted_by_did = ? and thread_at = ? and kind = ?`
72
row := e.QueryRow(query, reactedByDid, threadAt, kind)
73
74
-
var reaction Reaction
75
var created string
76
err := row.Scan(&reaction.ReactedByDid, &reaction.ThreadAt, &created, &reaction.Rkey)
77
if err != nil {
···
90
}
91
92
// Remove a reaction
93
-
func DeleteReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind ReactionKind) error {
94
_, err := e.Exec(`delete from reactions where reacted_by_did = ? and thread_at = ? and kind = ?`, reactedByDid, threadAt, kind)
95
return err
96
}
···
101
return err
102
}
103
104
-
func GetReactionCount(e Execer, threadAt syntax.ATURI, kind ReactionKind) (int, error) {
105
count := 0
106
err := e.QueryRow(
107
`select count(reacted_by_did) from reactions where thread_at = ? and kind = ?`, threadAt, kind).Scan(&count)
···
111
return count, nil
112
}
113
114
-
func GetReactionCountMap(e Execer, threadAt syntax.ATURI) (map[ReactionKind]int, error) {
115
-
countMap := map[ReactionKind]int{}
116
-
for _, kind := range OrderedReactionKinds {
117
count, err := GetReactionCount(e, threadAt, kind)
118
if err != nil {
119
-
return map[ReactionKind]int{}, nil
120
}
121
countMap[kind] = count
122
}
123
return countMap, nil
124
}
125
126
-
func GetReactionStatus(e Execer, userDid string, threadAt syntax.ATURI, kind ReactionKind) bool {
127
if _, err := GetReaction(e, userDid, threadAt, kind); err != nil {
128
return false
129
} else {
···
131
}
132
}
133
134
-
func GetReactionStatusMap(e Execer, userDid string, threadAt syntax.ATURI) map[ReactionKind]bool {
135
-
statusMap := map[ReactionKind]bool{}
136
-
for _, kind := range OrderedReactionKinds {
137
count := GetReactionStatus(e, userDid, threadAt, kind)
138
statusMap[kind] = count
139
}
···
5
"time"
6
7
"github.com/bluesky-social/indigo/atproto/syntax"
8
+
"tangled.org/core/appview/models"
9
)
10
11
+
func AddReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind models.ReactionKind, rkey string) error {
12
query := `insert or ignore into reactions (reacted_by_did, thread_at, kind, rkey) values (?, ?, ?, ?)`
13
_, err := e.Exec(query, reactedByDid, threadAt, kind, rkey)
14
return err
15
}
16
17
// Get a reaction record
18
+
func GetReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind models.ReactionKind) (*models.Reaction, error) {
19
query := `
20
select reacted_by_did, thread_at, created, rkey
21
from reactions
22
where reacted_by_did = ? and thread_at = ? and kind = ?`
23
row := e.QueryRow(query, reactedByDid, threadAt, kind)
24
25
+
var reaction models.Reaction
26
var created string
27
err := row.Scan(&reaction.ReactedByDid, &reaction.ThreadAt, &created, &reaction.Rkey)
28
if err != nil {
···
41
}
42
43
// Remove a reaction
44
+
func DeleteReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind models.ReactionKind) error {
45
_, err := e.Exec(`delete from reactions where reacted_by_did = ? and thread_at = ? and kind = ?`, reactedByDid, threadAt, kind)
46
return err
47
}
···
52
return err
53
}
54
55
+
func GetReactionCount(e Execer, threadAt syntax.ATURI, kind models.ReactionKind) (int, error) {
56
count := 0
57
err := e.QueryRow(
58
`select count(reacted_by_did) from reactions where thread_at = ? and kind = ?`, threadAt, kind).Scan(&count)
···
62
return count, nil
63
}
64
65
+
func GetReactionCountMap(e Execer, threadAt syntax.ATURI) (map[models.ReactionKind]int, error) {
66
+
countMap := map[models.ReactionKind]int{}
67
+
for _, kind := range models.OrderedReactionKinds {
68
count, err := GetReactionCount(e, threadAt, kind)
69
if err != nil {
70
+
return map[models.ReactionKind]int{}, nil
71
}
72
countMap[kind] = count
73
}
74
return countMap, nil
75
}
76
77
+
func GetReactionStatus(e Execer, userDid string, threadAt syntax.ATURI, kind models.ReactionKind) bool {
78
if _, err := GetReaction(e, userDid, threadAt, kind); err != nil {
79
return false
80
} else {
···
82
}
83
}
84
85
+
func GetReactionStatusMap(e Execer, userDid string, threadAt syntax.ATURI) map[models.ReactionKind]bool {
86
+
statusMap := map[models.ReactionKind]bool{}
87
+
for _, kind := range models.OrderedReactionKinds {
88
count := GetReactionStatus(e, userDid, threadAt, kind)
89
statusMap[kind] = count
90
}
+4
-43
appview/db/registration.go
+4
-43
appview/db/registration.go
···
5
"fmt"
6
"strings"
7
"time"
8
-
)
9
-
10
-
// Registration represents a knot registration. Knot would've been a better
11
-
// name but we're stuck with this for historical reasons.
12
-
type Registration struct {
13
-
Id int64
14
-
Domain string
15
-
ByDid string
16
-
Created *time.Time
17
-
Registered *time.Time
18
-
NeedsUpgrade bool
19
-
}
20
21
-
func (r *Registration) Status() Status {
22
-
if r.NeedsUpgrade {
23
-
return NeedsUpgrade
24
-
} else if r.Registered != nil {
25
-
return Registered
26
-
} else {
27
-
return Pending
28
-
}
29
-
}
30
-
31
-
func (r *Registration) IsRegistered() bool {
32
-
return r.Status() == Registered
33
-
}
34
-
35
-
func (r *Registration) IsNeedsUpgrade() bool {
36
-
return r.Status() == NeedsUpgrade
37
-
}
38
-
39
-
func (r *Registration) IsPending() bool {
40
-
return r.Status() == Pending
41
-
}
42
-
43
-
type Status uint32
44
-
45
-
const (
46
-
Registered Status = iota
47
-
Pending
48
-
NeedsUpgrade
49
)
50
51
-
func GetRegistrations(e Execer, filters ...filter) ([]Registration, error) {
52
-
var registrations []Registration
53
54
var conditions []string
55
var args []any
···
81
var createdAt string
82
var registeredAt sql.Null[string]
83
var needsUpgrade int
84
-
var reg Registration
85
86
err = rows.Scan(®.Id, ®.Domain, ®.ByDid, &createdAt, ®isteredAt, &needsUpgrade)
87
if err != nil {
···
5
"fmt"
6
"strings"
7
"time"
8
9
+
"tangled.org/core/appview/models"
10
)
11
12
+
func GetRegistrations(e Execer, filters ...filter) ([]models.Registration, error) {
13
+
var registrations []models.Registration
14
15
var conditions []string
16
var args []any
···
42
var createdAt string
43
var registeredAt sql.Null[string]
44
var needsUpgrade int
45
+
var reg models.Registration
46
47
err = rows.Scan(®.Id, ®.Domain, ®.ByDid, &createdAt, ®isteredAt, &needsUpgrade)
48
if err != nil {
+162
-78
appview/db/repos.go
+162
-78
appview/db/repos.go
···
11
12
"github.com/bluesky-social/indigo/atproto/syntax"
13
securejoin "github.com/cyphar/filepath-securejoin"
14
-
"tangled.sh/tangled.sh/core/api/tangled"
15
)
16
17
type Repo struct {
18
Did string
19
Name string
20
Knot string
···
24
Spindle string
25
26
// optionally, populate this when querying for reverse mappings
27
-
RepoStats *RepoStats
28
29
// optional
30
Source string
···
39
return p
40
}
41
42
-
func GetRepos(e Execer, limit int, filters ...filter) ([]Repo, error) {
43
-
repoMap := make(map[syntax.ATURI]*Repo)
44
45
var conditions []string
46
var args []any
···
61
62
repoQuery := fmt.Sprintf(
63
`select
64
did,
65
name,
66
knot,
···
84
}
85
86
for rows.Next() {
87
-
var repo Repo
88
var createdAt string
89
var description, source, spindle sql.NullString
90
91
err := rows.Scan(
92
&repo.Did,
93
&repo.Name,
94
&repo.Knot,
···
115
repo.Spindle = spindle.String
116
}
117
118
-
repo.RepoStats = &RepoStats{}
119
repoMap[repo.RepoAt()] = &repo
120
}
121
···
132
i++
133
}
134
135
languageQuery := fmt.Sprintf(
136
`
137
-
select
138
-
repo_at, language
139
-
from
140
-
repo_languages r1
141
-
where
142
-
repo_at IN (%s)
143
and is_default_ref = 1
144
-
and id = (
145
-
select id
146
-
from repo_languages r2
147
-
where r2.repo_at = r1.repo_at
148
-
and r2.is_default_ref = 1
149
-
order by bytes desc
150
-
limit 1
151
-
);
152
`,
153
inClause,
154
)
···
240
inClause,
241
)
242
args = append([]any{
243
-
PullOpen,
244
-
PullMerged,
245
-
PullClosed,
246
-
PullDeleted,
247
}, args...)
248
rows, err = e.Query(
249
pullCountQuery,
···
270
return nil, fmt.Errorf("failed to execute pulls-count query: %w ", err)
271
}
272
273
-
var repos []Repo
274
for _, r := range repoMap {
275
repos = append(repos, *r)
276
}
277
278
-
slices.SortFunc(repos, func(a, b Repo) int {
279
if a.Created.After(b.Created) {
280
return -1
281
}
···
285
return repos, nil
286
}
287
288
func CountRepos(e Execer, filters ...filter) (int64, error) {
289
var conditions []string
290
var args []any
···
309
return count, nil
310
}
311
312
-
func GetRepo(e Execer, did, name string) (*Repo, error) {
313
-
var repo Repo
314
-
var description, spindle sql.NullString
315
-
316
-
row := e.QueryRow(`
317
-
select did, name, knot, created, description, spindle, rkey
318
-
from repos
319
-
where did = ? and name = ?
320
-
`,
321
-
did,
322
-
name,
323
-
)
324
-
325
-
var createdAt string
326
-
if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &description, &spindle, &repo.Rkey); err != nil {
327
-
return nil, err
328
-
}
329
-
createdAtTime, _ := time.Parse(time.RFC3339, createdAt)
330
-
repo.Created = createdAtTime
331
-
332
-
if description.Valid {
333
-
repo.Description = description.String
334
-
}
335
-
336
-
if spindle.Valid {
337
-
repo.Spindle = spindle.String
338
-
}
339
-
340
-
return &repo, nil
341
-
}
342
-
343
-
func GetRepoByAtUri(e Execer, atUri string) (*Repo, error) {
344
-
var repo Repo
345
var nullableDescription sql.NullString
346
347
-
row := e.QueryRow(`select did, name, knot, created, rkey, description from repos where at_uri = ?`, atUri)
348
349
var createdAt string
350
-
if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription); err != nil {
351
return nil, err
352
}
353
createdAtTime, _ := time.Parse(time.RFC3339, createdAt)
···
362
return &repo, nil
363
}
364
365
-
func AddRepo(e Execer, repo *Repo) error {
366
-
_, err := e.Exec(
367
`insert into repos
368
(did, name, knot, rkey, at_uri, description, source)
369
values (?, ?, ?, ?, ?, ?, ?)`,
370
repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.RepoAt().String(), repo.Description, repo.Source,
371
)
372
-
return err
373
}
374
375
func RemoveRepo(e Execer, did, name string) error {
···
386
return nullableSource.String, nil
387
}
388
389
-
func GetForksByDid(e Execer, did string) ([]Repo, error) {
390
-
var repos []Repo
391
392
rows, err := e.Query(
393
-
`select distinct r.did, r.name, r.knot, r.rkey, r.description, r.created, r.source
394
from repos r
395
left join collaborators c on r.at_uri = c.repo_at
396
where (r.did = ? or c.subject_did = ?)
···
405
defer rows.Close()
406
407
for rows.Next() {
408
-
var repo Repo
409
var createdAt string
410
var nullableDescription sql.NullString
411
var nullableSource sql.NullString
412
413
-
err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource)
414
if err != nil {
415
return nil, err
416
}
···
440
return repos, nil
441
}
442
443
-
func GetForkByDid(e Execer, did string, name string) (*Repo, error) {
444
-
var repo Repo
445
var createdAt string
446
var nullableDescription sql.NullString
447
var nullableSource sql.NullString
448
449
row := e.QueryRow(
450
-
`select did, name, knot, rkey, description, created, source
451
from repos
452
where did = ? and name = ? and source is not null and source != ''`,
453
did, name,
454
)
455
456
-
err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource)
457
if err != nil {
458
return nil, err
459
}
···
488
return err
489
}
490
491
-
type RepoStats struct {
492
-
Language string
493
-
StarCount int
494
-
IssueCount IssueCount
495
-
PullCount PullCount
496
}
···
11
12
"github.com/bluesky-social/indigo/atproto/syntax"
13
securejoin "github.com/cyphar/filepath-securejoin"
14
+
"tangled.org/core/api/tangled"
15
+
"tangled.org/core/appview/models"
16
)
17
18
type Repo struct {
19
+
Id int64
20
Did string
21
Name string
22
Knot string
···
26
Spindle string
27
28
// optionally, populate this when querying for reverse mappings
29
+
RepoStats *models.RepoStats
30
31
// optional
32
Source string
···
41
return p
42
}
43
44
+
func GetRepos(e Execer, limit int, filters ...filter) ([]models.Repo, error) {
45
+
repoMap := make(map[syntax.ATURI]*models.Repo)
46
47
var conditions []string
48
var args []any
···
63
64
repoQuery := fmt.Sprintf(
65
`select
66
+
id,
67
did,
68
name,
69
knot,
···
87
}
88
89
for rows.Next() {
90
+
var repo models.Repo
91
var createdAt string
92
var description, source, spindle sql.NullString
93
94
err := rows.Scan(
95
+
&repo.Id,
96
&repo.Did,
97
&repo.Name,
98
&repo.Knot,
···
119
repo.Spindle = spindle.String
120
}
121
122
+
repo.RepoStats = &models.RepoStats{}
123
repoMap[repo.RepoAt()] = &repo
124
}
125
···
136
i++
137
}
138
139
+
// Get labels for all repos
140
+
labelsQuery := fmt.Sprintf(
141
+
`select repo_at, label_at from repo_labels where repo_at in (%s)`,
142
+
inClause,
143
+
)
144
+
rows, err = e.Query(labelsQuery, args...)
145
+
if err != nil {
146
+
return nil, fmt.Errorf("failed to execute labels query: %w ", err)
147
+
}
148
+
for rows.Next() {
149
+
var repoat, labelat string
150
+
if err := rows.Scan(&repoat, &labelat); err != nil {
151
+
log.Println("err", "err", err)
152
+
continue
153
+
}
154
+
if r, ok := repoMap[syntax.ATURI(repoat)]; ok {
155
+
r.Labels = append(r.Labels, labelat)
156
+
}
157
+
}
158
+
if err = rows.Err(); err != nil {
159
+
return nil, fmt.Errorf("failed to execute labels query: %w ", err)
160
+
}
161
+
162
languageQuery := fmt.Sprintf(
163
`
164
+
select repo_at, language
165
+
from (
166
+
select
167
+
repo_at,
168
+
language,
169
+
row_number() over (
170
+
partition by repo_at
171
+
order by bytes desc
172
+
) as rn
173
+
from repo_languages
174
+
where repo_at in (%s)
175
and is_default_ref = 1
176
+
)
177
+
where rn = 1
178
`,
179
inClause,
180
)
···
266
inClause,
267
)
268
args = append([]any{
269
+
models.PullOpen,
270
+
models.PullMerged,
271
+
models.PullClosed,
272
+
models.PullDeleted,
273
}, args...)
274
rows, err = e.Query(
275
pullCountQuery,
···
296
return nil, fmt.Errorf("failed to execute pulls-count query: %w ", err)
297
}
298
299
+
var repos []models.Repo
300
for _, r := range repoMap {
301
repos = append(repos, *r)
302
}
303
304
+
slices.SortFunc(repos, func(a, b models.Repo) int {
305
if a.Created.After(b.Created) {
306
return -1
307
}
···
311
return repos, nil
312
}
313
314
+
// helper to get exactly one repo
315
+
func GetRepo(e Execer, filters ...filter) (*models.Repo, error) {
316
+
repos, err := GetRepos(e, 0, filters...)
317
+
if err != nil {
318
+
return nil, err
319
+
}
320
+
321
+
if repos == nil {
322
+
return nil, sql.ErrNoRows
323
+
}
324
+
325
+
if len(repos) != 1 {
326
+
return nil, fmt.Errorf("too many rows returned")
327
+
}
328
+
329
+
return &repos[0], nil
330
+
}
331
+
332
func CountRepos(e Execer, filters ...filter) (int64, error) {
333
var conditions []string
334
var args []any
···
353
return count, nil
354
}
355
356
+
func GetRepoByAtUri(e Execer, atUri string) (*models.Repo, error) {
357
+
var repo models.Repo
358
var nullableDescription sql.NullString
359
360
+
row := e.QueryRow(`select id, did, name, knot, created, rkey, description from repos where at_uri = ?`, atUri)
361
362
var createdAt string
363
+
if err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription); err != nil {
364
return nil, err
365
}
366
createdAtTime, _ := time.Parse(time.RFC3339, createdAt)
···
375
return &repo, nil
376
}
377
378
+
func AddRepo(tx *sql.Tx, repo *models.Repo) error {
379
+
_, err := tx.Exec(
380
`insert into repos
381
(did, name, knot, rkey, at_uri, description, source)
382
values (?, ?, ?, ?, ?, ?, ?)`,
383
repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.RepoAt().String(), repo.Description, repo.Source,
384
)
385
+
if err != nil {
386
+
return fmt.Errorf("failed to insert repo: %w", err)
387
+
}
388
+
389
+
for _, dl := range repo.Labels {
390
+
if err := SubscribeLabel(tx, &models.RepoLabel{
391
+
RepoAt: repo.RepoAt(),
392
+
LabelAt: syntax.ATURI(dl),
393
+
}); err != nil {
394
+
return fmt.Errorf("failed to subscribe to label: %w", err)
395
+
}
396
+
}
397
+
398
+
return nil
399
}
400
401
func RemoveRepo(e Execer, did, name string) error {
···
412
return nullableSource.String, nil
413
}
414
415
+
func GetForksByDid(e Execer, did string) ([]models.Repo, error) {
416
+
var repos []models.Repo
417
418
rows, err := e.Query(
419
+
`select distinct r.id, r.did, r.name, r.knot, r.rkey, r.description, r.created, r.source
420
from repos r
421
left join collaborators c on r.at_uri = c.repo_at
422
where (r.did = ? or c.subject_did = ?)
···
431
defer rows.Close()
432
433
for rows.Next() {
434
+
var repo models.Repo
435
var createdAt string
436
var nullableDescription sql.NullString
437
var nullableSource sql.NullString
438
439
+
err := rows.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource)
440
if err != nil {
441
return nil, err
442
}
···
466
return repos, nil
467
}
468
469
+
func GetForkByDid(e Execer, did string, name string) (*models.Repo, error) {
470
+
var repo models.Repo
471
var createdAt string
472
var nullableDescription sql.NullString
473
var nullableSource sql.NullString
474
475
row := e.QueryRow(
476
+
`select id, did, name, knot, rkey, description, created, source
477
from repos
478
where did = ? and name = ? and source is not null and source != ''`,
479
did, name,
480
)
481
482
+
err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource)
483
if err != nil {
484
return nil, err
485
}
···
514
return err
515
}
516
517
+
func SubscribeLabel(e Execer, rl *models.RepoLabel) error {
518
+
query := `insert or ignore into repo_labels (repo_at, label_at) values (?, ?)`
519
+
520
+
_, err := e.Exec(query, rl.RepoAt.String(), rl.LabelAt.String())
521
+
return err
522
+
}
523
+
524
+
func UnsubscribeLabel(e Execer, filters ...filter) error {
525
+
var conditions []string
526
+
var args []any
527
+
for _, filter := range filters {
528
+
conditions = append(conditions, filter.Condition())
529
+
args = append(args, filter.Arg()...)
530
+
}
531
+
532
+
whereClause := ""
533
+
if conditions != nil {
534
+
whereClause = " where " + strings.Join(conditions, " and ")
535
+
}
536
+
537
+
query := fmt.Sprintf(`delete from repo_labels %s`, whereClause)
538
+
_, err := e.Exec(query, args...)
539
+
return err
540
+
}
541
+
542
+
func GetRepoLabels(e Execer, filters ...filter) ([]models.RepoLabel, error) {
543
+
var conditions []string
544
+
var args []any
545
+
for _, filter := range filters {
546
+
conditions = append(conditions, filter.Condition())
547
+
args = append(args, filter.Arg()...)
548
+
}
549
+
550
+
whereClause := ""
551
+
if conditions != nil {
552
+
whereClause = " where " + strings.Join(conditions, " and ")
553
+
}
554
+
555
+
query := fmt.Sprintf(`select id, repo_at, label_at from repo_labels %s`, whereClause)
556
+
557
+
rows, err := e.Query(query, args...)
558
+
if err != nil {
559
+
return nil, err
560
+
}
561
+
defer rows.Close()
562
+
563
+
var labels []models.RepoLabel
564
+
for rows.Next() {
565
+
var label models.RepoLabel
566
+
567
+
err := rows.Scan(&label.Id, &label.RepoAt, &label.LabelAt)
568
+
if err != nil {
569
+
return nil, err
570
+
}
571
+
572
+
labels = append(labels, label)
573
+
}
574
+
575
+
if err = rows.Err(); err != nil {
576
+
return nil, err
577
+
}
578
+
579
+
return labels, nil
580
}
+4
-9
appview/db/signup.go
+4
-9
appview/db/signup.go
···
1
package db
2
3
-
import "time"
4
5
-
type InflightSignup struct {
6
-
Id int64
7
-
Email string
8
-
InviteCode string
9
-
Created time.Time
10
-
}
11
-
12
-
func AddInflightSignup(e Execer, signup InflightSignup) error {
13
query := `insert into signups_inflight (email, invite_code) values (?, ?)`
14
_, err := e.Exec(query, signup.Email, signup.InviteCode)
15
return err
···
1
package db
2
3
+
import (
4
+
"tangled.org/core/appview/models"
5
+
)
6
7
+
func AddInflightSignup(e Execer, signup models.InflightSignup) error {
8
query := `insert into signups_inflight (email, invite_code) values (?, ?)`
9
_, err := e.Exec(query, signup.Email, signup.InviteCode)
10
return err
+9
-27
appview/db/spindle.go
+9
-27
appview/db/spindle.go
···
6
"strings"
7
"time"
8
9
-
"github.com/bluesky-social/indigo/atproto/syntax"
10
)
11
12
-
type Spindle struct {
13
-
Id int
14
-
Owner syntax.DID
15
-
Instance string
16
-
Verified *time.Time
17
-
Created time.Time
18
-
NeedsUpgrade bool
19
-
}
20
-
21
-
type SpindleMember struct {
22
-
Id int
23
-
Did syntax.DID // owner of the record
24
-
Rkey string // rkey of the record
25
-
Instance string
26
-
Subject syntax.DID // the member being added
27
-
Created time.Time
28
-
}
29
-
30
-
func GetSpindles(e Execer, filters ...filter) ([]Spindle, error) {
31
-
var spindles []Spindle
32
33
var conditions []string
34
var args []any
···
59
defer rows.Close()
60
61
for rows.Next() {
62
-
var spindle Spindle
63
var createdAt string
64
var verified sql.NullString
65
var needsUpgrade int
···
100
}
101
102
// if there is an existing spindle with the same instance, this returns an error
103
-
func AddSpindle(e Execer, spindle Spindle) error {
104
_, err := e.Exec(
105
`insert into spindles (owner, instance) values (?, ?)`,
106
spindle.Owner,
···
151
return err
152
}
153
154
-
func AddSpindleMember(e Execer, member SpindleMember) error {
155
_, err := e.Exec(
156
`insert or ignore into spindle_members (did, rkey, instance, subject) values (?, ?, ?, ?)`,
157
member.Did,
···
181
return err
182
}
183
184
-
func GetSpindleMembers(e Execer, filters ...filter) ([]SpindleMember, error) {
185
-
var members []SpindleMember
186
187
var conditions []string
188
var args []any
···
213
defer rows.Close()
214
215
for rows.Next() {
216
-
var member SpindleMember
217
var createdAt string
218
219
if err := rows.Scan(
···
6
"strings"
7
"time"
8
9
+
"tangled.org/core/appview/models"
10
)
11
12
+
func GetSpindles(e Execer, filters ...filter) ([]models.Spindle, error) {
13
+
var spindles []models.Spindle
14
15
var conditions []string
16
var args []any
···
41
defer rows.Close()
42
43
for rows.Next() {
44
+
var spindle models.Spindle
45
var createdAt string
46
var verified sql.NullString
47
var needsUpgrade int
···
82
}
83
84
// if there is an existing spindle with the same instance, this returns an error
85
+
func AddSpindle(e Execer, spindle models.Spindle) error {
86
_, err := e.Exec(
87
`insert into spindles (owner, instance) values (?, ?)`,
88
spindle.Owner,
···
133
return err
134
}
135
136
+
func AddSpindleMember(e Execer, member models.SpindleMember) error {
137
_, err := e.Exec(
138
`insert or ignore into spindle_members (did, rkey, instance, subject) values (?, ?, ?, ?)`,
139
member.Did,
···
163
return err
164
}
165
166
+
func GetSpindleMembers(e Execer, filters ...filter) ([]models.SpindleMember, error) {
167
+
var members []models.SpindleMember
168
169
var conditions []string
170
var args []any
···
195
defer rows.Close()
196
197
for rows.Next() {
198
+
var member models.SpindleMember
199
var createdAt string
200
201
if err := rows.Scan(
+80
-42
appview/db/star.go
+80
-42
appview/db/star.go
···
5
"errors"
6
"fmt"
7
"log"
8
"strings"
9
"time"
10
11
"github.com/bluesky-social/indigo/atproto/syntax"
12
)
13
14
-
type Star struct {
15
-
StarredByDid string
16
-
RepoAt syntax.ATURI
17
-
Created time.Time
18
-
Rkey string
19
-
20
-
// optionally, populate this when querying for reverse mappings
21
-
Repo *Repo
22
-
}
23
-
24
-
func (star *Star) ResolveRepo(e Execer) error {
25
-
if star.Repo != nil {
26
-
return nil
27
-
}
28
-
29
-
repo, err := GetRepoByAtUri(e, star.RepoAt.String())
30
-
if err != nil {
31
-
return err
32
-
}
33
-
34
-
star.Repo = repo
35
-
return nil
36
-
}
37
-
38
-
func AddStar(e Execer, star *Star) error {
39
query := `insert or ignore into stars (starred_by_did, repo_at, rkey) values (?, ?, ?)`
40
_, err := e.Exec(
41
query,
···
47
}
48
49
// Get a star record
50
-
func GetStar(e Execer, starredByDid string, repoAt syntax.ATURI) (*Star, error) {
51
query := `
52
select starred_by_did, repo_at, created, rkey
53
from stars
54
where starred_by_did = ? and repo_at = ?`
55
row := e.QueryRow(query, starredByDid, repoAt)
56
57
-
var star Star
58
var created string
59
err := row.Scan(&star.StarredByDid, &star.RepoAt, &created, &star.Rkey)
60
if err != nil {
···
94
return stars, nil
95
}
96
97
func GetStarStatus(e Execer, userDid string, repoAt syntax.ATURI) bool {
98
-
if _, err := GetStar(e, userDid, repoAt); err != nil {
99
return false
100
-
} else {
101
-
return true
102
}
103
}
104
105
-
func GetStars(e Execer, limit int, filters ...filter) ([]Star, error) {
106
var conditions []string
107
var args []any
108
for _, filter := range filters {
···
134
return nil, err
135
}
136
137
-
starMap := make(map[string][]Star)
138
for rows.Next() {
139
-
var star Star
140
var created string
141
err := rows.Scan(&star.StarredByDid, &star.RepoAt, &created, &star.Rkey)
142
if err != nil {
···
177
}
178
}
179
180
-
var stars []Star
181
for _, s := range starMap {
182
stars = append(stars, s...)
183
}
184
185
return stars, nil
186
}
187
···
209
return count, nil
210
}
211
212
-
func GetAllStars(e Execer, limit int) ([]Star, error) {
213
-
var stars []Star
214
215
rows, err := e.Query(`
216
select
···
233
defer rows.Close()
234
235
for rows.Next() {
236
-
var star Star
237
-
var repo Repo
238
var starCreatedAt, repoCreatedAt string
239
240
if err := rows.Scan(
···
272
}
273
274
// GetTopStarredReposLastWeek returns the top 8 most starred repositories from the last week
275
-
func GetTopStarredReposLastWeek(e Execer) ([]Repo, error) {
276
// first, get the top repo URIs by star count from the last week
277
query := `
278
with recent_starred_repos as (
···
316
}
317
318
if len(repoUris) == 0 {
319
-
return []Repo{}, nil
320
}
321
322
// get full repo data
···
326
}
327
328
// sort repos by the original trending order
329
-
repoMap := make(map[string]Repo)
330
for _, repo := range repos {
331
repoMap[repo.RepoAt().String()] = repo
332
}
333
334
-
orderedRepos := make([]Repo, 0, len(repoUris))
335
for _, uri := range repoUris {
336
if repo, exists := repoMap[uri]; exists {
337
orderedRepos = append(orderedRepos, repo)
···
5
"errors"
6
"fmt"
7
"log"
8
+
"slices"
9
"strings"
10
"time"
11
12
"github.com/bluesky-social/indigo/atproto/syntax"
13
+
"tangled.org/core/appview/models"
14
)
15
16
+
func AddStar(e Execer, star *models.Star) error {
17
query := `insert or ignore into stars (starred_by_did, repo_at, rkey) values (?, ?, ?)`
18
_, err := e.Exec(
19
query,
···
25
}
26
27
// Get a star record
28
+
func GetStar(e Execer, starredByDid string, repoAt syntax.ATURI) (*models.Star, error) {
29
query := `
30
select starred_by_did, repo_at, created, rkey
31
from stars
32
where starred_by_did = ? and repo_at = ?`
33
row := e.QueryRow(query, starredByDid, repoAt)
34
35
+
var star models.Star
36
var created string
37
err := row.Scan(&star.StarredByDid, &star.RepoAt, &created, &star.Rkey)
38
if err != nil {
···
72
return stars, nil
73
}
74
75
+
// getStarStatuses returns a map of repo URIs to star status for a given user
76
+
// This is an internal helper function to avoid N+1 queries
77
+
func getStarStatuses(e Execer, userDid string, repoAts []syntax.ATURI) (map[string]bool, error) {
78
+
if len(repoAts) == 0 || userDid == "" {
79
+
return make(map[string]bool), nil
80
+
}
81
+
82
+
placeholders := make([]string, len(repoAts))
83
+
args := make([]any, len(repoAts)+1)
84
+
args[0] = userDid
85
+
86
+
for i, repoAt := range repoAts {
87
+
placeholders[i] = "?"
88
+
args[i+1] = repoAt.String()
89
+
}
90
+
91
+
query := fmt.Sprintf(`
92
+
SELECT repo_at
93
+
FROM stars
94
+
WHERE starred_by_did = ? AND repo_at IN (%s)
95
+
`, strings.Join(placeholders, ","))
96
+
97
+
rows, err := e.Query(query, args...)
98
+
if err != nil {
99
+
return nil, err
100
+
}
101
+
defer rows.Close()
102
+
103
+
result := make(map[string]bool)
104
+
// Initialize all repos as not starred
105
+
for _, repoAt := range repoAts {
106
+
result[repoAt.String()] = false
107
+
}
108
+
109
+
// Mark starred repos as true
110
+
for rows.Next() {
111
+
var repoAt string
112
+
if err := rows.Scan(&repoAt); err != nil {
113
+
return nil, err
114
+
}
115
+
result[repoAt] = true
116
+
}
117
+
118
+
return result, nil
119
+
}
120
+
121
func GetStarStatus(e Execer, userDid string, repoAt syntax.ATURI) bool {
122
+
statuses, err := getStarStatuses(e, userDid, []syntax.ATURI{repoAt})
123
+
if err != nil {
124
return false
125
}
126
+
return statuses[repoAt.String()]
127
}
128
129
+
// GetStarStatuses returns a map of repo URIs to star status for a given user
130
+
func GetStarStatuses(e Execer, userDid string, repoAts []syntax.ATURI) (map[string]bool, error) {
131
+
return getStarStatuses(e, userDid, repoAts)
132
+
}
133
+
func GetStars(e Execer, limit int, filters ...filter) ([]models.Star, error) {
134
var conditions []string
135
var args []any
136
for _, filter := range filters {
···
162
return nil, err
163
}
164
165
+
starMap := make(map[string][]models.Star)
166
for rows.Next() {
167
+
var star models.Star
168
var created string
169
err := rows.Scan(&star.StarredByDid, &star.RepoAt, &created, &star.Rkey)
170
if err != nil {
···
205
}
206
}
207
208
+
var stars []models.Star
209
for _, s := range starMap {
210
stars = append(stars, s...)
211
}
212
213
+
slices.SortFunc(stars, func(a, b models.Star) int {
214
+
if a.Created.After(b.Created) {
215
+
return -1
216
+
}
217
+
if b.Created.After(a.Created) {
218
+
return 1
219
+
}
220
+
return 0
221
+
})
222
+
223
return stars, nil
224
}
225
···
247
return count, nil
248
}
249
250
+
func GetAllStars(e Execer, limit int) ([]models.Star, error) {
251
+
var stars []models.Star
252
253
rows, err := e.Query(`
254
select
···
271
defer rows.Close()
272
273
for rows.Next() {
274
+
var star models.Star
275
+
var repo models.Repo
276
var starCreatedAt, repoCreatedAt string
277
278
if err := rows.Scan(
···
310
}
311
312
// GetTopStarredReposLastWeek returns the top 8 most starred repositories from the last week
313
+
func GetTopStarredReposLastWeek(e Execer) ([]models.Repo, error) {
314
// first, get the top repo URIs by star count from the last week
315
query := `
316
with recent_starred_repos as (
···
354
}
355
356
if len(repoUris) == 0 {
357
+
return []models.Repo{}, nil
358
}
359
360
// get full repo data
···
364
}
365
366
// sort repos by the original trending order
367
+
repoMap := make(map[string]models.Repo)
368
for _, repo := range repos {
369
repoMap[repo.RepoAt().String()] = repo
370
}
371
372
+
orderedRepos := make([]models.Repo, 0, len(repoUris))
373
for _, uri := range repoUris {
374
if repo, exists := repoMap[uri]; exists {
375
orderedRepos = append(orderedRepos, repo)
+5
-110
appview/db/strings.go
+5
-110
appview/db/strings.go
···
1
package db
2
3
import (
4
-
"bytes"
5
"database/sql"
6
"errors"
7
"fmt"
8
-
"io"
9
"strings"
10
"time"
11
-
"unicode/utf8"
12
13
-
"github.com/bluesky-social/indigo/atproto/syntax"
14
-
"tangled.sh/tangled.sh/core/api/tangled"
15
)
16
17
-
type String struct {
18
-
Did syntax.DID
19
-
Rkey string
20
-
21
-
Filename string
22
-
Description string
23
-
Contents string
24
-
Created time.Time
25
-
Edited *time.Time
26
-
}
27
-
28
-
func (s *String) StringAt() syntax.ATURI {
29
-
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", s.Did, tangled.StringNSID, s.Rkey))
30
-
}
31
-
32
-
type StringStats struct {
33
-
LineCount uint64
34
-
ByteCount uint64
35
-
}
36
-
37
-
func (s String) Stats() StringStats {
38
-
lineCount, err := countLines(strings.NewReader(s.Contents))
39
-
if err != nil {
40
-
// non-fatal
41
-
// TODO: log this?
42
-
}
43
-
44
-
return StringStats{
45
-
LineCount: uint64(lineCount),
46
-
ByteCount: uint64(len(s.Contents)),
47
-
}
48
-
}
49
-
50
-
func (s String) Validate() error {
51
-
var err error
52
-
53
-
if utf8.RuneCountInString(s.Filename) > 140 {
54
-
err = errors.Join(err, fmt.Errorf("filename too long"))
55
-
}
56
-
57
-
if utf8.RuneCountInString(s.Description) > 280 {
58
-
err = errors.Join(err, fmt.Errorf("description too long"))
59
-
}
60
-
61
-
if len(s.Contents) == 0 {
62
-
err = errors.Join(err, fmt.Errorf("contents is empty"))
63
-
}
64
-
65
-
return err
66
-
}
67
-
68
-
func (s *String) AsRecord() tangled.String {
69
-
return tangled.String{
70
-
Filename: s.Filename,
71
-
Description: s.Description,
72
-
Contents: s.Contents,
73
-
CreatedAt: s.Created.Format(time.RFC3339),
74
-
}
75
-
}
76
-
77
-
func StringFromRecord(did, rkey string, record tangled.String) String {
78
-
created, err := time.Parse(record.CreatedAt, time.RFC3339)
79
-
if err != nil {
80
-
created = time.Now()
81
-
}
82
-
return String{
83
-
Did: syntax.DID(did),
84
-
Rkey: rkey,
85
-
Filename: record.Filename,
86
-
Description: record.Description,
87
-
Contents: record.Contents,
88
-
Created: created,
89
-
}
90
-
}
91
-
92
-
func AddString(e Execer, s String) error {
93
_, err := e.Exec(
94
`insert into strings (
95
did,
···
123
return err
124
}
125
126
-
func GetStrings(e Execer, limit int, filters ...filter) ([]String, error) {
127
-
var all []String
128
129
var conditions []string
130
var args []any
···
167
defer rows.Close()
168
169
for rows.Next() {
170
-
var s String
171
var createdAt string
172
var editedAt sql.NullString
173
···
248
_, err := e.Exec(query, args...)
249
return err
250
}
251
-
252
-
func countLines(r io.Reader) (int, error) {
253
-
buf := make([]byte, 32*1024)
254
-
bufLen := 0
255
-
count := 0
256
-
nl := []byte{'\n'}
257
-
258
-
for {
259
-
c, err := r.Read(buf)
260
-
if c > 0 {
261
-
bufLen += c
262
-
}
263
-
count += bytes.Count(buf[:c], nl)
264
-
265
-
switch {
266
-
case err == io.EOF:
267
-
/* handle last line not having a newline at the end */
268
-
if bufLen >= 1 && buf[(bufLen-1)%(32*1024)] != '\n' {
269
-
count++
270
-
}
271
-
return count, nil
272
-
case err != nil:
273
-
return 0, err
274
-
}
275
-
}
276
-
}
···
1
package db
2
3
import (
4
"database/sql"
5
"errors"
6
"fmt"
7
"strings"
8
"time"
9
10
+
"tangled.org/core/appview/models"
11
)
12
13
+
func AddString(e Execer, s models.String) error {
14
_, err := e.Exec(
15
`insert into strings (
16
did,
···
44
return err
45
}
46
47
+
func GetStrings(e Execer, limit int, filters ...filter) ([]models.String, error) {
48
+
var all []models.String
49
50
var conditions []string
51
var args []any
···
88
defer rows.Close()
89
90
for rows.Next() {
91
+
var s models.String
92
var createdAt string
93
var editedAt sql.NullString
94
···
169
_, err := e.Exec(query, args...)
170
return err
171
}
+93
-42
appview/db/timeline.go
+93
-42
appview/db/timeline.go
···
2
3
import (
4
"sort"
5
-
"time"
6
-
)
7
8
-
type TimelineEvent struct {
9
-
*Repo
10
-
*Follow
11
-
*Star
12
-
13
-
EventAt time.Time
14
-
15
-
// optional: populate only if Repo is a fork
16
-
Source *Repo
17
-
18
-
// optional: populate only if event is Follow
19
-
*Profile
20
-
*FollowStats
21
-
}
22
23
// TODO: this gathers heterogenous events from different sources and aggregates
24
// them in code; if we did this entirely in sql, we could order and limit and paginate easily
25
-
func MakeTimeline(e Execer, limit int) ([]TimelineEvent, error) {
26
-
var events []TimelineEvent
27
28
-
repos, err := getTimelineRepos(e, limit)
29
if err != nil {
30
return nil, err
31
}
32
33
-
stars, err := getTimelineStars(e, limit)
34
if err != nil {
35
return nil, err
36
}
37
38
-
follows, err := getTimelineFollows(e, limit)
39
if err != nil {
40
return nil, err
41
}
···
56
return events, nil
57
}
58
59
-
func getTimelineRepos(e Execer, limit int) ([]TimelineEvent, error) {
60
repos, err := GetRepos(e, limit)
61
if err != nil {
62
return nil, err
···
70
}
71
}
72
73
-
var origRepos []Repo
74
if args != nil {
75
origRepos, err = GetRepos(e, 0, FilterIn("at_uri", args))
76
}
···
78
return nil, err
79
}
80
81
-
uriToRepo := make(map[string]Repo)
82
for _, r := range origRepos {
83
uriToRepo[r.RepoAt().String()] = r
84
}
85
86
-
var events []TimelineEvent
87
for _, r := range repos {
88
-
var source *Repo
89
if r.Source != "" {
90
if origRepo, ok := uriToRepo[r.Source]; ok {
91
source = &origRepo
92
}
93
}
94
95
-
events = append(events, TimelineEvent{
96
-
Repo: &r,
97
-
EventAt: r.Created,
98
-
Source: source,
99
})
100
}
101
102
return events, nil
103
}
104
105
-
func getTimelineStars(e Execer, limit int) ([]TimelineEvent, error) {
106
stars, err := GetStars(e, limit)
107
if err != nil {
108
return nil, err
···
118
}
119
stars = stars[:n]
120
121
-
var events []TimelineEvent
122
for _, s := range stars {
123
-
events = append(events, TimelineEvent{
124
-
Star: &s,
125
-
EventAt: s.Created,
126
})
127
}
128
129
return events, nil
130
}
131
132
-
func getTimelineFollows(e Execer, limit int) ([]TimelineEvent, error) {
133
follows, err := GetFollows(e, limit)
134
if err != nil {
135
return nil, err
···
154
return nil, err
155
}
156
157
-
var events []TimelineEvent
158
for _, f := range follows {
159
profile, _ := profiles[f.SubjectDid]
160
followStatMap, _ := followStatMap[f.SubjectDid]
161
162
-
events = append(events, TimelineEvent{
163
-
Follow: &f,
164
-
Profile: profile,
165
-
FollowStats: &followStatMap,
166
-
EventAt: f.FollowedAt,
167
})
168
}
169
···
2
3
import (
4
"sort"
5
6
+
"github.com/bluesky-social/indigo/atproto/syntax"
7
+
"tangled.org/core/appview/models"
8
+
)
9
10
// TODO: this gathers heterogenous events from different sources and aggregates
11
// them in code; if we did this entirely in sql, we could order and limit and paginate easily
12
+
func MakeTimeline(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) {
13
+
var events []models.TimelineEvent
14
15
+
repos, err := getTimelineRepos(e, limit, loggedInUserDid)
16
if err != nil {
17
return nil, err
18
}
19
20
+
stars, err := getTimelineStars(e, limit, loggedInUserDid)
21
if err != nil {
22
return nil, err
23
}
24
25
+
follows, err := getTimelineFollows(e, limit, loggedInUserDid)
26
if err != nil {
27
return nil, err
28
}
···
43
return events, nil
44
}
45
46
+
func fetchStarStatuses(e Execer, loggedInUserDid string, repos []models.Repo) (map[string]bool, error) {
47
+
if loggedInUserDid == "" {
48
+
return nil, nil
49
+
}
50
+
51
+
var repoAts []syntax.ATURI
52
+
for _, r := range repos {
53
+
repoAts = append(repoAts, r.RepoAt())
54
+
}
55
+
56
+
return GetStarStatuses(e, loggedInUserDid, repoAts)
57
+
}
58
+
59
+
func getRepoStarInfo(repo *models.Repo, starStatuses map[string]bool) (bool, int64) {
60
+
var isStarred bool
61
+
if starStatuses != nil {
62
+
isStarred = starStatuses[repo.RepoAt().String()]
63
+
}
64
+
65
+
var starCount int64
66
+
if repo.RepoStats != nil {
67
+
starCount = int64(repo.RepoStats.StarCount)
68
+
}
69
+
70
+
return isStarred, starCount
71
+
}
72
+
73
+
func getTimelineRepos(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) {
74
repos, err := GetRepos(e, limit)
75
if err != nil {
76
return nil, err
···
84
}
85
}
86
87
+
var origRepos []models.Repo
88
if args != nil {
89
origRepos, err = GetRepos(e, 0, FilterIn("at_uri", args))
90
}
···
92
return nil, err
93
}
94
95
+
uriToRepo := make(map[string]models.Repo)
96
for _, r := range origRepos {
97
uriToRepo[r.RepoAt().String()] = r
98
}
99
100
+
starStatuses, err := fetchStarStatuses(e, loggedInUserDid, repos)
101
+
if err != nil {
102
+
return nil, err
103
+
}
104
+
105
+
var events []models.TimelineEvent
106
for _, r := range repos {
107
+
var source *models.Repo
108
if r.Source != "" {
109
if origRepo, ok := uriToRepo[r.Source]; ok {
110
source = &origRepo
111
}
112
}
113
114
+
isStarred, starCount := getRepoStarInfo(&r, starStatuses)
115
+
116
+
events = append(events, models.TimelineEvent{
117
+
Repo: &r,
118
+
EventAt: r.Created,
119
+
Source: source,
120
+
IsStarred: isStarred,
121
+
StarCount: starCount,
122
})
123
}
124
125
return events, nil
126
}
127
128
+
func getTimelineStars(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) {
129
stars, err := GetStars(e, limit)
130
if err != nil {
131
return nil, err
···
141
}
142
stars = stars[:n]
143
144
+
var repos []models.Repo
145
+
for _, s := range stars {
146
+
repos = append(repos, *s.Repo)
147
+
}
148
+
149
+
starStatuses, err := fetchStarStatuses(e, loggedInUserDid, repos)
150
+
if err != nil {
151
+
return nil, err
152
+
}
153
+
154
+
var events []models.TimelineEvent
155
for _, s := range stars {
156
+
isStarred, starCount := getRepoStarInfo(s.Repo, starStatuses)
157
+
158
+
events = append(events, models.TimelineEvent{
159
+
Star: &s,
160
+
EventAt: s.Created,
161
+
IsStarred: isStarred,
162
+
StarCount: starCount,
163
})
164
}
165
166
return events, nil
167
}
168
169
+
func getTimelineFollows(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) {
170
follows, err := GetFollows(e, limit)
171
if err != nil {
172
return nil, err
···
191
return nil, err
192
}
193
194
+
var followStatuses map[string]models.FollowStatus
195
+
if loggedInUserDid != "" {
196
+
followStatuses, err = GetFollowStatuses(e, loggedInUserDid, subjects)
197
+
if err != nil {
198
+
return nil, err
199
+
}
200
+
}
201
+
202
+
var events []models.TimelineEvent
203
for _, f := range follows {
204
profile, _ := profiles[f.SubjectDid]
205
followStatMap, _ := followStatMap[f.SubjectDid]
206
207
+
followStatus := models.IsNotFollowing
208
+
if followStatuses != nil {
209
+
followStatus = followStatuses[f.SubjectDid]
210
+
}
211
+
212
+
events = append(events, models.TimelineEvent{
213
+
Follow: &f,
214
+
Profile: profile,
215
+
FollowStats: &followStatMap,
216
+
FollowStatus: &followStatus,
217
+
EventAt: f.FollowedAt,
218
})
219
}
220
+1
-1
appview/dns/cloudflare.go
+1
-1
appview/dns/cloudflare.go
+198
-61
appview/ingester.go
+198
-61
appview/ingester.go
···
5
"encoding/json"
6
"fmt"
7
"log/slog"
8
9
"time"
10
11
"github.com/bluesky-social/indigo/atproto/syntax"
12
-
"github.com/bluesky-social/jetstream/pkg/models"
13
"github.com/go-git/go-git/v5/plumbing"
14
"github.com/ipfs/go-cid"
15
-
"tangled.sh/tangled.sh/core/api/tangled"
16
-
"tangled.sh/tangled.sh/core/appview/config"
17
-
"tangled.sh/tangled.sh/core/appview/db"
18
-
"tangled.sh/tangled.sh/core/appview/serververify"
19
-
"tangled.sh/tangled.sh/core/appview/validator"
20
-
"tangled.sh/tangled.sh/core/idresolver"
21
-
"tangled.sh/tangled.sh/core/rbac"
22
)
23
24
type Ingester struct {
···
30
Validator *validator.Validator
31
}
32
33
-
type processFunc func(ctx context.Context, e *models.Event) error
34
35
func (i *Ingester) Ingest() processFunc {
36
-
return func(ctx context.Context, e *models.Event) error {
37
var err error
38
defer func() {
39
eventTime := e.TimeUS
···
45
46
l := i.Logger.With("kind", e.Kind)
47
switch e.Kind {
48
-
case models.EventKindAccount:
49
if !e.Account.Active && *e.Account.Status == "deactivated" {
50
err = i.IdResolver.InvalidateIdent(ctx, e.Account.Did)
51
}
52
-
case models.EventKindIdentity:
53
err = i.IdResolver.InvalidateIdent(ctx, e.Identity.Did)
54
-
case models.EventKindCommit:
55
switch e.Commit.Collection {
56
case tangled.GraphFollowNSID:
57
err = i.ingestFollow(e)
···
77
err = i.ingestIssue(ctx, e)
78
case tangled.RepoIssueCommentNSID:
79
err = i.ingestIssueComment(e)
80
}
81
l = i.Logger.With("nsid", e.Commit.Collection)
82
}
···
89
}
90
}
91
92
-
func (i *Ingester) ingestStar(e *models.Event) error {
93
var err error
94
did := e.Did
95
···
97
l = l.With("nsid", e.Commit.Collection)
98
99
switch e.Commit.Operation {
100
-
case models.CommitOperationCreate, models.CommitOperationUpdate:
101
var subjectUri syntax.ATURI
102
103
raw := json.RawMessage(e.Commit.Record)
···
113
l.Error("invalid record", "err", err)
114
return err
115
}
116
-
err = db.AddStar(i.Db, &db.Star{
117
StarredByDid: did,
118
RepoAt: subjectUri,
119
Rkey: e.Commit.RKey,
120
})
121
-
case models.CommitOperationDelete:
122
err = db.DeleteStarByRkey(i.Db, did, e.Commit.RKey)
123
}
124
···
129
return nil
130
}
131
132
-
func (i *Ingester) ingestFollow(e *models.Event) error {
133
var err error
134
did := e.Did
135
···
137
l = l.With("nsid", e.Commit.Collection)
138
139
switch e.Commit.Operation {
140
-
case models.CommitOperationCreate, models.CommitOperationUpdate:
141
raw := json.RawMessage(e.Commit.Record)
142
record := tangled.GraphFollow{}
143
err = json.Unmarshal(raw, &record)
···
146
return err
147
}
148
149
-
err = db.AddFollow(i.Db, &db.Follow{
150
UserDid: did,
151
SubjectDid: record.Subject,
152
Rkey: e.Commit.RKey,
153
})
154
-
case models.CommitOperationDelete:
155
err = db.DeleteFollowByRkey(i.Db, did, e.Commit.RKey)
156
}
157
···
162
return nil
163
}
164
165
-
func (i *Ingester) ingestPublicKey(e *models.Event) error {
166
did := e.Did
167
var err error
168
···
170
l = l.With("nsid", e.Commit.Collection)
171
172
switch e.Commit.Operation {
173
-
case models.CommitOperationCreate, models.CommitOperationUpdate:
174
l.Debug("processing add of pubkey")
175
raw := json.RawMessage(e.Commit.Record)
176
record := tangled.PublicKey{}
···
183
name := record.Name
184
key := record.Key
185
err = db.AddPublicKey(i.Db, did, name, key, e.Commit.RKey)
186
-
case models.CommitOperationDelete:
187
l.Debug("processing delete of pubkey")
188
err = db.DeletePublicKeyByRkey(i.Db, did, e.Commit.RKey)
189
}
···
195
return nil
196
}
197
198
-
func (i *Ingester) ingestArtifact(e *models.Event) error {
199
did := e.Did
200
var err error
201
···
203
l = l.With("nsid", e.Commit.Collection)
204
205
switch e.Commit.Operation {
206
-
case models.CommitOperationCreate, models.CommitOperationUpdate:
207
raw := json.RawMessage(e.Commit.Record)
208
record := tangled.RepoArtifact{}
209
err = json.Unmarshal(raw, &record)
···
232
createdAt = time.Now()
233
}
234
235
-
artifact := db.Artifact{
236
Did: did,
237
Rkey: e.Commit.RKey,
238
RepoAt: repoAt,
···
245
}
246
247
err = db.AddArtifact(i.Db, artifact)
248
-
case models.CommitOperationDelete:
249
err = db.DeleteArtifact(i.Db, db.FilterEq("did", did), db.FilterEq("rkey", e.Commit.RKey))
250
}
251
···
256
return nil
257
}
258
259
-
func (i *Ingester) ingestProfile(e *models.Event) error {
260
did := e.Did
261
var err error
262
···
268
}
269
270
switch e.Commit.Operation {
271
-
case models.CommitOperationCreate, models.CommitOperationUpdate:
272
raw := json.RawMessage(e.Commit.Record)
273
record := tangled.ActorProfile{}
274
err = json.Unmarshal(raw, &record)
···
296
}
297
}
298
299
-
var stats [2]db.VanityStat
300
for i, s := range record.Stats {
301
if i < 2 {
302
-
stats[i].Kind = db.VanityStatKind(s)
303
}
304
}
305
···
310
}
311
}
312
313
-
profile := db.Profile{
314
Did: did,
315
Description: description,
316
IncludeBluesky: includeBluesky,
···
336
}
337
338
err = db.UpsertProfile(tx, &profile)
339
-
case models.CommitOperationDelete:
340
err = db.DeleteArtifact(i.Db, db.FilterEq("did", did), db.FilterEq("rkey", e.Commit.RKey))
341
}
342
···
347
return nil
348
}
349
350
-
func (i *Ingester) ingestSpindleMember(ctx context.Context, e *models.Event) error {
351
did := e.Did
352
var err error
353
···
355
l = l.With("nsid", e.Commit.Collection)
356
357
switch e.Commit.Operation {
358
-
case models.CommitOperationCreate:
359
raw := json.RawMessage(e.Commit.Record)
360
record := tangled.SpindleMember{}
361
err = json.Unmarshal(raw, &record)
···
384
return fmt.Errorf("failed to index profile record, invalid db cast")
385
}
386
387
-
err = db.AddSpindleMember(ddb, db.SpindleMember{
388
Did: syntax.DID(did),
389
Rkey: e.Commit.RKey,
390
Instance: record.Instance,
···
400
}
401
402
l.Info("added spindle member")
403
-
case models.CommitOperationDelete:
404
rkey := e.Commit.RKey
405
406
ddb, ok := i.Db.Execer.(*db.DB)
···
453
return nil
454
}
455
456
-
func (i *Ingester) ingestSpindle(ctx context.Context, e *models.Event) error {
457
did := e.Did
458
var err error
459
···
461
l = l.With("nsid", e.Commit.Collection)
462
463
switch e.Commit.Operation {
464
-
case models.CommitOperationCreate:
465
raw := json.RawMessage(e.Commit.Record)
466
record := tangled.Spindle{}
467
err = json.Unmarshal(raw, &record)
···
477
return fmt.Errorf("failed to index profile record, invalid db cast")
478
}
479
480
-
err := db.AddSpindle(ddb, db.Spindle{
481
Owner: syntax.DID(did),
482
Instance: instance,
483
})
···
499
500
return nil
501
502
-
case models.CommitOperationDelete:
503
instance := e.Commit.RKey
504
505
ddb, ok := i.Db.Execer.(*db.DB)
···
567
return nil
568
}
569
570
-
func (i *Ingester) ingestString(e *models.Event) error {
571
did := e.Did
572
rkey := e.Commit.RKey
573
···
582
}
583
584
switch e.Commit.Operation {
585
-
case models.CommitOperationCreate, models.CommitOperationUpdate:
586
raw := json.RawMessage(e.Commit.Record)
587
record := tangled.String{}
588
err = json.Unmarshal(raw, &record)
···
591
return err
592
}
593
594
-
string := db.StringFromRecord(did, rkey, record)
595
596
-
if err = string.Validate(); err != nil {
597
l.Error("invalid record", "err", err)
598
return err
599
}
···
605
606
return nil
607
608
-
case models.CommitOperationDelete:
609
if err := db.DeleteString(
610
ddb,
611
db.FilterEq("did", did),
···
621
return nil
622
}
623
624
-
func (i *Ingester) ingestKnotMember(e *models.Event) error {
625
did := e.Did
626
var err error
627
···
629
l = l.With("nsid", e.Commit.Collection)
630
631
switch e.Commit.Operation {
632
-
case models.CommitOperationCreate:
633
raw := json.RawMessage(e.Commit.Record)
634
record := tangled.KnotMember{}
635
err = json.Unmarshal(raw, &record)
···
659
}
660
661
l.Info("added knot member")
662
-
case models.CommitOperationDelete:
663
// we don't store knot members in a table (like we do for spindle)
664
// and we can't remove this just yet. possibly fixed if we switch
665
// to either:
···
673
return nil
674
}
675
676
-
func (i *Ingester) ingestKnot(e *models.Event) error {
677
did := e.Did
678
var err error
679
···
681
l = l.With("nsid", e.Commit.Collection)
682
683
switch e.Commit.Operation {
684
-
case models.CommitOperationCreate:
685
raw := json.RawMessage(e.Commit.Record)
686
record := tangled.Knot{}
687
err = json.Unmarshal(raw, &record)
···
716
717
return nil
718
719
-
case models.CommitOperationDelete:
720
domain := e.Commit.RKey
721
722
ddb, ok := i.Db.Execer.(*db.DB)
···
776
777
return nil
778
}
779
-
func (i *Ingester) ingestIssue(ctx context.Context, e *models.Event) error {
780
did := e.Did
781
rkey := e.Commit.RKey
782
···
791
}
792
793
switch e.Commit.Operation {
794
-
case models.CommitOperationCreate, models.CommitOperationUpdate:
795
raw := json.RawMessage(e.Commit.Record)
796
record := tangled.RepoIssue{}
797
err = json.Unmarshal(raw, &record)
···
800
return err
801
}
802
803
-
issue := db.IssueFromRecord(did, rkey, record)
804
805
if err := i.Validator.ValidateIssue(&issue); err != nil {
806
return fmt.Errorf("failed to validate issue: %w", err)
···
827
828
return nil
829
830
-
case models.CommitOperationDelete:
831
if err := db.DeleteIssues(
832
ddb,
833
db.FilterEq("did", did),
···
843
return nil
844
}
845
846
-
func (i *Ingester) ingestIssueComment(e *models.Event) error {
847
did := e.Did
848
rkey := e.Commit.RKey
849
···
858
}
859
860
switch e.Commit.Operation {
861
-
case models.CommitOperationCreate, models.CommitOperationUpdate:
862
raw := json.RawMessage(e.Commit.Record)
863
record := tangled.RepoIssueComment{}
864
err = json.Unmarshal(raw, &record)
···
866
return fmt.Errorf("invalid record: %w", err)
867
}
868
869
-
comment, err := db.IssueCommentFromRecord(ddb, did, rkey, record)
870
if err != nil {
871
return fmt.Errorf("failed to parse comment from record: %w", err)
872
}
···
882
883
return nil
884
885
-
case models.CommitOperationDelete:
886
if err := db.DeleteIssueComments(
887
ddb,
888
db.FilterEq("did", did),
···
896
897
return nil
898
}
···
5
"encoding/json"
6
"fmt"
7
"log/slog"
8
+
"maps"
9
+
"slices"
10
11
"time"
12
13
"github.com/bluesky-social/indigo/atproto/syntax"
14
+
jmodels "github.com/bluesky-social/jetstream/pkg/models"
15
"github.com/go-git/go-git/v5/plumbing"
16
"github.com/ipfs/go-cid"
17
+
"tangled.org/core/api/tangled"
18
+
"tangled.org/core/appview/config"
19
+
"tangled.org/core/appview/db"
20
+
"tangled.org/core/appview/models"
21
+
"tangled.org/core/appview/serververify"
22
+
"tangled.org/core/appview/validator"
23
+
"tangled.org/core/idresolver"
24
+
"tangled.org/core/rbac"
25
)
26
27
type Ingester struct {
···
33
Validator *validator.Validator
34
}
35
36
+
type processFunc func(ctx context.Context, e *jmodels.Event) error
37
38
func (i *Ingester) Ingest() processFunc {
39
+
return func(ctx context.Context, e *jmodels.Event) error {
40
var err error
41
defer func() {
42
eventTime := e.TimeUS
···
48
49
l := i.Logger.With("kind", e.Kind)
50
switch e.Kind {
51
+
case jmodels.EventKindAccount:
52
if !e.Account.Active && *e.Account.Status == "deactivated" {
53
err = i.IdResolver.InvalidateIdent(ctx, e.Account.Did)
54
}
55
+
case jmodels.EventKindIdentity:
56
err = i.IdResolver.InvalidateIdent(ctx, e.Identity.Did)
57
+
case jmodels.EventKindCommit:
58
switch e.Commit.Collection {
59
case tangled.GraphFollowNSID:
60
err = i.ingestFollow(e)
···
80
err = i.ingestIssue(ctx, e)
81
case tangled.RepoIssueCommentNSID:
82
err = i.ingestIssueComment(e)
83
+
case tangled.LabelDefinitionNSID:
84
+
err = i.ingestLabelDefinition(e)
85
+
case tangled.LabelOpNSID:
86
+
err = i.ingestLabelOp(e)
87
}
88
l = i.Logger.With("nsid", e.Commit.Collection)
89
}
···
96
}
97
}
98
99
+
func (i *Ingester) ingestStar(e *jmodels.Event) error {
100
var err error
101
did := e.Did
102
···
104
l = l.With("nsid", e.Commit.Collection)
105
106
switch e.Commit.Operation {
107
+
case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate:
108
var subjectUri syntax.ATURI
109
110
raw := json.RawMessage(e.Commit.Record)
···
120
l.Error("invalid record", "err", err)
121
return err
122
}
123
+
err = db.AddStar(i.Db, &models.Star{
124
StarredByDid: did,
125
RepoAt: subjectUri,
126
Rkey: e.Commit.RKey,
127
})
128
+
case jmodels.CommitOperationDelete:
129
err = db.DeleteStarByRkey(i.Db, did, e.Commit.RKey)
130
}
131
···
136
return nil
137
}
138
139
+
func (i *Ingester) ingestFollow(e *jmodels.Event) error {
140
var err error
141
did := e.Did
142
···
144
l = l.With("nsid", e.Commit.Collection)
145
146
switch e.Commit.Operation {
147
+
case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate:
148
raw := json.RawMessage(e.Commit.Record)
149
record := tangled.GraphFollow{}
150
err = json.Unmarshal(raw, &record)
···
153
return err
154
}
155
156
+
err = db.AddFollow(i.Db, &models.Follow{
157
UserDid: did,
158
SubjectDid: record.Subject,
159
Rkey: e.Commit.RKey,
160
})
161
+
case jmodels.CommitOperationDelete:
162
err = db.DeleteFollowByRkey(i.Db, did, e.Commit.RKey)
163
}
164
···
169
return nil
170
}
171
172
+
func (i *Ingester) ingestPublicKey(e *jmodels.Event) error {
173
did := e.Did
174
var err error
175
···
177
l = l.With("nsid", e.Commit.Collection)
178
179
switch e.Commit.Operation {
180
+
case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate:
181
l.Debug("processing add of pubkey")
182
raw := json.RawMessage(e.Commit.Record)
183
record := tangled.PublicKey{}
···
190
name := record.Name
191
key := record.Key
192
err = db.AddPublicKey(i.Db, did, name, key, e.Commit.RKey)
193
+
case jmodels.CommitOperationDelete:
194
l.Debug("processing delete of pubkey")
195
err = db.DeletePublicKeyByRkey(i.Db, did, e.Commit.RKey)
196
}
···
202
return nil
203
}
204
205
+
func (i *Ingester) ingestArtifact(e *jmodels.Event) error {
206
did := e.Did
207
var err error
208
···
210
l = l.With("nsid", e.Commit.Collection)
211
212
switch e.Commit.Operation {
213
+
case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate:
214
raw := json.RawMessage(e.Commit.Record)
215
record := tangled.RepoArtifact{}
216
err = json.Unmarshal(raw, &record)
···
239
createdAt = time.Now()
240
}
241
242
+
artifact := models.Artifact{
243
Did: did,
244
Rkey: e.Commit.RKey,
245
RepoAt: repoAt,
···
252
}
253
254
err = db.AddArtifact(i.Db, artifact)
255
+
case jmodels.CommitOperationDelete:
256
err = db.DeleteArtifact(i.Db, db.FilterEq("did", did), db.FilterEq("rkey", e.Commit.RKey))
257
}
258
···
263
return nil
264
}
265
266
+
func (i *Ingester) ingestProfile(e *jmodels.Event) error {
267
did := e.Did
268
var err error
269
···
275
}
276
277
switch e.Commit.Operation {
278
+
case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate:
279
raw := json.RawMessage(e.Commit.Record)
280
record := tangled.ActorProfile{}
281
err = json.Unmarshal(raw, &record)
···
303
}
304
}
305
306
+
var stats [2]models.VanityStat
307
for i, s := range record.Stats {
308
if i < 2 {
309
+
stats[i].Kind = models.VanityStatKind(s)
310
}
311
}
312
···
317
}
318
}
319
320
+
profile := models.Profile{
321
Did: did,
322
Description: description,
323
IncludeBluesky: includeBluesky,
···
343
}
344
345
err = db.UpsertProfile(tx, &profile)
346
+
case jmodels.CommitOperationDelete:
347
err = db.DeleteArtifact(i.Db, db.FilterEq("did", did), db.FilterEq("rkey", e.Commit.RKey))
348
}
349
···
354
return nil
355
}
356
357
+
func (i *Ingester) ingestSpindleMember(ctx context.Context, e *jmodels.Event) error {
358
did := e.Did
359
var err error
360
···
362
l = l.With("nsid", e.Commit.Collection)
363
364
switch e.Commit.Operation {
365
+
case jmodels.CommitOperationCreate:
366
raw := json.RawMessage(e.Commit.Record)
367
record := tangled.SpindleMember{}
368
err = json.Unmarshal(raw, &record)
···
391
return fmt.Errorf("failed to index profile record, invalid db cast")
392
}
393
394
+
err = db.AddSpindleMember(ddb, models.SpindleMember{
395
Did: syntax.DID(did),
396
Rkey: e.Commit.RKey,
397
Instance: record.Instance,
···
407
}
408
409
l.Info("added spindle member")
410
+
case jmodels.CommitOperationDelete:
411
rkey := e.Commit.RKey
412
413
ddb, ok := i.Db.Execer.(*db.DB)
···
460
return nil
461
}
462
463
+
func (i *Ingester) ingestSpindle(ctx context.Context, e *jmodels.Event) error {
464
did := e.Did
465
var err error
466
···
468
l = l.With("nsid", e.Commit.Collection)
469
470
switch e.Commit.Operation {
471
+
case jmodels.CommitOperationCreate:
472
raw := json.RawMessage(e.Commit.Record)
473
record := tangled.Spindle{}
474
err = json.Unmarshal(raw, &record)
···
484
return fmt.Errorf("failed to index profile record, invalid db cast")
485
}
486
487
+
err := db.AddSpindle(ddb, models.Spindle{
488
Owner: syntax.DID(did),
489
Instance: instance,
490
})
···
506
507
return nil
508
509
+
case jmodels.CommitOperationDelete:
510
instance := e.Commit.RKey
511
512
ddb, ok := i.Db.Execer.(*db.DB)
···
574
return nil
575
}
576
577
+
func (i *Ingester) ingestString(e *jmodels.Event) error {
578
did := e.Did
579
rkey := e.Commit.RKey
580
···
589
}
590
591
switch e.Commit.Operation {
592
+
case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate:
593
raw := json.RawMessage(e.Commit.Record)
594
record := tangled.String{}
595
err = json.Unmarshal(raw, &record)
···
598
return err
599
}
600
601
+
string := models.StringFromRecord(did, rkey, record)
602
603
+
if err = i.Validator.ValidateString(&string); err != nil {
604
l.Error("invalid record", "err", err)
605
return err
606
}
···
612
613
return nil
614
615
+
case jmodels.CommitOperationDelete:
616
if err := db.DeleteString(
617
ddb,
618
db.FilterEq("did", did),
···
628
return nil
629
}
630
631
+
func (i *Ingester) ingestKnotMember(e *jmodels.Event) error {
632
did := e.Did
633
var err error
634
···
636
l = l.With("nsid", e.Commit.Collection)
637
638
switch e.Commit.Operation {
639
+
case jmodels.CommitOperationCreate:
640
raw := json.RawMessage(e.Commit.Record)
641
record := tangled.KnotMember{}
642
err = json.Unmarshal(raw, &record)
···
666
}
667
668
l.Info("added knot member")
669
+
case jmodels.CommitOperationDelete:
670
// we don't store knot members in a table (like we do for spindle)
671
// and we can't remove this just yet. possibly fixed if we switch
672
// to either:
···
680
return nil
681
}
682
683
+
func (i *Ingester) ingestKnot(e *jmodels.Event) error {
684
did := e.Did
685
var err error
686
···
688
l = l.With("nsid", e.Commit.Collection)
689
690
switch e.Commit.Operation {
691
+
case jmodels.CommitOperationCreate:
692
raw := json.RawMessage(e.Commit.Record)
693
record := tangled.Knot{}
694
err = json.Unmarshal(raw, &record)
···
723
724
return nil
725
726
+
case jmodels.CommitOperationDelete:
727
domain := e.Commit.RKey
728
729
ddb, ok := i.Db.Execer.(*db.DB)
···
783
784
return nil
785
}
786
+
func (i *Ingester) ingestIssue(ctx context.Context, e *jmodels.Event) error {
787
did := e.Did
788
rkey := e.Commit.RKey
789
···
798
}
799
800
switch e.Commit.Operation {
801
+
case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate:
802
raw := json.RawMessage(e.Commit.Record)
803
record := tangled.RepoIssue{}
804
err = json.Unmarshal(raw, &record)
···
807
return err
808
}
809
810
+
issue := models.IssueFromRecord(did, rkey, record)
811
812
if err := i.Validator.ValidateIssue(&issue); err != nil {
813
return fmt.Errorf("failed to validate issue: %w", err)
···
834
835
return nil
836
837
+
case jmodels.CommitOperationDelete:
838
if err := db.DeleteIssues(
839
ddb,
840
db.FilterEq("did", did),
···
850
return nil
851
}
852
853
+
func (i *Ingester) ingestIssueComment(e *jmodels.Event) error {
854
did := e.Did
855
rkey := e.Commit.RKey
856
···
865
}
866
867
switch e.Commit.Operation {
868
+
case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate:
869
raw := json.RawMessage(e.Commit.Record)
870
record := tangled.RepoIssueComment{}
871
err = json.Unmarshal(raw, &record)
···
873
return fmt.Errorf("invalid record: %w", err)
874
}
875
876
+
comment, err := models.IssueCommentFromRecord(did, rkey, record)
877
if err != nil {
878
return fmt.Errorf("failed to parse comment from record: %w", err)
879
}
···
889
890
return nil
891
892
+
case jmodels.CommitOperationDelete:
893
if err := db.DeleteIssueComments(
894
ddb,
895
db.FilterEq("did", did),
···
903
904
return nil
905
}
906
+
907
+
func (i *Ingester) ingestLabelDefinition(e *jmodels.Event) error {
908
+
did := e.Did
909
+
rkey := e.Commit.RKey
910
+
911
+
var err error
912
+
913
+
l := i.Logger.With("handler", "ingestLabelDefinition", "nsid", e.Commit.Collection, "did", did, "rkey", rkey)
914
+
l.Info("ingesting record")
915
+
916
+
ddb, ok := i.Db.Execer.(*db.DB)
917
+
if !ok {
918
+
return fmt.Errorf("failed to index label definition, invalid db cast")
919
+
}
920
+
921
+
switch e.Commit.Operation {
922
+
case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate:
923
+
raw := json.RawMessage(e.Commit.Record)
924
+
record := tangled.LabelDefinition{}
925
+
err = json.Unmarshal(raw, &record)
926
+
if err != nil {
927
+
return fmt.Errorf("invalid record: %w", err)
928
+
}
929
+
930
+
def, err := models.LabelDefinitionFromRecord(did, rkey, record)
931
+
if err != nil {
932
+
return fmt.Errorf("failed to parse labeldef from record: %w", err)
933
+
}
934
+
935
+
if err := i.Validator.ValidateLabelDefinition(def); err != nil {
936
+
return fmt.Errorf("failed to validate labeldef: %w", err)
937
+
}
938
+
939
+
_, err = db.AddLabelDefinition(ddb, def)
940
+
if err != nil {
941
+
return fmt.Errorf("failed to create labeldef: %w", err)
942
+
}
943
+
944
+
return nil
945
+
946
+
case jmodels.CommitOperationDelete:
947
+
if err := db.DeleteLabelDefinition(
948
+
ddb,
949
+
db.FilterEq("did", did),
950
+
db.FilterEq("rkey", rkey),
951
+
); err != nil {
952
+
return fmt.Errorf("failed to delete labeldef record: %w", err)
953
+
}
954
+
955
+
return nil
956
+
}
957
+
958
+
return nil
959
+
}
960
+
961
+
func (i *Ingester) ingestLabelOp(e *jmodels.Event) error {
962
+
did := e.Did
963
+
rkey := e.Commit.RKey
964
+
965
+
var err error
966
+
967
+
l := i.Logger.With("handler", "ingestLabelOp", "nsid", e.Commit.Collection, "did", did, "rkey", rkey)
968
+
l.Info("ingesting record")
969
+
970
+
ddb, ok := i.Db.Execer.(*db.DB)
971
+
if !ok {
972
+
return fmt.Errorf("failed to index label op, invalid db cast")
973
+
}
974
+
975
+
switch e.Commit.Operation {
976
+
case jmodels.CommitOperationCreate:
977
+
raw := json.RawMessage(e.Commit.Record)
978
+
record := tangled.LabelOp{}
979
+
err = json.Unmarshal(raw, &record)
980
+
if err != nil {
981
+
return fmt.Errorf("invalid record: %w", err)
982
+
}
983
+
984
+
subject := syntax.ATURI(record.Subject)
985
+
collection := subject.Collection()
986
+
987
+
var repo *models.Repo
988
+
switch collection {
989
+
case tangled.RepoIssueNSID:
990
+
i, err := db.GetIssues(ddb, db.FilterEq("at_uri", subject))
991
+
if err != nil || len(i) != 1 {
992
+
return fmt.Errorf("failed to find subject: %w || subject count %d", err, len(i))
993
+
}
994
+
repo = i[0].Repo
995
+
default:
996
+
return fmt.Errorf("unsupport label subject: %s", collection)
997
+
}
998
+
999
+
actx, err := db.NewLabelApplicationCtx(ddb, db.FilterIn("at_uri", repo.Labels))
1000
+
if err != nil {
1001
+
return fmt.Errorf("failed to build label application ctx: %w", err)
1002
+
}
1003
+
1004
+
ops := models.LabelOpsFromRecord(did, rkey, record)
1005
+
1006
+
for _, o := range ops {
1007
+
def, ok := actx.Defs[o.OperandKey]
1008
+
if !ok {
1009
+
return fmt.Errorf("failed to find label def for key: %s, expected: %q", o.OperandKey, slices.Collect(maps.Keys(actx.Defs)))
1010
+
}
1011
+
if err := i.Validator.ValidateLabelOp(def, repo, &o); err != nil {
1012
+
return fmt.Errorf("failed to validate labelop: %w", err)
1013
+
}
1014
+
}
1015
+
1016
+
tx, err := ddb.Begin()
1017
+
if err != nil {
1018
+
return err
1019
+
}
1020
+
defer tx.Rollback()
1021
+
1022
+
for _, o := range ops {
1023
+
_, err = db.AddLabelOp(tx, &o)
1024
+
if err != nil {
1025
+
return fmt.Errorf("failed to add labelop: %w", err)
1026
+
}
1027
+
}
1028
+
1029
+
if err = tx.Commit(); err != nil {
1030
+
return err
1031
+
}
1032
+
}
1033
+
1034
+
return nil
1035
+
}
+71
-28
appview/issues/issues.go
+71
-28
appview/issues/issues.go
···
16
lexutil "github.com/bluesky-social/indigo/lex/util"
17
"github.com/go-chi/chi/v5"
18
19
-
"tangled.sh/tangled.sh/core/api/tangled"
20
-
"tangled.sh/tangled.sh/core/appview/config"
21
-
"tangled.sh/tangled.sh/core/appview/db"
22
-
"tangled.sh/tangled.sh/core/appview/notify"
23
-
"tangled.sh/tangled.sh/core/appview/oauth"
24
-
"tangled.sh/tangled.sh/core/appview/pages"
25
-
"tangled.sh/tangled.sh/core/appview/pagination"
26
-
"tangled.sh/tangled.sh/core/appview/reporesolver"
27
-
"tangled.sh/tangled.sh/core/appview/validator"
28
-
"tangled.sh/tangled.sh/core/appview/xrpcclient"
29
-
"tangled.sh/tangled.sh/core/idresolver"
30
-
tlog "tangled.sh/tangled.sh/core/log"
31
-
"tangled.sh/tangled.sh/core/tid"
32
)
33
34
type Issues struct {
···
75
return
76
}
77
78
-
issue, ok := r.Context().Value("issue").(*db.Issue)
79
if !ok {
80
l.Error("failed to get issue")
81
rp.pages.Error404(w)
···
87
l.Error("failed to get issue reactions", "err", err)
88
}
89
90
-
userReactions := map[db.ReactionKind]bool{}
91
if user != nil {
92
userReactions = db.GetReactionStatusMap(rp.db, user.Did, issue.AtUri())
93
}
94
95
rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{
96
LoggedInUser: user,
97
RepoInfo: f.RepoInfo(user),
98
Issue: issue,
99
CommentList: issue.CommentList(),
100
-
OrderedReactionKinds: db.OrderedReactionKinds,
101
Reactions: reactionCountMap,
102
UserReacted: userReactions,
103
})
104
}
105
···
112
return
113
}
114
115
-
issue, ok := r.Context().Value("issue").(*db.Issue)
116
if !ok {
117
l.Error("failed to get issue")
118
rp.pages.Error404(w)
···
208
return
209
}
210
211
-
issue, ok := r.Context().Value("issue").(*db.Issue)
212
if !ok {
213
l.Error("failed to get issue")
214
rp.pages.Notice(w, noticeId, "Failed to delete issue.")
···
255
return
256
}
257
258
-
issue, ok := r.Context().Value("issue").(*db.Issue)
259
if !ok {
260
l.Error("failed to get issue")
261
rp.pages.Error404(w)
···
283
return
284
}
285
286
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
287
return
288
} else {
···
301
return
302
}
303
304
-
issue, ok := r.Context().Value("issue").(*db.Issue)
305
if !ok {
306
l.Error("failed to get issue")
307
rp.pages.Error404(w)
···
345
return
346
}
347
348
-
issue, ok := r.Context().Value("issue").(*db.Issue)
349
if !ok {
350
l.Error("failed to get issue")
351
rp.pages.Error404(w)
···
364
replyTo = &replyToUri
365
}
366
367
-
comment := db.IssueComment{
368
Did: user.Did,
369
Rkey: tid.TID(),
370
IssueAt: issue.AtUri().String(),
···
416
417
// reset atUri to make rollback a no-op
418
atUri = ""
419
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issue.IssueId, commentId))
420
}
421
···
428
return
429
}
430
431
-
issue, ok := r.Context().Value("issue").(*db.Issue)
432
if !ok {
433
l.Error("failed to get issue")
434
rp.pages.Error404(w)
···
469
return
470
}
471
472
-
issue, ok := r.Context().Value("issue").(*db.Issue)
473
if !ok {
474
l.Error("failed to get issue")
475
rp.pages.Error404(w)
···
573
return
574
}
575
576
-
issue, ok := r.Context().Value("issue").(*db.Issue)
577
if !ok {
578
l.Error("failed to get issue")
579
rp.pages.Error404(w)
···
614
return
615
}
616
617
-
issue, ok := r.Context().Value("issue").(*db.Issue)
618
if !ok {
619
l.Error("failed to get issue")
620
rp.pages.Error404(w)
···
655
return
656
}
657
658
-
issue, ok := r.Context().Value("issue").(*db.Issue)
659
if !ok {
660
l.Error("failed to get issue")
661
rp.pages.Error404(w)
···
772
return
773
}
774
775
rp.pages.RepoIssues(w, pages.RepoIssuesParams{
776
LoggedInUser: rp.oauth.GetUser(r),
777
RepoInfo: f.RepoInfo(user),
778
Issues: issues,
779
FilteringByOpen: isOpen,
780
Page: page,
781
})
···
798
RepoInfo: f.RepoInfo(user),
799
})
800
case http.MethodPost:
801
-
issue := &db.Issue{
802
RepoAt: f.RepoAt(),
803
Rkey: tid.TID(),
804
Title: r.FormValue("title"),
···
16
lexutil "github.com/bluesky-social/indigo/lex/util"
17
"github.com/go-chi/chi/v5"
18
19
+
"tangled.org/core/api/tangled"
20
+
"tangled.org/core/appview/config"
21
+
"tangled.org/core/appview/db"
22
+
"tangled.org/core/appview/models"
23
+
"tangled.org/core/appview/notify"
24
+
"tangled.org/core/appview/oauth"
25
+
"tangled.org/core/appview/pages"
26
+
"tangled.org/core/appview/pagination"
27
+
"tangled.org/core/appview/reporesolver"
28
+
"tangled.org/core/appview/validator"
29
+
"tangled.org/core/appview/xrpcclient"
30
+
"tangled.org/core/idresolver"
31
+
tlog "tangled.org/core/log"
32
+
"tangled.org/core/tid"
33
)
34
35
type Issues struct {
···
76
return
77
}
78
79
+
issue, ok := r.Context().Value("issue").(*models.Issue)
80
if !ok {
81
l.Error("failed to get issue")
82
rp.pages.Error404(w)
···
88
l.Error("failed to get issue reactions", "err", err)
89
}
90
91
+
userReactions := map[models.ReactionKind]bool{}
92
if user != nil {
93
userReactions = db.GetReactionStatusMap(rp.db, user.Did, issue.AtUri())
94
}
95
96
+
labelDefs, err := db.GetLabelDefinitions(
97
+
rp.db,
98
+
db.FilterIn("at_uri", f.Repo.Labels),
99
+
db.FilterContains("scope", tangled.RepoIssueNSID),
100
+
)
101
+
if err != nil {
102
+
log.Println("failed to fetch labels", err)
103
+
rp.pages.Error503(w)
104
+
return
105
+
}
106
+
107
+
defs := make(map[string]*models.LabelDefinition)
108
+
for _, l := range labelDefs {
109
+
defs[l.AtUri().String()] = &l
110
+
}
111
+
112
rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{
113
LoggedInUser: user,
114
RepoInfo: f.RepoInfo(user),
115
Issue: issue,
116
CommentList: issue.CommentList(),
117
+
OrderedReactionKinds: models.OrderedReactionKinds,
118
Reactions: reactionCountMap,
119
UserReacted: userReactions,
120
+
LabelDefs: defs,
121
})
122
}
123
···
130
return
131
}
132
133
+
issue, ok := r.Context().Value("issue").(*models.Issue)
134
if !ok {
135
l.Error("failed to get issue")
136
rp.pages.Error404(w)
···
226
return
227
}
228
229
+
issue, ok := r.Context().Value("issue").(*models.Issue)
230
if !ok {
231
l.Error("failed to get issue")
232
rp.pages.Notice(w, noticeId, "Failed to delete issue.")
···
273
return
274
}
275
276
+
issue, ok := r.Context().Value("issue").(*models.Issue)
277
if !ok {
278
l.Error("failed to get issue")
279
rp.pages.Error404(w)
···
301
return
302
}
303
304
+
// notify about the issue closure
305
+
rp.notifier.NewIssueClosed(r.Context(), issue)
306
+
307
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
308
return
309
} else {
···
322
return
323
}
324
325
+
issue, ok := r.Context().Value("issue").(*models.Issue)
326
if !ok {
327
l.Error("failed to get issue")
328
rp.pages.Error404(w)
···
366
return
367
}
368
369
+
issue, ok := r.Context().Value("issue").(*models.Issue)
370
if !ok {
371
l.Error("failed to get issue")
372
rp.pages.Error404(w)
···
385
replyTo = &replyToUri
386
}
387
388
+
comment := models.IssueComment{
389
Did: user.Did,
390
Rkey: tid.TID(),
391
IssueAt: issue.AtUri().String(),
···
437
438
// reset atUri to make rollback a no-op
439
atUri = ""
440
+
441
+
// notify about the new comment
442
+
comment.Id = commentId
443
+
rp.notifier.NewIssueComment(r.Context(), &comment)
444
+
445
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issue.IssueId, commentId))
446
}
447
···
454
return
455
}
456
457
+
issue, ok := r.Context().Value("issue").(*models.Issue)
458
if !ok {
459
l.Error("failed to get issue")
460
rp.pages.Error404(w)
···
495
return
496
}
497
498
+
issue, ok := r.Context().Value("issue").(*models.Issue)
499
if !ok {
500
l.Error("failed to get issue")
501
rp.pages.Error404(w)
···
599
return
600
}
601
602
+
issue, ok := r.Context().Value("issue").(*models.Issue)
603
if !ok {
604
l.Error("failed to get issue")
605
rp.pages.Error404(w)
···
640
return
641
}
642
643
+
issue, ok := r.Context().Value("issue").(*models.Issue)
644
if !ok {
645
l.Error("failed to get issue")
646
rp.pages.Error404(w)
···
681
return
682
}
683
684
+
issue, ok := r.Context().Value("issue").(*models.Issue)
685
if !ok {
686
l.Error("failed to get issue")
687
rp.pages.Error404(w)
···
798
return
799
}
800
801
+
labelDefs, err := db.GetLabelDefinitions(
802
+
rp.db,
803
+
db.FilterIn("at_uri", f.Repo.Labels),
804
+
db.FilterContains("scope", tangled.RepoIssueNSID),
805
+
)
806
+
if err != nil {
807
+
log.Println("failed to fetch labels", err)
808
+
rp.pages.Error503(w)
809
+
return
810
+
}
811
+
812
+
defs := make(map[string]*models.LabelDefinition)
813
+
for _, l := range labelDefs {
814
+
defs[l.AtUri().String()] = &l
815
+
}
816
+
817
rp.pages.RepoIssues(w, pages.RepoIssuesParams{
818
LoggedInUser: rp.oauth.GetUser(r),
819
RepoInfo: f.RepoInfo(user),
820
Issues: issues,
821
+
LabelDefs: defs,
822
FilteringByOpen: isOpen,
823
Page: page,
824
})
···
841
RepoInfo: f.RepoInfo(user),
842
})
843
case http.MethodPost:
844
+
issue := &models.Issue{
845
RepoAt: f.RepoAt(),
846
Rkey: tid.TID(),
847
Title: r.FormValue("title"),
+2
-2
appview/issues/router.go
+2
-2
appview/issues/router.go
···
4
"net/http"
5
6
"github.com/go-chi/chi/v5"
7
-
"tangled.sh/tangled.sh/core/appview/middleware"
8
)
9
10
func (i *Issues) Router(mw *middleware.Middleware) http.Handler {
···
14
r.With(middleware.Paginate).Get("/", i.RepoIssues)
15
16
r.Route("/{issue}", func(r chi.Router) {
17
-
r.Use(mw.ResolveIssue())
18
r.Get("/", i.RepoSingleIssue)
19
20
// authenticated routes
···
4
"net/http"
5
6
"github.com/go-chi/chi/v5"
7
+
"tangled.org/core/appview/middleware"
8
)
9
10
func (i *Issues) Router(mw *middleware.Middleware) http.Handler {
···
14
r.With(middleware.Paginate).Get("/", i.RepoIssues)
15
16
r.Route("/{issue}", func(r chi.Router) {
17
+
r.Use(mw.ResolveIssue)
18
r.Get("/", i.RepoSingleIssue)
19
20
// authenticated routes
+14
-13
appview/knots/knots.go
+14
-13
appview/knots/knots.go
···
9
"time"
10
11
"github.com/go-chi/chi/v5"
12
-
"tangled.sh/tangled.sh/core/api/tangled"
13
-
"tangled.sh/tangled.sh/core/appview/config"
14
-
"tangled.sh/tangled.sh/core/appview/db"
15
-
"tangled.sh/tangled.sh/core/appview/middleware"
16
-
"tangled.sh/tangled.sh/core/appview/oauth"
17
-
"tangled.sh/tangled.sh/core/appview/pages"
18
-
"tangled.sh/tangled.sh/core/appview/serververify"
19
-
"tangled.sh/tangled.sh/core/appview/xrpcclient"
20
-
"tangled.sh/tangled.sh/core/eventconsumer"
21
-
"tangled.sh/tangled.sh/core/idresolver"
22
-
"tangled.sh/tangled.sh/core/rbac"
23
-
"tangled.sh/tangled.sh/core/tid"
24
25
comatproto "github.com/bluesky-social/indigo/api/atproto"
26
lexutil "github.com/bluesky-social/indigo/lex/util"
···
119
}
120
121
// organize repos by did
122
-
repoMap := make(map[string][]db.Repo)
123
for _, r := range repos {
124
repoMap[r.Did] = append(repoMap[r.Did], r)
125
}
···
9
"time"
10
11
"github.com/go-chi/chi/v5"
12
+
"tangled.org/core/api/tangled"
13
+
"tangled.org/core/appview/config"
14
+
"tangled.org/core/appview/db"
15
+
"tangled.org/core/appview/middleware"
16
+
"tangled.org/core/appview/models"
17
+
"tangled.org/core/appview/oauth"
18
+
"tangled.org/core/appview/pages"
19
+
"tangled.org/core/appview/serververify"
20
+
"tangled.org/core/appview/xrpcclient"
21
+
"tangled.org/core/eventconsumer"
22
+
"tangled.org/core/idresolver"
23
+
"tangled.org/core/rbac"
24
+
"tangled.org/core/tid"
25
26
comatproto "github.com/bluesky-social/indigo/api/atproto"
27
lexutil "github.com/bluesky-social/indigo/lex/util"
···
120
}
121
122
// organize repos by did
123
+
repoMap := make(map[string][]models.Repo)
124
for _, r := range repos {
125
repoMap[r.Did] = append(repoMap[r.Did], r)
126
}
+272
appview/labels/labels.go
+272
appview/labels/labels.go
···
···
1
+
package labels
2
+
3
+
import (
4
+
"context"
5
+
"database/sql"
6
+
"errors"
7
+
"fmt"
8
+
"log/slog"
9
+
"net/http"
10
+
"time"
11
+
12
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
13
+
"github.com/bluesky-social/indigo/atproto/syntax"
14
+
lexutil "github.com/bluesky-social/indigo/lex/util"
15
+
"github.com/go-chi/chi/v5"
16
+
17
+
"tangled.org/core/api/tangled"
18
+
"tangled.org/core/appview/db"
19
+
"tangled.org/core/appview/middleware"
20
+
"tangled.org/core/appview/models"
21
+
"tangled.org/core/appview/oauth"
22
+
"tangled.org/core/appview/pages"
23
+
"tangled.org/core/appview/validator"
24
+
"tangled.org/core/appview/xrpcclient"
25
+
"tangled.org/core/log"
26
+
"tangled.org/core/rbac"
27
+
"tangled.org/core/tid"
28
+
)
29
+
30
+
type Labels struct {
31
+
oauth *oauth.OAuth
32
+
pages *pages.Pages
33
+
db *db.DB
34
+
logger *slog.Logger
35
+
validator *validator.Validator
36
+
enforcer *rbac.Enforcer
37
+
}
38
+
39
+
func New(
40
+
oauth *oauth.OAuth,
41
+
pages *pages.Pages,
42
+
db *db.DB,
43
+
validator *validator.Validator,
44
+
enforcer *rbac.Enforcer,
45
+
) *Labels {
46
+
logger := log.New("labels")
47
+
48
+
return &Labels{
49
+
oauth: oauth,
50
+
pages: pages,
51
+
db: db,
52
+
logger: logger,
53
+
validator: validator,
54
+
enforcer: enforcer,
55
+
}
56
+
}
57
+
58
+
func (l *Labels) Router(mw *middleware.Middleware) http.Handler {
59
+
r := chi.NewRouter()
60
+
61
+
r.Use(middleware.AuthMiddleware(l.oauth))
62
+
r.Put("/perform", l.PerformLabelOp)
63
+
64
+
return r
65
+
}
66
+
67
+
// this is a tricky handler implementation:
68
+
// - the user selects the new state of all the labels in the label panel and hits save
69
+
// - this handler should calculate the diff in order to create the labelop record
70
+
// - we need the diff in order to maintain a "history" of operations performed by users
71
+
func (l *Labels) PerformLabelOp(w http.ResponseWriter, r *http.Request) {
72
+
user := l.oauth.GetUser(r)
73
+
74
+
noticeId := "add-label-error"
75
+
76
+
fail := func(msg string, err error) {
77
+
l.logger.Error("failed to add label", "err", err)
78
+
l.pages.Notice(w, noticeId, msg)
79
+
}
80
+
81
+
if err := r.ParseForm(); err != nil {
82
+
fail("Invalid form.", err)
83
+
return
84
+
}
85
+
86
+
did := user.Did
87
+
rkey := tid.TID()
88
+
performedAt := time.Now()
89
+
indexedAt := time.Now()
90
+
repoAt := r.Form.Get("repo")
91
+
subjectUri := r.Form.Get("subject")
92
+
93
+
repo, err := db.GetRepo(l.db, db.FilterEq("at_uri", repoAt))
94
+
if err != nil {
95
+
fail("Failed to get repository.", err)
96
+
return
97
+
}
98
+
99
+
// find all the labels that this repo subscribes to
100
+
repoLabels, err := db.GetRepoLabels(l.db, db.FilterEq("repo_at", repoAt))
101
+
if err != nil {
102
+
fail("Failed to get labels for this repository.", err)
103
+
return
104
+
}
105
+
106
+
var labelAts []string
107
+
for _, rl := range repoLabels {
108
+
labelAts = append(labelAts, rl.LabelAt.String())
109
+
}
110
+
111
+
actx, err := db.NewLabelApplicationCtx(l.db, db.FilterIn("at_uri", labelAts))
112
+
if err != nil {
113
+
fail("Invalid form data.", err)
114
+
return
115
+
}
116
+
117
+
// calculate the start state by applying already known labels
118
+
existingOps, err := db.GetLabelOps(l.db, db.FilterEq("subject", subjectUri))
119
+
if err != nil {
120
+
fail("Invalid form data.", err)
121
+
return
122
+
}
123
+
124
+
labelState := models.NewLabelState()
125
+
actx.ApplyLabelOps(labelState, existingOps)
126
+
127
+
var labelOps []models.LabelOp
128
+
129
+
// first delete all existing state
130
+
for key, vals := range labelState.Inner() {
131
+
for val := range vals {
132
+
labelOps = append(labelOps, models.LabelOp{
133
+
Did: did,
134
+
Rkey: rkey,
135
+
Subject: syntax.ATURI(subjectUri),
136
+
Operation: models.LabelOperationDel,
137
+
OperandKey: key,
138
+
OperandValue: val,
139
+
PerformedAt: performedAt,
140
+
IndexedAt: indexedAt,
141
+
})
142
+
}
143
+
}
144
+
145
+
// add all the new state the user specified
146
+
for key, vals := range r.Form {
147
+
if _, ok := actx.Defs[key]; !ok {
148
+
continue
149
+
}
150
+
151
+
for _, val := range vals {
152
+
labelOps = append(labelOps, models.LabelOp{
153
+
Did: did,
154
+
Rkey: rkey,
155
+
Subject: syntax.ATURI(subjectUri),
156
+
Operation: models.LabelOperationAdd,
157
+
OperandKey: key,
158
+
OperandValue: val,
159
+
PerformedAt: performedAt,
160
+
IndexedAt: indexedAt,
161
+
})
162
+
}
163
+
}
164
+
165
+
for i := range labelOps {
166
+
def := actx.Defs[labelOps[i].OperandKey]
167
+
if err := l.validator.ValidateLabelOp(def, repo, &labelOps[i]); err != nil {
168
+
fail(fmt.Sprintf("Invalid form data: %s", err), err)
169
+
return
170
+
}
171
+
}
172
+
173
+
// reduce the opset
174
+
labelOps = models.ReduceLabelOps(labelOps)
175
+
176
+
// next, apply all ops introduced in this request and filter out ones that are no-ops
177
+
validLabelOps := labelOps[:0]
178
+
for _, op := range labelOps {
179
+
if err = actx.ApplyLabelOp(labelState, op); err != models.LabelNoOpError {
180
+
validLabelOps = append(validLabelOps, op)
181
+
}
182
+
}
183
+
184
+
// nothing to do
185
+
if len(validLabelOps) == 0 {
186
+
l.pages.HxRefresh(w)
187
+
return
188
+
}
189
+
190
+
// create an atproto record of valid ops
191
+
record := models.LabelOpsAsRecord(validLabelOps)
192
+
193
+
client, err := l.oauth.AuthorizedClient(r)
194
+
if err != nil {
195
+
fail("Failed to authorize user.", err)
196
+
return
197
+
}
198
+
199
+
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
200
+
Collection: tangled.LabelOpNSID,
201
+
Repo: did,
202
+
Rkey: rkey,
203
+
Record: &lexutil.LexiconTypeDecoder{
204
+
Val: &record,
205
+
},
206
+
})
207
+
if err != nil {
208
+
fail("Failed to create record on PDS for user.", err)
209
+
return
210
+
}
211
+
atUri := resp.Uri
212
+
213
+
tx, err := l.db.BeginTx(r.Context(), nil)
214
+
if err != nil {
215
+
fail("Failed to update labels. Try again later.", err)
216
+
return
217
+
}
218
+
219
+
rollback := func() {
220
+
err1 := tx.Rollback()
221
+
err2 := rollbackRecord(context.Background(), atUri, client)
222
+
223
+
// ignore txn complete errors, this is okay
224
+
if errors.Is(err1, sql.ErrTxDone) {
225
+
err1 = nil
226
+
}
227
+
228
+
if errs := errors.Join(err1, err2); errs != nil {
229
+
return
230
+
}
231
+
}
232
+
defer rollback()
233
+
234
+
for _, o := range validLabelOps {
235
+
if _, err := db.AddLabelOp(l.db, &o); err != nil {
236
+
fail("Failed to update labels. Try again later.", err)
237
+
return
238
+
}
239
+
}
240
+
241
+
err = tx.Commit()
242
+
if err != nil {
243
+
return
244
+
}
245
+
246
+
// clear aturi when everything is successful
247
+
atUri = ""
248
+
249
+
l.pages.HxRefresh(w)
250
+
}
251
+
252
+
// this is used to rollback changes made to the PDS
253
+
//
254
+
// it is a no-op if the provided ATURI is empty
255
+
func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error {
256
+
if aturi == "" {
257
+
return nil
258
+
}
259
+
260
+
parsed := syntax.ATURI(aturi)
261
+
262
+
collection := parsed.Collection().String()
263
+
repo := parsed.Authority().String()
264
+
rkey := parsed.RecordKey().String()
265
+
266
+
_, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{
267
+
Collection: collection,
268
+
Repo: repo,
269
+
Rkey: rkey,
270
+
})
271
+
return err
272
+
}
+62
-47
appview/middleware/middleware.go
+62
-47
appview/middleware/middleware.go
···
12
13
"github.com/bluesky-social/indigo/atproto/identity"
14
"github.com/go-chi/chi/v5"
15
-
"tangled.sh/tangled.sh/core/appview/db"
16
-
"tangled.sh/tangled.sh/core/appview/oauth"
17
-
"tangled.sh/tangled.sh/core/appview/pages"
18
-
"tangled.sh/tangled.sh/core/appview/pagination"
19
-
"tangled.sh/tangled.sh/core/appview/reporesolver"
20
-
"tangled.sh/tangled.sh/core/idresolver"
21
-
"tangled.sh/tangled.sh/core/rbac"
22
)
23
24
type Middleware struct {
···
42
}
43
44
type middlewareFunc func(http.Handler) http.Handler
45
46
func AuthMiddleware(a *oauth.OAuth) middlewareFunc {
47
return func(next http.Handler) http.Handler {
···
213
return
214
}
215
216
-
repo, err := db.GetRepo(mw.db, id.DID.String(), repoName)
217
if err != nil {
218
-
// invalid did or handle
219
-
log.Println("failed to resolve repo")
220
mw.pages.ErrorKnot404(w)
221
return
222
}
···
276
}
277
278
// middleware that is tacked on top of /{user}/{repo}/issues/{issue}
279
-
func (mw Middleware) ResolveIssue() middlewareFunc {
280
-
return func(next http.Handler) http.Handler {
281
-
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
282
-
f, err := mw.repoResolver.Resolve(r)
283
-
if err != nil {
284
-
log.Println("failed to fully resolve repo", err)
285
-
mw.pages.ErrorKnot404(w)
286
-
return
287
-
}
288
289
-
issueIdStr := chi.URLParam(r, "issue")
290
-
issueId, err := strconv.Atoi(issueIdStr)
291
-
if err != nil {
292
-
log.Println("failed to fully resolve issue ID", err)
293
-
mw.pages.ErrorKnot404(w)
294
-
return
295
-
}
296
297
-
issues, err := db.GetIssues(
298
-
mw.db,
299
-
db.FilterEq("repo_at", f.RepoAt()),
300
-
db.FilterEq("issue_id", issueId),
301
-
)
302
-
if err != nil {
303
-
log.Println("failed to get issues", "err", err)
304
-
return
305
-
}
306
-
if len(issues) != 1 {
307
-
log.Println("got incorrect number of issues", "len(issuse)", len(issues))
308
-
return
309
-
}
310
-
issue := issues[0]
311
312
-
ctx := context.WithValue(r.Context(), "issue", &issue)
313
-
next.ServeHTTP(w, r.WithContext(ctx))
314
-
})
315
-
}
316
}
317
318
// this should serve the go-import meta tag even if the path is technically
319
// a 404 like tangled.sh/oppi.li/go-git/v5
320
func (mw Middleware) GoImport() middlewareFunc {
321
return func(next http.Handler) http.Handler {
322
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
···
332
if r.Header.Get("User-Agent") == "Go-http-client/1.1" {
333
if r.URL.Query().Get("go-get") == "1" {
334
html := fmt.Sprintf(
335
-
`<meta name="go-import" content="tangled.sh/%s git https://tangled.sh/%s"/>`,
336
-
fullName,
337
-
fullName,
338
)
339
w.Header().Set("Content-Type", "text/html")
340
w.Write([]byte(html))
···
12
13
"github.com/bluesky-social/indigo/atproto/identity"
14
"github.com/go-chi/chi/v5"
15
+
"tangled.org/core/appview/db"
16
+
"tangled.org/core/appview/oauth"
17
+
"tangled.org/core/appview/pages"
18
+
"tangled.org/core/appview/pagination"
19
+
"tangled.org/core/appview/reporesolver"
20
+
"tangled.org/core/idresolver"
21
+
"tangled.org/core/rbac"
22
)
23
24
type Middleware struct {
···
42
}
43
44
type middlewareFunc func(http.Handler) http.Handler
45
+
46
+
func (mw *Middleware) TryRefreshSession() middlewareFunc {
47
+
return func(next http.Handler) http.Handler {
48
+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
49
+
_, _, _ = mw.oauth.GetSession(r)
50
+
next.ServeHTTP(w, r)
51
+
})
52
+
}
53
+
}
54
55
func AuthMiddleware(a *oauth.OAuth) middlewareFunc {
56
return func(next http.Handler) http.Handler {
···
222
return
223
}
224
225
+
repo, err := db.GetRepo(
226
+
mw.db,
227
+
db.FilterEq("did", id.DID.String()),
228
+
db.FilterEq("name", repoName),
229
+
)
230
if err != nil {
231
+
log.Println("failed to resolve repo", "err", err)
232
mw.pages.ErrorKnot404(w)
233
return
234
}
···
288
}
289
290
// middleware that is tacked on top of /{user}/{repo}/issues/{issue}
291
+
func (mw Middleware) ResolveIssue(next http.Handler) http.Handler {
292
+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
293
+
f, err := mw.repoResolver.Resolve(r)
294
+
if err != nil {
295
+
log.Println("failed to fully resolve repo", err)
296
+
mw.pages.ErrorKnot404(w)
297
+
return
298
+
}
299
300
+
issueIdStr := chi.URLParam(r, "issue")
301
+
issueId, err := strconv.Atoi(issueIdStr)
302
+
if err != nil {
303
+
log.Println("failed to fully resolve issue ID", err)
304
+
mw.pages.ErrorKnot404(w)
305
+
return
306
+
}
307
308
+
issues, err := db.GetIssues(
309
+
mw.db,
310
+
db.FilterEq("repo_at", f.RepoAt()),
311
+
db.FilterEq("issue_id", issueId),
312
+
)
313
+
if err != nil {
314
+
log.Println("failed to get issues", "err", err)
315
+
return
316
+
}
317
+
if len(issues) != 1 {
318
+
log.Println("got incorrect number of issues", "len(issuse)", len(issues))
319
+
return
320
+
}
321
+
issue := issues[0]
322
323
+
ctx := context.WithValue(r.Context(), "issue", &issue)
324
+
next.ServeHTTP(w, r.WithContext(ctx))
325
+
})
326
}
327
328
// this should serve the go-import meta tag even if the path is technically
329
// a 404 like tangled.sh/oppi.li/go-git/v5
330
+
//
331
+
// we're keeping the tangled.sh go-import tag too to maintain backward
332
+
// compatiblity for modules that still point there. they will be redirected
333
+
// to fetch source from tangled.org
334
func (mw Middleware) GoImport() middlewareFunc {
335
return func(next http.Handler) http.Handler {
336
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
···
346
if r.Header.Get("User-Agent") == "Go-http-client/1.1" {
347
if r.URL.Query().Get("go-get") == "1" {
348
html := fmt.Sprintf(
349
+
`<meta name="go-import" content="tangled.sh/%s git https://tangled.sh/%s"/>
350
+
<meta name="go-import" content="tangled.org/%s git https://tangled.org/%s"/>`,
351
+
fullName, fullName,
352
+
fullName, fullName,
353
)
354
w.Header().Set("Content-Type", "text/html")
355
w.Write([]byte(html))
+30
appview/models/artifact.go
+30
appview/models/artifact.go
···
···
1
+
package models
2
+
3
+
import (
4
+
"fmt"
5
+
"time"
6
+
7
+
"github.com/bluesky-social/indigo/atproto/syntax"
8
+
"github.com/go-git/go-git/v5/plumbing"
9
+
"github.com/ipfs/go-cid"
10
+
"tangled.org/core/api/tangled"
11
+
)
12
+
13
+
type Artifact struct {
14
+
Id uint64
15
+
Did string
16
+
Rkey string
17
+
18
+
RepoAt syntax.ATURI
19
+
Tag plumbing.Hash
20
+
CreatedAt time.Time
21
+
22
+
BlobCid cid.Cid
23
+
Name string
24
+
Size uint64
25
+
MimeType string
26
+
}
27
+
28
+
func (a *Artifact) ArtifactAt() syntax.ATURI {
29
+
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", a.Did, tangled.RepoArtifactNSID, a.Rkey))
30
+
}
+21
appview/models/collaborator.go
+21
appview/models/collaborator.go
···
···
1
+
package models
2
+
3
+
import (
4
+
"time"
5
+
6
+
"github.com/bluesky-social/indigo/atproto/syntax"
7
+
)
8
+
9
+
type Collaborator struct {
10
+
// identifiers for the record
11
+
Id int64
12
+
Did syntax.DID
13
+
Rkey string
14
+
15
+
// content
16
+
SubjectDid syntax.DID
17
+
RepoAt syntax.ATURI
18
+
19
+
// meta
20
+
Created time.Time
21
+
}
+16
appview/models/email.go
+16
appview/models/email.go
+38
appview/models/follow.go
+38
appview/models/follow.go
···
···
1
+
package models
2
+
3
+
import (
4
+
"time"
5
+
)
6
+
7
+
type Follow struct {
8
+
UserDid string
9
+
SubjectDid string
10
+
FollowedAt time.Time
11
+
Rkey string
12
+
}
13
+
14
+
type FollowStats struct {
15
+
Followers int64
16
+
Following int64
17
+
}
18
+
19
+
type FollowStatus int
20
+
21
+
const (
22
+
IsNotFollowing FollowStatus = iota
23
+
IsFollowing
24
+
IsSelf
25
+
)
26
+
27
+
func (s FollowStatus) String() string {
28
+
switch s {
29
+
case IsNotFollowing:
30
+
return "IsNotFollowing"
31
+
case IsFollowing:
32
+
return "IsFollowing"
33
+
case IsSelf:
34
+
return "IsSelf"
35
+
default:
36
+
return "IsNotFollowing"
37
+
}
38
+
}
+194
appview/models/issue.go
+194
appview/models/issue.go
···
···
1
+
package models
2
+
3
+
import (
4
+
"fmt"
5
+
"sort"
6
+
"time"
7
+
8
+
"github.com/bluesky-social/indigo/atproto/syntax"
9
+
"tangled.org/core/api/tangled"
10
+
)
11
+
12
+
type Issue struct {
13
+
Id int64
14
+
Did string
15
+
Rkey string
16
+
RepoAt syntax.ATURI
17
+
IssueId int
18
+
Created time.Time
19
+
Edited *time.Time
20
+
Deleted *time.Time
21
+
Title string
22
+
Body string
23
+
Open bool
24
+
25
+
// optionally, populate this when querying for reverse mappings
26
+
// like comment counts, parent repo etc.
27
+
Comments []IssueComment
28
+
Labels LabelState
29
+
Repo *Repo
30
+
}
31
+
32
+
func (i *Issue) AtUri() syntax.ATURI {
33
+
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueNSID, i.Rkey))
34
+
}
35
+
36
+
func (i *Issue) AsRecord() tangled.RepoIssue {
37
+
return tangled.RepoIssue{
38
+
Repo: i.RepoAt.String(),
39
+
Title: i.Title,
40
+
Body: &i.Body,
41
+
CreatedAt: i.Created.Format(time.RFC3339),
42
+
}
43
+
}
44
+
45
+
func (i *Issue) State() string {
46
+
if i.Open {
47
+
return "open"
48
+
}
49
+
return "closed"
50
+
}
51
+
52
+
type CommentListItem struct {
53
+
Self *IssueComment
54
+
Replies []*IssueComment
55
+
}
56
+
57
+
func (i *Issue) CommentList() []CommentListItem {
58
+
// Create a map to quickly find comments by their aturi
59
+
toplevel := make(map[string]*CommentListItem)
60
+
var replies []*IssueComment
61
+
62
+
// collect top level comments into the map
63
+
for _, comment := range i.Comments {
64
+
if comment.IsTopLevel() {
65
+
toplevel[comment.AtUri().String()] = &CommentListItem{
66
+
Self: &comment,
67
+
}
68
+
} else {
69
+
replies = append(replies, &comment)
70
+
}
71
+
}
72
+
73
+
for _, r := range replies {
74
+
parentAt := *r.ReplyTo
75
+
if parent, exists := toplevel[parentAt]; exists {
76
+
parent.Replies = append(parent.Replies, r)
77
+
}
78
+
}
79
+
80
+
var listing []CommentListItem
81
+
for _, v := range toplevel {
82
+
listing = append(listing, *v)
83
+
}
84
+
85
+
// sort everything
86
+
sortFunc := func(a, b *IssueComment) bool {
87
+
return a.Created.Before(b.Created)
88
+
}
89
+
sort.Slice(listing, func(i, j int) bool {
90
+
return sortFunc(listing[i].Self, listing[j].Self)
91
+
})
92
+
for _, r := range listing {
93
+
sort.Slice(r.Replies, func(i, j int) bool {
94
+
return sortFunc(r.Replies[i], r.Replies[j])
95
+
})
96
+
}
97
+
98
+
return listing
99
+
}
100
+
101
+
func (i *Issue) Participants() []string {
102
+
participantSet := make(map[string]struct{})
103
+
participants := []string{}
104
+
105
+
addParticipant := func(did string) {
106
+
if _, exists := participantSet[did]; !exists {
107
+
participantSet[did] = struct{}{}
108
+
participants = append(participants, did)
109
+
}
110
+
}
111
+
112
+
addParticipant(i.Did)
113
+
114
+
for _, c := range i.Comments {
115
+
addParticipant(c.Did)
116
+
}
117
+
118
+
return participants
119
+
}
120
+
121
+
func IssueFromRecord(did, rkey string, record tangled.RepoIssue) Issue {
122
+
created, err := time.Parse(time.RFC3339, record.CreatedAt)
123
+
if err != nil {
124
+
created = time.Now()
125
+
}
126
+
127
+
body := ""
128
+
if record.Body != nil {
129
+
body = *record.Body
130
+
}
131
+
132
+
return Issue{
133
+
RepoAt: syntax.ATURI(record.Repo),
134
+
Did: did,
135
+
Rkey: rkey,
136
+
Created: created,
137
+
Title: record.Title,
138
+
Body: body,
139
+
Open: true, // new issues are open by default
140
+
}
141
+
}
142
+
143
+
type IssueComment struct {
144
+
Id int64
145
+
Did string
146
+
Rkey string
147
+
IssueAt string
148
+
ReplyTo *string
149
+
Body string
150
+
Created time.Time
151
+
Edited *time.Time
152
+
Deleted *time.Time
153
+
}
154
+
155
+
func (i *IssueComment) AtUri() syntax.ATURI {
156
+
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueCommentNSID, i.Rkey))
157
+
}
158
+
159
+
func (i *IssueComment) AsRecord() tangled.RepoIssueComment {
160
+
return tangled.RepoIssueComment{
161
+
Body: i.Body,
162
+
Issue: i.IssueAt,
163
+
CreatedAt: i.Created.Format(time.RFC3339),
164
+
ReplyTo: i.ReplyTo,
165
+
}
166
+
}
167
+
168
+
func (i *IssueComment) IsTopLevel() bool {
169
+
return i.ReplyTo == nil
170
+
}
171
+
172
+
func IssueCommentFromRecord(did, rkey string, record tangled.RepoIssueComment) (*IssueComment, error) {
173
+
created, err := time.Parse(time.RFC3339, record.CreatedAt)
174
+
if err != nil {
175
+
created = time.Now()
176
+
}
177
+
178
+
ownerDid := did
179
+
180
+
if _, err = syntax.ParseATURI(record.Issue); err != nil {
181
+
return nil, err
182
+
}
183
+
184
+
comment := IssueComment{
185
+
Did: ownerDid,
186
+
Rkey: rkey,
187
+
Body: record.Body,
188
+
IssueAt: record.Issue,
189
+
ReplyTo: record.ReplyTo,
190
+
Created: created,
191
+
}
192
+
193
+
return &comment, nil
194
+
}
+542
appview/models/label.go
+542
appview/models/label.go
···
···
1
+
package models
2
+
3
+
import (
4
+
"context"
5
+
"crypto/sha1"
6
+
"encoding/hex"
7
+
"encoding/json"
8
+
"errors"
9
+
"fmt"
10
+
"slices"
11
+
"time"
12
+
13
+
"github.com/bluesky-social/indigo/api/atproto"
14
+
"github.com/bluesky-social/indigo/atproto/syntax"
15
+
"github.com/bluesky-social/indigo/xrpc"
16
+
"tangled.org/core/api/tangled"
17
+
"tangled.org/core/consts"
18
+
"tangled.org/core/idresolver"
19
+
)
20
+
21
+
type ConcreteType string
22
+
23
+
const (
24
+
ConcreteTypeNull ConcreteType = "null"
25
+
ConcreteTypeString ConcreteType = "string"
26
+
ConcreteTypeInt ConcreteType = "integer"
27
+
ConcreteTypeBool ConcreteType = "boolean"
28
+
)
29
+
30
+
type ValueTypeFormat string
31
+
32
+
const (
33
+
ValueTypeFormatAny ValueTypeFormat = "any"
34
+
ValueTypeFormatDid ValueTypeFormat = "did"
35
+
)
36
+
37
+
// ValueType represents an atproto lexicon type definition with constraints
38
+
type ValueType struct {
39
+
Type ConcreteType `json:"type"`
40
+
Format ValueTypeFormat `json:"format,omitempty"`
41
+
Enum []string `json:"enum,omitempty"`
42
+
}
43
+
44
+
func (vt *ValueType) AsRecord() tangled.LabelDefinition_ValueType {
45
+
return tangled.LabelDefinition_ValueType{
46
+
Type: string(vt.Type),
47
+
Format: string(vt.Format),
48
+
Enum: vt.Enum,
49
+
}
50
+
}
51
+
52
+
func ValueTypeFromRecord(record tangled.LabelDefinition_ValueType) ValueType {
53
+
return ValueType{
54
+
Type: ConcreteType(record.Type),
55
+
Format: ValueTypeFormat(record.Format),
56
+
Enum: record.Enum,
57
+
}
58
+
}
59
+
60
+
func (vt ValueType) IsConcreteType() bool {
61
+
return vt.Type == ConcreteTypeNull ||
62
+
vt.Type == ConcreteTypeString ||
63
+
vt.Type == ConcreteTypeInt ||
64
+
vt.Type == ConcreteTypeBool
65
+
}
66
+
67
+
func (vt ValueType) IsNull() bool {
68
+
return vt.Type == ConcreteTypeNull
69
+
}
70
+
71
+
func (vt ValueType) IsString() bool {
72
+
return vt.Type == ConcreteTypeString
73
+
}
74
+
75
+
func (vt ValueType) IsInt() bool {
76
+
return vt.Type == ConcreteTypeInt
77
+
}
78
+
79
+
func (vt ValueType) IsBool() bool {
80
+
return vt.Type == ConcreteTypeBool
81
+
}
82
+
83
+
func (vt ValueType) IsEnum() bool {
84
+
return len(vt.Enum) > 0
85
+
}
86
+
87
+
func (vt ValueType) IsDidFormat() bool {
88
+
return vt.Format == ValueTypeFormatDid
89
+
}
90
+
91
+
func (vt ValueType) IsAnyFormat() bool {
92
+
return vt.Format == ValueTypeFormatAny
93
+
}
94
+
95
+
type LabelDefinition struct {
96
+
Id int64
97
+
Did string
98
+
Rkey string
99
+
100
+
Name string
101
+
ValueType ValueType
102
+
Scope []string
103
+
Color *string
104
+
Multiple bool
105
+
Created time.Time
106
+
}
107
+
108
+
func (l *LabelDefinition) AtUri() syntax.ATURI {
109
+
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", l.Did, tangled.LabelDefinitionNSID, l.Rkey))
110
+
}
111
+
112
+
func (l *LabelDefinition) AsRecord() tangled.LabelDefinition {
113
+
vt := l.ValueType.AsRecord()
114
+
return tangled.LabelDefinition{
115
+
Name: l.Name,
116
+
Color: l.Color,
117
+
CreatedAt: l.Created.Format(time.RFC3339),
118
+
Multiple: &l.Multiple,
119
+
Scope: l.Scope,
120
+
ValueType: &vt,
121
+
}
122
+
}
123
+
124
+
// random color for a given seed
125
+
func randomColor(seed string) string {
126
+
hash := sha1.Sum([]byte(seed))
127
+
hexStr := hex.EncodeToString(hash[:])
128
+
r := hexStr[0:2]
129
+
g := hexStr[2:4]
130
+
b := hexStr[4:6]
131
+
132
+
return fmt.Sprintf("#%s%s%s", r, g, b)
133
+
}
134
+
135
+
func (ld LabelDefinition) GetColor() string {
136
+
if ld.Color == nil {
137
+
seed := fmt.Sprintf("%d:%s:%s", ld.Id, ld.Did, ld.Rkey)
138
+
color := randomColor(seed)
139
+
return color
140
+
}
141
+
142
+
return *ld.Color
143
+
}
144
+
145
+
func LabelDefinitionFromRecord(did, rkey string, record tangled.LabelDefinition) (*LabelDefinition, error) {
146
+
created, err := time.Parse(time.RFC3339, record.CreatedAt)
147
+
if err != nil {
148
+
created = time.Now()
149
+
}
150
+
151
+
multiple := false
152
+
if record.Multiple != nil {
153
+
multiple = *record.Multiple
154
+
}
155
+
156
+
var vt ValueType
157
+
if record.ValueType != nil {
158
+
vt = ValueTypeFromRecord(*record.ValueType)
159
+
}
160
+
161
+
return &LabelDefinition{
162
+
Did: did,
163
+
Rkey: rkey,
164
+
165
+
Name: record.Name,
166
+
ValueType: vt,
167
+
Scope: record.Scope,
168
+
Color: record.Color,
169
+
Multiple: multiple,
170
+
Created: created,
171
+
}, nil
172
+
}
173
+
174
+
type LabelOp struct {
175
+
Id int64
176
+
Did string
177
+
Rkey string
178
+
Subject syntax.ATURI
179
+
Operation LabelOperation
180
+
OperandKey string
181
+
OperandValue string
182
+
PerformedAt time.Time
183
+
IndexedAt time.Time
184
+
}
185
+
186
+
func (l LabelOp) SortAt() time.Time {
187
+
createdAt := l.PerformedAt
188
+
indexedAt := l.IndexedAt
189
+
190
+
// if we don't have an indexedat, fall back to now
191
+
if indexedAt.IsZero() {
192
+
indexedAt = time.Now()
193
+
}
194
+
195
+
// if createdat is invalid (before epoch), treat as null -> return zero time
196
+
if createdAt.Before(time.UnixMicro(0)) {
197
+
return time.Time{}
198
+
}
199
+
200
+
// if createdat is <= indexedat, use createdat
201
+
if createdAt.Before(indexedAt) || createdAt.Equal(indexedAt) {
202
+
return createdAt
203
+
}
204
+
205
+
// otherwise, createdat is in the future relative to indexedat -> use indexedat
206
+
return indexedAt
207
+
}
208
+
209
+
type LabelOperation string
210
+
211
+
const (
212
+
LabelOperationAdd LabelOperation = "add"
213
+
LabelOperationDel LabelOperation = "del"
214
+
)
215
+
216
+
// a record can create multiple label ops
217
+
func LabelOpsFromRecord(did, rkey string, record tangled.LabelOp) []LabelOp {
218
+
performed, err := time.Parse(time.RFC3339, record.PerformedAt)
219
+
if err != nil {
220
+
performed = time.Now()
221
+
}
222
+
223
+
mkOp := func(operand *tangled.LabelOp_Operand) LabelOp {
224
+
return LabelOp{
225
+
Did: did,
226
+
Rkey: rkey,
227
+
Subject: syntax.ATURI(record.Subject),
228
+
OperandKey: operand.Key,
229
+
OperandValue: operand.Value,
230
+
PerformedAt: performed,
231
+
}
232
+
}
233
+
234
+
var ops []LabelOp
235
+
// deletes first, then additions
236
+
for _, o := range record.Delete {
237
+
if o != nil {
238
+
op := mkOp(o)
239
+
op.Operation = LabelOperationDel
240
+
ops = append(ops, op)
241
+
}
242
+
}
243
+
for _, o := range record.Add {
244
+
if o != nil {
245
+
op := mkOp(o)
246
+
op.Operation = LabelOperationAdd
247
+
ops = append(ops, op)
248
+
}
249
+
}
250
+
251
+
return ops
252
+
}
253
+
254
+
func LabelOpsAsRecord(ops []LabelOp) tangled.LabelOp {
255
+
if len(ops) == 0 {
256
+
return tangled.LabelOp{}
257
+
}
258
+
259
+
// use the first operation to establish common fields
260
+
first := ops[0]
261
+
record := tangled.LabelOp{
262
+
Subject: string(first.Subject),
263
+
PerformedAt: first.PerformedAt.Format(time.RFC3339),
264
+
}
265
+
266
+
var addOperands []*tangled.LabelOp_Operand
267
+
var deleteOperands []*tangled.LabelOp_Operand
268
+
269
+
for _, op := range ops {
270
+
operand := &tangled.LabelOp_Operand{
271
+
Key: op.OperandKey,
272
+
Value: op.OperandValue,
273
+
}
274
+
275
+
switch op.Operation {
276
+
case LabelOperationAdd:
277
+
addOperands = append(addOperands, operand)
278
+
case LabelOperationDel:
279
+
deleteOperands = append(deleteOperands, operand)
280
+
default:
281
+
return tangled.LabelOp{}
282
+
}
283
+
}
284
+
285
+
record.Add = addOperands
286
+
record.Delete = deleteOperands
287
+
288
+
return record
289
+
}
290
+
291
+
type set = map[string]struct{}
292
+
293
+
type LabelState struct {
294
+
inner map[string]set
295
+
}
296
+
297
+
func NewLabelState() LabelState {
298
+
return LabelState{
299
+
inner: make(map[string]set),
300
+
}
301
+
}
302
+
303
+
func (s LabelState) Inner() map[string]set {
304
+
return s.inner
305
+
}
306
+
307
+
func (s LabelState) ContainsLabel(l string) bool {
308
+
if valset, exists := s.inner[l]; exists {
309
+
if valset != nil {
310
+
return true
311
+
}
312
+
}
313
+
314
+
return false
315
+
}
316
+
317
+
// go maps behavior in templates make this necessary,
318
+
// indexing a map and getting `set` in return is apparently truthy
319
+
func (s LabelState) ContainsLabelAndVal(l, v string) bool {
320
+
if valset, exists := s.inner[l]; exists {
321
+
if _, exists := valset[v]; exists {
322
+
return true
323
+
}
324
+
}
325
+
326
+
return false
327
+
}
328
+
329
+
func (s LabelState) GetValSet(l string) set {
330
+
if valset, exists := s.inner[l]; exists {
331
+
return valset
332
+
} else {
333
+
return make(set)
334
+
}
335
+
}
336
+
337
+
type LabelApplicationCtx struct {
338
+
Defs map[string]*LabelDefinition // labelAt -> labelDef
339
+
}
340
+
341
+
var (
342
+
LabelNoOpError = errors.New("no-op")
343
+
)
344
+
345
+
func (c *LabelApplicationCtx) ApplyLabelOp(state LabelState, op LabelOp) error {
346
+
def, ok := c.Defs[op.OperandKey]
347
+
if !ok {
348
+
// this def was deleted, but an op exists, so we just skip over the op
349
+
return nil
350
+
}
351
+
352
+
switch op.Operation {
353
+
case LabelOperationAdd:
354
+
// if valueset is empty, init it
355
+
if state.inner[op.OperandKey] == nil {
356
+
state.inner[op.OperandKey] = make(set)
357
+
}
358
+
359
+
// if valueset is populated & this val alr exists, this labelop is a noop
360
+
if valueSet, exists := state.inner[op.OperandKey]; exists {
361
+
if _, exists = valueSet[op.OperandValue]; exists {
362
+
return LabelNoOpError
363
+
}
364
+
}
365
+
366
+
if def.Multiple {
367
+
// append to set
368
+
state.inner[op.OperandKey][op.OperandValue] = struct{}{}
369
+
} else {
370
+
// reset to just this value
371
+
state.inner[op.OperandKey] = set{op.OperandValue: struct{}{}}
372
+
}
373
+
374
+
case LabelOperationDel:
375
+
// if label DNE, then deletion is a no-op
376
+
if valueSet, exists := state.inner[op.OperandKey]; !exists {
377
+
return LabelNoOpError
378
+
} else if _, exists = valueSet[op.OperandValue]; !exists { // if value DNE, then deletion is no-op
379
+
return LabelNoOpError
380
+
}
381
+
382
+
if def.Multiple {
383
+
// remove from set
384
+
delete(state.inner[op.OperandKey], op.OperandValue)
385
+
} else {
386
+
// reset the entire label
387
+
delete(state.inner, op.OperandKey)
388
+
}
389
+
390
+
// if the map becomes empty, then set it to nil, this is just the inverse of add
391
+
if len(state.inner[op.OperandKey]) == 0 {
392
+
state.inner[op.OperandKey] = nil
393
+
}
394
+
395
+
}
396
+
397
+
return nil
398
+
}
399
+
400
+
func (c *LabelApplicationCtx) ApplyLabelOps(state LabelState, ops []LabelOp) {
401
+
// sort label ops in sort order first
402
+
slices.SortFunc(ops, func(a, b LabelOp) int {
403
+
return a.SortAt().Compare(b.SortAt())
404
+
})
405
+
406
+
// apply ops in sequence
407
+
for _, o := range ops {
408
+
_ = c.ApplyLabelOp(state, o)
409
+
}
410
+
}
411
+
412
+
// IsInverse checks if one label operation is the inverse of another
413
+
// returns true if one is an add and the other is a delete with the same key and value
414
+
func (op1 LabelOp) IsInverse(op2 LabelOp) bool {
415
+
if op1.OperandKey != op2.OperandKey || op1.OperandValue != op2.OperandValue {
416
+
return false
417
+
}
418
+
419
+
return (op1.Operation == LabelOperationAdd && op2.Operation == LabelOperationDel) ||
420
+
(op1.Operation == LabelOperationDel && op2.Operation == LabelOperationAdd)
421
+
}
422
+
423
+
// removes pairs of label operations that are inverses of each other
424
+
// from the given slice. the function preserves the order of remaining operations.
425
+
func ReduceLabelOps(ops []LabelOp) []LabelOp {
426
+
if len(ops) <= 1 {
427
+
return ops
428
+
}
429
+
430
+
keep := make([]bool, len(ops))
431
+
for i := range keep {
432
+
keep[i] = true
433
+
}
434
+
435
+
for i := range ops {
436
+
if !keep[i] {
437
+
continue
438
+
}
439
+
440
+
for j := i + 1; j < len(ops); j++ {
441
+
if !keep[j] {
442
+
continue
443
+
}
444
+
445
+
if ops[i].IsInverse(ops[j]) {
446
+
keep[i] = false
447
+
keep[j] = false
448
+
break // move to next i since this one is now eliminated
449
+
}
450
+
}
451
+
}
452
+
453
+
// build result slice with only kept operations
454
+
var result []LabelOp
455
+
for i, op := range ops {
456
+
if keep[i] {
457
+
result = append(result, op)
458
+
}
459
+
}
460
+
461
+
return result
462
+
}
463
+
464
+
var (
465
+
LabelWontfix = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "wontfix")
466
+
LabelDuplicate = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "duplicate")
467
+
LabelAssignee = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "assignee")
468
+
LabelGoodFirstIssue = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "good-first-issue")
469
+
LabelDocumentation = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "documentation")
470
+
)
471
+
472
+
func DefaultLabelDefs() []string {
473
+
return []string{
474
+
LabelWontfix,
475
+
LabelDuplicate,
476
+
LabelAssignee,
477
+
LabelGoodFirstIssue,
478
+
LabelDocumentation,
479
+
}
480
+
}
481
+
482
+
func FetchDefaultDefs(r *idresolver.Resolver) ([]LabelDefinition, error) {
483
+
resolved, err := r.ResolveIdent(context.Background(), consts.TangledDid)
484
+
if err != nil {
485
+
return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", consts.TangledDid, err)
486
+
}
487
+
pdsEndpoint := resolved.PDSEndpoint()
488
+
if pdsEndpoint == "" {
489
+
return nil, fmt.Errorf("no PDS endpoint found for tangled.sh DID %s", consts.TangledDid)
490
+
}
491
+
client := &xrpc.Client{
492
+
Host: pdsEndpoint,
493
+
}
494
+
495
+
var labelDefs []LabelDefinition
496
+
497
+
for _, dl := range DefaultLabelDefs() {
498
+
atUri := syntax.ATURI(dl)
499
+
parsedUri, err := syntax.ParseATURI(string(atUri))
500
+
if err != nil {
501
+
return nil, fmt.Errorf("failed to parse AT-URI %s: %v", atUri, err)
502
+
}
503
+
record, err := atproto.RepoGetRecord(
504
+
context.Background(),
505
+
client,
506
+
"",
507
+
parsedUri.Collection().String(),
508
+
parsedUri.Authority().String(),
509
+
parsedUri.RecordKey().String(),
510
+
)
511
+
if err != nil {
512
+
return nil, fmt.Errorf("failed to get record for %s: %v", atUri, err)
513
+
}
514
+
515
+
if record != nil {
516
+
bytes, err := record.Value.MarshalJSON()
517
+
if err != nil {
518
+
return nil, fmt.Errorf("failed to marshal record value for %s: %v", atUri, err)
519
+
}
520
+
521
+
raw := json.RawMessage(bytes)
522
+
labelRecord := tangled.LabelDefinition{}
523
+
err = json.Unmarshal(raw, &labelRecord)
524
+
if err != nil {
525
+
return nil, fmt.Errorf("invalid record for %s: %w", atUri, err)
526
+
}
527
+
528
+
labelDef, err := LabelDefinitionFromRecord(
529
+
parsedUri.Authority().String(),
530
+
parsedUri.RecordKey().String(),
531
+
labelRecord,
532
+
)
533
+
if err != nil {
534
+
return nil, fmt.Errorf("failed to create label definition from record %s: %v", atUri, err)
535
+
}
536
+
537
+
labelDefs = append(labelDefs, *labelDef)
538
+
}
539
+
}
540
+
541
+
return labelDefs, nil
542
+
}
+14
appview/models/language.go
+14
appview/models/language.go
+82
appview/models/notifications.go
+82
appview/models/notifications.go
···
···
1
+
package models
2
+
3
+
import (
4
+
"time"
5
+
)
6
+
7
+
type NotificationType string
8
+
9
+
const (
10
+
NotificationTypeRepoStarred NotificationType = "repo_starred"
11
+
NotificationTypeIssueCreated NotificationType = "issue_created"
12
+
NotificationTypeIssueCommented NotificationType = "issue_commented"
13
+
NotificationTypePullCreated NotificationType = "pull_created"
14
+
NotificationTypePullCommented NotificationType = "pull_commented"
15
+
NotificationTypeFollowed NotificationType = "followed"
16
+
NotificationTypePullMerged NotificationType = "pull_merged"
17
+
NotificationTypeIssueClosed NotificationType = "issue_closed"
18
+
NotificationTypePullClosed NotificationType = "pull_closed"
19
+
)
20
+
21
+
type Notification struct {
22
+
ID int64
23
+
RecipientDid string
24
+
ActorDid string
25
+
Type NotificationType
26
+
EntityType string
27
+
EntityId string
28
+
Read bool
29
+
Created time.Time
30
+
31
+
// foreign key references
32
+
RepoId *int64
33
+
IssueId *int64
34
+
PullId *int64
35
+
}
36
+
37
+
// lucide icon that represents this notification
38
+
func (n *Notification) Icon() string {
39
+
switch n.Type {
40
+
case NotificationTypeRepoStarred:
41
+
return "star"
42
+
case NotificationTypeIssueCreated:
43
+
return "circle-dot"
44
+
case NotificationTypeIssueCommented:
45
+
return "message-square"
46
+
case NotificationTypeIssueClosed:
47
+
return "ban"
48
+
case NotificationTypePullCreated:
49
+
return "git-pull-request-create"
50
+
case NotificationTypePullCommented:
51
+
return "message-square"
52
+
case NotificationTypePullMerged:
53
+
return "git-merge"
54
+
case NotificationTypePullClosed:
55
+
return "git-pull-request-closed"
56
+
case NotificationTypeFollowed:
57
+
return "user-plus"
58
+
default:
59
+
return ""
60
+
}
61
+
}
62
+
63
+
type NotificationWithEntity struct {
64
+
*Notification
65
+
Repo *Repo
66
+
Issue *Issue
67
+
Pull *Pull
68
+
}
69
+
70
+
type NotificationPreferences struct {
71
+
ID int64
72
+
UserDid string
73
+
RepoStarred bool
74
+
IssueCreated bool
75
+
IssueCommented bool
76
+
PullCreated bool
77
+
PullCommented bool
78
+
Followed bool
79
+
PullMerged bool
80
+
IssueClosed bool
81
+
EmailNotifications bool
82
+
}
+130
appview/models/pipeline.go
+130
appview/models/pipeline.go
···
···
1
+
package models
2
+
3
+
import (
4
+
"slices"
5
+
"time"
6
+
7
+
"github.com/bluesky-social/indigo/atproto/syntax"
8
+
"github.com/go-git/go-git/v5/plumbing"
9
+
spindle "tangled.org/core/spindle/models"
10
+
"tangled.org/core/workflow"
11
+
)
12
+
13
+
type Pipeline struct {
14
+
Id int
15
+
Rkey string
16
+
Knot string
17
+
RepoOwner syntax.DID
18
+
RepoName string
19
+
TriggerId int
20
+
Sha string
21
+
Created time.Time
22
+
23
+
// populate when querying for reverse mappings
24
+
Trigger *Trigger
25
+
Statuses map[string]WorkflowStatus
26
+
}
27
+
28
+
type WorkflowStatus struct {
29
+
Data []PipelineStatus
30
+
}
31
+
32
+
func (w WorkflowStatus) Latest() PipelineStatus {
33
+
return w.Data[len(w.Data)-1]
34
+
}
35
+
36
+
// time taken by this workflow to reach an "end state"
37
+
func (w WorkflowStatus) TimeTaken() time.Duration {
38
+
var start, end *time.Time
39
+
for _, s := range w.Data {
40
+
if s.Status.IsStart() {
41
+
start = &s.Created
42
+
}
43
+
if s.Status.IsFinish() {
44
+
end = &s.Created
45
+
}
46
+
}
47
+
48
+
if start != nil && end != nil && end.After(*start) {
49
+
return end.Sub(*start)
50
+
}
51
+
52
+
return 0
53
+
}
54
+
55
+
func (p Pipeline) Counts() map[string]int {
56
+
m := make(map[string]int)
57
+
for _, w := range p.Statuses {
58
+
m[w.Latest().Status.String()] += 1
59
+
}
60
+
return m
61
+
}
62
+
63
+
func (p Pipeline) TimeTaken() time.Duration {
64
+
var s time.Duration
65
+
for _, w := range p.Statuses {
66
+
s += w.TimeTaken()
67
+
}
68
+
return s
69
+
}
70
+
71
+
func (p Pipeline) Workflows() []string {
72
+
var ws []string
73
+
for v := range p.Statuses {
74
+
ws = append(ws, v)
75
+
}
76
+
slices.Sort(ws)
77
+
return ws
78
+
}
79
+
80
+
// if we know that a spindle has picked up this pipeline, then it is Responding
81
+
func (p Pipeline) IsResponding() bool {
82
+
return len(p.Statuses) != 0
83
+
}
84
+
85
+
type Trigger struct {
86
+
Id int
87
+
Kind workflow.TriggerKind
88
+
89
+
// push trigger fields
90
+
PushRef *string
91
+
PushNewSha *string
92
+
PushOldSha *string
93
+
94
+
// pull request trigger fields
95
+
PRSourceBranch *string
96
+
PRTargetBranch *string
97
+
PRSourceSha *string
98
+
PRAction *string
99
+
}
100
+
101
+
func (t *Trigger) IsPush() bool {
102
+
return t != nil && t.Kind == workflow.TriggerKindPush
103
+
}
104
+
105
+
func (t *Trigger) IsPullRequest() bool {
106
+
return t != nil && t.Kind == workflow.TriggerKindPullRequest
107
+
}
108
+
109
+
func (t *Trigger) TargetRef() string {
110
+
if t.IsPush() {
111
+
return plumbing.ReferenceName(*t.PushRef).Short()
112
+
} else if t.IsPullRequest() {
113
+
return *t.PRTargetBranch
114
+
}
115
+
116
+
return ""
117
+
}
118
+
119
+
type PipelineStatus struct {
120
+
ID int
121
+
Spindle string
122
+
Rkey string
123
+
PipelineKnot string
124
+
PipelineRkey string
125
+
Created time.Time
126
+
Workflow string
127
+
Status spindle.StatusKind
128
+
Error *string
129
+
ExitCode int
130
+
}
+177
appview/models/profile.go
+177
appview/models/profile.go
···
···
1
+
package models
2
+
3
+
import (
4
+
"fmt"
5
+
6
+
"github.com/bluesky-social/indigo/atproto/syntax"
7
+
"tangled.org/core/api/tangled"
8
+
)
9
+
10
+
type Profile struct {
11
+
// ids
12
+
ID int
13
+
Did string
14
+
15
+
// data
16
+
Description string
17
+
IncludeBluesky bool
18
+
Location string
19
+
Links [5]string
20
+
Stats [2]VanityStat
21
+
PinnedRepos [6]syntax.ATURI
22
+
}
23
+
24
+
func (p Profile) IsLinksEmpty() bool {
25
+
for _, l := range p.Links {
26
+
if l != "" {
27
+
return false
28
+
}
29
+
}
30
+
return true
31
+
}
32
+
33
+
func (p Profile) IsStatsEmpty() bool {
34
+
for _, s := range p.Stats {
35
+
if s.Kind != "" {
36
+
return false
37
+
}
38
+
}
39
+
return true
40
+
}
41
+
42
+
func (p Profile) IsPinnedReposEmpty() bool {
43
+
for _, r := range p.PinnedRepos {
44
+
if r != "" {
45
+
return false
46
+
}
47
+
}
48
+
return true
49
+
}
50
+
51
+
type VanityStatKind string
52
+
53
+
const (
54
+
VanityStatMergedPRCount VanityStatKind = "merged-pull-request-count"
55
+
VanityStatClosedPRCount VanityStatKind = "closed-pull-request-count"
56
+
VanityStatOpenPRCount VanityStatKind = "open-pull-request-count"
57
+
VanityStatOpenIssueCount VanityStatKind = "open-issue-count"
58
+
VanityStatClosedIssueCount VanityStatKind = "closed-issue-count"
59
+
VanityStatRepositoryCount VanityStatKind = "repository-count"
60
+
)
61
+
62
+
func (v VanityStatKind) String() string {
63
+
switch v {
64
+
case VanityStatMergedPRCount:
65
+
return "Merged PRs"
66
+
case VanityStatClosedPRCount:
67
+
return "Closed PRs"
68
+
case VanityStatOpenPRCount:
69
+
return "Open PRs"
70
+
case VanityStatOpenIssueCount:
71
+
return "Open Issues"
72
+
case VanityStatClosedIssueCount:
73
+
return "Closed Issues"
74
+
case VanityStatRepositoryCount:
75
+
return "Repositories"
76
+
}
77
+
return ""
78
+
}
79
+
80
+
type VanityStat struct {
81
+
Kind VanityStatKind
82
+
Value uint64
83
+
}
84
+
85
+
func (p *Profile) ProfileAt() syntax.ATURI {
86
+
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.Did, tangled.ActorProfileNSID, "self"))
87
+
}
88
+
89
+
type RepoEvent struct {
90
+
Repo *Repo
91
+
Source *Repo
92
+
}
93
+
94
+
type ProfileTimeline struct {
95
+
ByMonth []ByMonth
96
+
}
97
+
98
+
func (p *ProfileTimeline) IsEmpty() bool {
99
+
if p == nil {
100
+
return true
101
+
}
102
+
103
+
for _, m := range p.ByMonth {
104
+
if !m.IsEmpty() {
105
+
return false
106
+
}
107
+
}
108
+
109
+
return true
110
+
}
111
+
112
+
type ByMonth struct {
113
+
RepoEvents []RepoEvent
114
+
IssueEvents IssueEvents
115
+
PullEvents PullEvents
116
+
}
117
+
118
+
func (b ByMonth) IsEmpty() bool {
119
+
return len(b.RepoEvents) == 0 &&
120
+
len(b.IssueEvents.Items) == 0 &&
121
+
len(b.PullEvents.Items) == 0
122
+
}
123
+
124
+
type IssueEvents struct {
125
+
Items []*Issue
126
+
}
127
+
128
+
type IssueEventStats struct {
129
+
Open int
130
+
Closed int
131
+
}
132
+
133
+
func (i IssueEvents) Stats() IssueEventStats {
134
+
var open, closed int
135
+
for _, issue := range i.Items {
136
+
if issue.Open {
137
+
open += 1
138
+
} else {
139
+
closed += 1
140
+
}
141
+
}
142
+
143
+
return IssueEventStats{
144
+
Open: open,
145
+
Closed: closed,
146
+
}
147
+
}
148
+
149
+
type PullEvents struct {
150
+
Items []*Pull
151
+
}
152
+
153
+
func (p PullEvents) Stats() PullEventStats {
154
+
var open, merged, closed int
155
+
for _, pull := range p.Items {
156
+
switch pull.State {
157
+
case PullOpen:
158
+
open += 1
159
+
case PullMerged:
160
+
merged += 1
161
+
case PullClosed:
162
+
closed += 1
163
+
}
164
+
}
165
+
166
+
return PullEventStats{
167
+
Open: open,
168
+
Merged: merged,
169
+
Closed: closed,
170
+
}
171
+
}
172
+
173
+
type PullEventStats struct {
174
+
Closed int
175
+
Open int
176
+
Merged int
177
+
}
+25
appview/models/pubkey.go
+25
appview/models/pubkey.go
···
···
1
+
package models
2
+
3
+
import (
4
+
"encoding/json"
5
+
"time"
6
+
)
7
+
8
+
type PublicKey struct {
9
+
Did string `json:"did"`
10
+
Key string `json:"key"`
11
+
Name string `json:"name"`
12
+
Rkey string `json:"rkey"`
13
+
Created *time.Time
14
+
}
15
+
16
+
func (p PublicKey) MarshalJSON() ([]byte, error) {
17
+
type Alias PublicKey
18
+
return json.Marshal(&struct {
19
+
Created string `json:"created"`
20
+
*Alias
21
+
}{
22
+
Created: p.Created.Format(time.RFC3339),
23
+
Alias: (*Alias)(&p),
24
+
})
25
+
}
+352
appview/models/pull.go
+352
appview/models/pull.go
···
···
1
+
package models
2
+
3
+
import (
4
+
"fmt"
5
+
"log"
6
+
"slices"
7
+
"strings"
8
+
"time"
9
+
10
+
"github.com/bluesky-social/indigo/atproto/syntax"
11
+
"tangled.org/core/api/tangled"
12
+
"tangled.org/core/patchutil"
13
+
"tangled.org/core/types"
14
+
)
15
+
16
+
type PullState int
17
+
18
+
const (
19
+
PullClosed PullState = iota
20
+
PullOpen
21
+
PullMerged
22
+
PullDeleted
23
+
)
24
+
25
+
func (p PullState) String() string {
26
+
switch p {
27
+
case PullOpen:
28
+
return "open"
29
+
case PullMerged:
30
+
return "merged"
31
+
case PullClosed:
32
+
return "closed"
33
+
case PullDeleted:
34
+
return "deleted"
35
+
default:
36
+
return "closed"
37
+
}
38
+
}
39
+
40
+
func (p PullState) IsOpen() bool {
41
+
return p == PullOpen
42
+
}
43
+
func (p PullState) IsMerged() bool {
44
+
return p == PullMerged
45
+
}
46
+
func (p PullState) IsClosed() bool {
47
+
return p == PullClosed
48
+
}
49
+
func (p PullState) IsDeleted() bool {
50
+
return p == PullDeleted
51
+
}
52
+
53
+
type Pull struct {
54
+
// ids
55
+
ID int
56
+
PullId int
57
+
58
+
// at ids
59
+
RepoAt syntax.ATURI
60
+
OwnerDid string
61
+
Rkey string
62
+
63
+
// content
64
+
Title string
65
+
Body string
66
+
TargetBranch string
67
+
State PullState
68
+
Submissions []*PullSubmission
69
+
70
+
// stacking
71
+
StackId string // nullable string
72
+
ChangeId string // nullable string
73
+
ParentChangeId string // nullable string
74
+
75
+
// meta
76
+
Created time.Time
77
+
PullSource *PullSource
78
+
79
+
// optionally, populate this when querying for reverse mappings
80
+
Labels LabelState
81
+
Repo *Repo
82
+
}
83
+
84
+
func (p Pull) AsRecord() tangled.RepoPull {
85
+
var source *tangled.RepoPull_Source
86
+
if p.PullSource != nil {
87
+
s := p.PullSource.AsRecord()
88
+
source = &s
89
+
source.Sha = p.LatestSha()
90
+
}
91
+
92
+
record := tangled.RepoPull{
93
+
Title: p.Title,
94
+
Body: &p.Body,
95
+
CreatedAt: p.Created.Format(time.RFC3339),
96
+
Target: &tangled.RepoPull_Target{
97
+
Repo: p.RepoAt.String(),
98
+
Branch: p.TargetBranch,
99
+
},
100
+
Patch: p.LatestPatch(),
101
+
Source: source,
102
+
}
103
+
return record
104
+
}
105
+
106
+
type PullSource struct {
107
+
Branch string
108
+
RepoAt *syntax.ATURI
109
+
110
+
// optionally populate this for reverse mappings
111
+
Repo *Repo
112
+
}
113
+
114
+
func (p PullSource) AsRecord() tangled.RepoPull_Source {
115
+
var repoAt *string
116
+
if p.RepoAt != nil {
117
+
s := p.RepoAt.String()
118
+
repoAt = &s
119
+
}
120
+
record := tangled.RepoPull_Source{
121
+
Branch: p.Branch,
122
+
Repo: repoAt,
123
+
}
124
+
return record
125
+
}
126
+
127
+
type PullSubmission struct {
128
+
// ids
129
+
ID int
130
+
131
+
// at ids
132
+
PullAt syntax.ATURI
133
+
134
+
// content
135
+
RoundNumber int
136
+
Patch string
137
+
Comments []PullComment
138
+
SourceRev string // include the rev that was used to create this submission: only for branch/fork PRs
139
+
140
+
// meta
141
+
Created time.Time
142
+
}
143
+
144
+
type PullComment struct {
145
+
// ids
146
+
ID int
147
+
PullId int
148
+
SubmissionId int
149
+
150
+
// at ids
151
+
RepoAt string
152
+
OwnerDid string
153
+
CommentAt string
154
+
155
+
// content
156
+
Body string
157
+
158
+
// meta
159
+
Created time.Time
160
+
}
161
+
162
+
func (p *Pull) LatestPatch() string {
163
+
latestSubmission := p.Submissions[p.LastRoundNumber()]
164
+
return latestSubmission.Patch
165
+
}
166
+
167
+
func (p *Pull) LatestSha() string {
168
+
latestSubmission := p.Submissions[p.LastRoundNumber()]
169
+
return latestSubmission.SourceRev
170
+
}
171
+
172
+
func (p *Pull) PullAt() syntax.ATURI {
173
+
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.OwnerDid, tangled.RepoPullNSID, p.Rkey))
174
+
}
175
+
176
+
func (p *Pull) LastRoundNumber() int {
177
+
return len(p.Submissions) - 1
178
+
}
179
+
180
+
func (p *Pull) IsPatchBased() bool {
181
+
return p.PullSource == nil
182
+
}
183
+
184
+
func (p *Pull) IsBranchBased() bool {
185
+
if p.PullSource != nil {
186
+
if p.PullSource.RepoAt != nil {
187
+
return p.PullSource.RepoAt == &p.RepoAt
188
+
} else {
189
+
// no repo specified
190
+
return true
191
+
}
192
+
}
193
+
return false
194
+
}
195
+
196
+
func (p *Pull) IsForkBased() bool {
197
+
if p.PullSource != nil {
198
+
if p.PullSource.RepoAt != nil {
199
+
// make sure repos are different
200
+
return p.PullSource.RepoAt != &p.RepoAt
201
+
}
202
+
}
203
+
return false
204
+
}
205
+
206
+
func (p *Pull) IsStacked() bool {
207
+
return p.StackId != ""
208
+
}
209
+
210
+
func (p *Pull) Participants() []string {
211
+
participantSet := make(map[string]struct{})
212
+
participants := []string{}
213
+
214
+
addParticipant := func(did string) {
215
+
if _, exists := participantSet[did]; !exists {
216
+
participantSet[did] = struct{}{}
217
+
participants = append(participants, did)
218
+
}
219
+
}
220
+
221
+
addParticipant(p.OwnerDid)
222
+
223
+
for _, s := range p.Submissions {
224
+
for _, sp := range s.Participants() {
225
+
addParticipant(sp)
226
+
}
227
+
}
228
+
229
+
return participants
230
+
}
231
+
232
+
func (s PullSubmission) IsFormatPatch() bool {
233
+
return patchutil.IsFormatPatch(s.Patch)
234
+
}
235
+
236
+
func (s PullSubmission) AsFormatPatch() []types.FormatPatch {
237
+
patches, err := patchutil.ExtractPatches(s.Patch)
238
+
if err != nil {
239
+
log.Println("error extracting patches from submission:", err)
240
+
return []types.FormatPatch{}
241
+
}
242
+
243
+
return patches
244
+
}
245
+
246
+
func (s *PullSubmission) Participants() []string {
247
+
participantSet := make(map[string]struct{})
248
+
participants := []string{}
249
+
250
+
addParticipant := func(did string) {
251
+
if _, exists := participantSet[did]; !exists {
252
+
participantSet[did] = struct{}{}
253
+
participants = append(participants, did)
254
+
}
255
+
}
256
+
257
+
addParticipant(s.PullAt.Authority().String())
258
+
259
+
for _, c := range s.Comments {
260
+
addParticipant(c.OwnerDid)
261
+
}
262
+
263
+
return participants
264
+
}
265
+
266
+
type Stack []*Pull
267
+
268
+
// position of this pull in the stack
269
+
func (stack Stack) Position(pull *Pull) int {
270
+
return slices.IndexFunc(stack, func(p *Pull) bool {
271
+
return p.ChangeId == pull.ChangeId
272
+
})
273
+
}
274
+
275
+
// all pulls below this pull (including self) in this stack
276
+
//
277
+
// nil if this pull does not belong to this stack
278
+
func (stack Stack) Below(pull *Pull) Stack {
279
+
position := stack.Position(pull)
280
+
281
+
if position < 0 {
282
+
return nil
283
+
}
284
+
285
+
return stack[position:]
286
+
}
287
+
288
+
// all pulls below this pull (excluding self) in this stack
289
+
func (stack Stack) StrictlyBelow(pull *Pull) Stack {
290
+
below := stack.Below(pull)
291
+
292
+
if len(below) > 0 {
293
+
return below[1:]
294
+
}
295
+
296
+
return nil
297
+
}
298
+
299
+
// all pulls above this pull (including self) in this stack
300
+
func (stack Stack) Above(pull *Pull) Stack {
301
+
position := stack.Position(pull)
302
+
303
+
if position < 0 {
304
+
return nil
305
+
}
306
+
307
+
return stack[:position+1]
308
+
}
309
+
310
+
// all pulls below this pull (excluding self) in this stack
311
+
func (stack Stack) StrictlyAbove(pull *Pull) Stack {
312
+
above := stack.Above(pull)
313
+
314
+
if len(above) > 0 {
315
+
return above[:len(above)-1]
316
+
}
317
+
318
+
return nil
319
+
}
320
+
321
+
// the combined format-patches of all the newest submissions in this stack
322
+
func (stack Stack) CombinedPatch() string {
323
+
// go in reverse order because the bottom of the stack is the last element in the slice
324
+
var combined strings.Builder
325
+
for idx := range stack {
326
+
pull := stack[len(stack)-1-idx]
327
+
combined.WriteString(pull.LatestPatch())
328
+
combined.WriteString("\n")
329
+
}
330
+
return combined.String()
331
+
}
332
+
333
+
// filter out PRs that are "active"
334
+
//
335
+
// PRs that are still open are active
336
+
func (stack Stack) Mergeable() Stack {
337
+
var mergeable Stack
338
+
339
+
for _, p := range stack {
340
+
// stop at the first merged PR
341
+
if p.State == PullMerged || p.State == PullClosed {
342
+
break
343
+
}
344
+
345
+
// skip over deleted PRs
346
+
if p.State != PullDeleted {
347
+
mergeable = append(mergeable, p)
348
+
}
349
+
}
350
+
351
+
return mergeable
352
+
}
+14
appview/models/punchcard.go
+14
appview/models/punchcard.go
+57
appview/models/reaction.go
+57
appview/models/reaction.go
···
···
1
+
package models
2
+
3
+
import (
4
+
"time"
5
+
6
+
"github.com/bluesky-social/indigo/atproto/syntax"
7
+
)
8
+
9
+
type ReactionKind string
10
+
11
+
const (
12
+
Like ReactionKind = "👍"
13
+
Unlike ReactionKind = "👎"
14
+
Laugh ReactionKind = "😆"
15
+
Celebration ReactionKind = "🎉"
16
+
Confused ReactionKind = "🫤"
17
+
Heart ReactionKind = "❤️"
18
+
Rocket ReactionKind = "🚀"
19
+
Eyes ReactionKind = "👀"
20
+
)
21
+
22
+
func (rk ReactionKind) String() string {
23
+
return string(rk)
24
+
}
25
+
26
+
var OrderedReactionKinds = []ReactionKind{
27
+
Like,
28
+
Unlike,
29
+
Laugh,
30
+
Celebration,
31
+
Confused,
32
+
Heart,
33
+
Rocket,
34
+
Eyes,
35
+
}
36
+
37
+
func ParseReactionKind(raw string) (ReactionKind, bool) {
38
+
k, ok := (map[string]ReactionKind{
39
+
"👍": Like,
40
+
"👎": Unlike,
41
+
"😆": Laugh,
42
+
"🎉": Celebration,
43
+
"🫤": Confused,
44
+
"❤️": Heart,
45
+
"🚀": Rocket,
46
+
"👀": Eyes,
47
+
})[raw]
48
+
return k, ok
49
+
}
50
+
51
+
type Reaction struct {
52
+
ReactedByDid string
53
+
ThreadAt syntax.ATURI
54
+
Created time.Time
55
+
Rkey string
56
+
Kind ReactionKind
57
+
}
+44
appview/models/registration.go
+44
appview/models/registration.go
···
···
1
+
package models
2
+
3
+
import "time"
4
+
5
+
// Registration represents a knot registration. Knot would've been a better
6
+
// name but we're stuck with this for historical reasons.
7
+
type Registration struct {
8
+
Id int64
9
+
Domain string
10
+
ByDid string
11
+
Created *time.Time
12
+
Registered *time.Time
13
+
NeedsUpgrade bool
14
+
}
15
+
16
+
func (r *Registration) Status() Status {
17
+
if r.NeedsUpgrade {
18
+
return NeedsUpgrade
19
+
} else if r.Registered != nil {
20
+
return Registered
21
+
} else {
22
+
return Pending
23
+
}
24
+
}
25
+
26
+
func (r *Registration) IsRegistered() bool {
27
+
return r.Status() == Registered
28
+
}
29
+
30
+
func (r *Registration) IsNeedsUpgrade() bool {
31
+
return r.Status() == NeedsUpgrade
32
+
}
33
+
34
+
func (r *Registration) IsPending() bool {
35
+
return r.Status() == Pending
36
+
}
37
+
38
+
type Status uint32
39
+
40
+
const (
41
+
Registered Status = iota
42
+
Pending
43
+
NeedsUpgrade
44
+
)
+93
appview/models/repo.go
+93
appview/models/repo.go
···
···
1
+
package models
2
+
3
+
import (
4
+
"fmt"
5
+
"time"
6
+
7
+
"github.com/bluesky-social/indigo/atproto/syntax"
8
+
securejoin "github.com/cyphar/filepath-securejoin"
9
+
"tangled.org/core/api/tangled"
10
+
)
11
+
12
+
type Repo struct {
13
+
Id int64
14
+
Did string
15
+
Name string
16
+
Knot string
17
+
Rkey string
18
+
Created time.Time
19
+
Description string
20
+
Spindle string
21
+
Labels []string
22
+
23
+
// optionally, populate this when querying for reverse mappings
24
+
RepoStats *RepoStats
25
+
26
+
// optional
27
+
Source string
28
+
}
29
+
30
+
func (r *Repo) AsRecord() tangled.Repo {
31
+
var source, spindle, description *string
32
+
33
+
if r.Source != "" {
34
+
source = &r.Source
35
+
}
36
+
37
+
if r.Spindle != "" {
38
+
spindle = &r.Spindle
39
+
}
40
+
41
+
if r.Description != "" {
42
+
description = &r.Description
43
+
}
44
+
45
+
return tangled.Repo{
46
+
Knot: r.Knot,
47
+
Name: r.Name,
48
+
Description: description,
49
+
CreatedAt: r.Created.Format(time.RFC3339),
50
+
Source: source,
51
+
Spindle: spindle,
52
+
Labels: r.Labels,
53
+
}
54
+
}
55
+
56
+
func (r Repo) RepoAt() syntax.ATURI {
57
+
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", r.Did, tangled.RepoNSID, r.Rkey))
58
+
}
59
+
60
+
func (r Repo) DidSlashRepo() string {
61
+
p, _ := securejoin.SecureJoin(r.Did, r.Name)
62
+
return p
63
+
}
64
+
65
+
type RepoStats struct {
66
+
Language string
67
+
StarCount int
68
+
IssueCount IssueCount
69
+
PullCount PullCount
70
+
}
71
+
72
+
type IssueCount struct {
73
+
Open int
74
+
Closed int
75
+
}
76
+
77
+
type PullCount struct {
78
+
Open int
79
+
Merged int
80
+
Closed int
81
+
Deleted int
82
+
}
83
+
84
+
type RepoLabel struct {
85
+
Id int64
86
+
RepoAt syntax.ATURI
87
+
LabelAt syntax.ATURI
88
+
}
89
+
90
+
type RepoGroup struct {
91
+
Repo *Repo
92
+
Issues []Issue
93
+
}
+10
appview/models/signup.go
+10
appview/models/signup.go
+25
appview/models/spindle.go
+25
appview/models/spindle.go
···
···
1
+
package models
2
+
3
+
import (
4
+
"time"
5
+
6
+
"github.com/bluesky-social/indigo/atproto/syntax"
7
+
)
8
+
9
+
type Spindle struct {
10
+
Id int
11
+
Owner syntax.DID
12
+
Instance string
13
+
Verified *time.Time
14
+
Created time.Time
15
+
NeedsUpgrade bool
16
+
}
17
+
18
+
type SpindleMember struct {
19
+
Id int
20
+
Did syntax.DID // owner of the record
21
+
Rkey string // rkey of the record
22
+
Instance string
23
+
Subject syntax.DID // the member being added
24
+
Created time.Time
25
+
}
+17
appview/models/star.go
+17
appview/models/star.go
···
···
1
+
package models
2
+
3
+
import (
4
+
"time"
5
+
6
+
"github.com/bluesky-social/indigo/atproto/syntax"
7
+
)
8
+
9
+
type Star struct {
10
+
StarredByDid string
11
+
RepoAt syntax.ATURI
12
+
Created time.Time
13
+
Rkey string
14
+
15
+
// optionally, populate this when querying for reverse mappings
16
+
Repo *Repo
17
+
}
+95
appview/models/string.go
+95
appview/models/string.go
···
···
1
+
package models
2
+
3
+
import (
4
+
"bytes"
5
+
"fmt"
6
+
"io"
7
+
"strings"
8
+
"time"
9
+
10
+
"github.com/bluesky-social/indigo/atproto/syntax"
11
+
"tangled.org/core/api/tangled"
12
+
)
13
+
14
+
type String struct {
15
+
Did syntax.DID
16
+
Rkey string
17
+
18
+
Filename string
19
+
Description string
20
+
Contents string
21
+
Created time.Time
22
+
Edited *time.Time
23
+
}
24
+
25
+
func (s *String) StringAt() syntax.ATURI {
26
+
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", s.Did, tangled.StringNSID, s.Rkey))
27
+
}
28
+
29
+
func (s *String) AsRecord() tangled.String {
30
+
return tangled.String{
31
+
Filename: s.Filename,
32
+
Description: s.Description,
33
+
Contents: s.Contents,
34
+
CreatedAt: s.Created.Format(time.RFC3339),
35
+
}
36
+
}
37
+
38
+
func StringFromRecord(did, rkey string, record tangled.String) String {
39
+
created, err := time.Parse(record.CreatedAt, time.RFC3339)
40
+
if err != nil {
41
+
created = time.Now()
42
+
}
43
+
return String{
44
+
Did: syntax.DID(did),
45
+
Rkey: rkey,
46
+
Filename: record.Filename,
47
+
Description: record.Description,
48
+
Contents: record.Contents,
49
+
Created: created,
50
+
}
51
+
}
52
+
53
+
type StringStats struct {
54
+
LineCount uint64
55
+
ByteCount uint64
56
+
}
57
+
58
+
func (s String) Stats() StringStats {
59
+
lineCount, err := countLines(strings.NewReader(s.Contents))
60
+
if err != nil {
61
+
// non-fatal
62
+
// TODO: log this?
63
+
}
64
+
65
+
return StringStats{
66
+
LineCount: uint64(lineCount),
67
+
ByteCount: uint64(len(s.Contents)),
68
+
}
69
+
}
70
+
71
+
func countLines(r io.Reader) (int, error) {
72
+
buf := make([]byte, 32*1024)
73
+
bufLen := 0
74
+
count := 0
75
+
nl := []byte{'\n'}
76
+
77
+
for {
78
+
c, err := r.Read(buf)
79
+
if c > 0 {
80
+
bufLen += c
81
+
}
82
+
count += bytes.Count(buf[:c], nl)
83
+
84
+
switch {
85
+
case err == io.EOF:
86
+
/* handle last line not having a newline at the end */
87
+
if bufLen >= 1 && buf[(bufLen-1)%(32*1024)] != '\n' {
88
+
count++
89
+
}
90
+
return count, nil
91
+
case err != nil:
92
+
return 0, err
93
+
}
94
+
}
95
+
}
+23
appview/models/timeline.go
+23
appview/models/timeline.go
···
···
1
+
package models
2
+
3
+
import "time"
4
+
5
+
type TimelineEvent struct {
6
+
*Repo
7
+
*Follow
8
+
*Star
9
+
10
+
EventAt time.Time
11
+
12
+
// optional: populate only if Repo is a fork
13
+
Source *Repo
14
+
15
+
// optional: populate only if event is Follow
16
+
*Profile
17
+
*FollowStats
18
+
*FollowStatus
19
+
20
+
// optional: populate only if event is Repo
21
+
IsStarred bool
22
+
StarCount int64
23
+
}
+168
appview/notifications/notifications.go
+168
appview/notifications/notifications.go
···
···
1
+
package notifications
2
+
3
+
import (
4
+
"fmt"
5
+
"log"
6
+
"net/http"
7
+
"strconv"
8
+
9
+
"github.com/go-chi/chi/v5"
10
+
"tangled.org/core/appview/db"
11
+
"tangled.org/core/appview/middleware"
12
+
"tangled.org/core/appview/oauth"
13
+
"tangled.org/core/appview/pages"
14
+
"tangled.org/core/appview/pagination"
15
+
)
16
+
17
+
type Notifications struct {
18
+
db *db.DB
19
+
oauth *oauth.OAuth
20
+
pages *pages.Pages
21
+
}
22
+
23
+
func New(database *db.DB, oauthHandler *oauth.OAuth, pagesHandler *pages.Pages) *Notifications {
24
+
return &Notifications{
25
+
db: database,
26
+
oauth: oauthHandler,
27
+
pages: pagesHandler,
28
+
}
29
+
}
30
+
31
+
func (n *Notifications) Router(mw *middleware.Middleware) http.Handler {
32
+
r := chi.NewRouter()
33
+
34
+
r.Use(middleware.AuthMiddleware(n.oauth))
35
+
36
+
r.With(middleware.Paginate).Get("/", n.notificationsPage)
37
+
38
+
r.Get("/count", n.getUnreadCount)
39
+
r.Post("/{id}/read", n.markRead)
40
+
r.Post("/read-all", n.markAllRead)
41
+
r.Delete("/{id}", n.deleteNotification)
42
+
43
+
return r
44
+
}
45
+
46
+
func (n *Notifications) notificationsPage(w http.ResponseWriter, r *http.Request) {
47
+
userDid := n.oauth.GetDid(r)
48
+
49
+
page, ok := r.Context().Value("page").(pagination.Page)
50
+
if !ok {
51
+
log.Println("failed to get page")
52
+
page = pagination.FirstPage()
53
+
}
54
+
55
+
total, err := db.CountNotifications(
56
+
n.db,
57
+
db.FilterEq("recipient_did", userDid),
58
+
)
59
+
if err != nil {
60
+
log.Println("failed to get total notifications:", err)
61
+
n.pages.Error500(w)
62
+
return
63
+
}
64
+
65
+
notifications, err := db.GetNotificationsWithEntities(
66
+
n.db,
67
+
page,
68
+
db.FilterEq("recipient_did", userDid),
69
+
)
70
+
if err != nil {
71
+
log.Println("failed to get notifications:", err)
72
+
n.pages.Error500(w)
73
+
return
74
+
}
75
+
76
+
err = n.db.MarkAllNotificationsRead(r.Context(), userDid)
77
+
if err != nil {
78
+
log.Println("failed to mark notifications as read:", err)
79
+
}
80
+
81
+
unreadCount := 0
82
+
83
+
user := n.oauth.GetUser(r)
84
+
if user == nil {
85
+
http.Error(w, "Failed to get user", http.StatusInternalServerError)
86
+
return
87
+
}
88
+
89
+
fmt.Println(n.pages.Notifications(w, pages.NotificationsParams{
90
+
LoggedInUser: user,
91
+
Notifications: notifications,
92
+
UnreadCount: unreadCount,
93
+
Page: page,
94
+
Total: total,
95
+
}))
96
+
}
97
+
98
+
func (n *Notifications) getUnreadCount(w http.ResponseWriter, r *http.Request) {
99
+
user := n.oauth.GetUser(r)
100
+
count, err := db.CountNotifications(
101
+
n.db,
102
+
db.FilterEq("recipient_did", user.Did),
103
+
db.FilterEq("read", 0),
104
+
)
105
+
if err != nil {
106
+
http.Error(w, "Failed to get unread count", http.StatusInternalServerError)
107
+
return
108
+
}
109
+
110
+
params := pages.NotificationCountParams{
111
+
Count: count,
112
+
}
113
+
err = n.pages.NotificationCount(w, params)
114
+
if err != nil {
115
+
http.Error(w, "Failed to render count", http.StatusInternalServerError)
116
+
return
117
+
}
118
+
}
119
+
120
+
func (n *Notifications) markRead(w http.ResponseWriter, r *http.Request) {
121
+
userDid := n.oauth.GetDid(r)
122
+
123
+
idStr := chi.URLParam(r, "id")
124
+
notificationID, err := strconv.ParseInt(idStr, 10, 64)
125
+
if err != nil {
126
+
http.Error(w, "Invalid notification ID", http.StatusBadRequest)
127
+
return
128
+
}
129
+
130
+
err = n.db.MarkNotificationRead(r.Context(), notificationID, userDid)
131
+
if err != nil {
132
+
http.Error(w, "Failed to mark notification as read", http.StatusInternalServerError)
133
+
return
134
+
}
135
+
136
+
w.WriteHeader(http.StatusNoContent)
137
+
}
138
+
139
+
func (n *Notifications) markAllRead(w http.ResponseWriter, r *http.Request) {
140
+
userDid := n.oauth.GetDid(r)
141
+
142
+
err := n.db.MarkAllNotificationsRead(r.Context(), userDid)
143
+
if err != nil {
144
+
http.Error(w, "Failed to mark all notifications as read", http.StatusInternalServerError)
145
+
return
146
+
}
147
+
148
+
http.Redirect(w, r, "/notifications", http.StatusSeeOther)
149
+
}
150
+
151
+
func (n *Notifications) deleteNotification(w http.ResponseWriter, r *http.Request) {
152
+
userDid := n.oauth.GetDid(r)
153
+
154
+
idStr := chi.URLParam(r, "id")
155
+
notificationID, err := strconv.ParseInt(idStr, 10, 64)
156
+
if err != nil {
157
+
http.Error(w, "Invalid notification ID", http.StatusBadRequest)
158
+
return
159
+
}
160
+
161
+
err = n.db.DeleteNotification(r.Context(), notificationID, userDid)
162
+
if err != nil {
163
+
http.Error(w, "Failed to delete notification", http.StatusInternalServerError)
164
+
return
165
+
}
166
+
167
+
w.WriteHeader(http.StatusOK)
168
+
}
+429
appview/notify/db/db.go
+429
appview/notify/db/db.go
···
···
1
+
package db
2
+
3
+
import (
4
+
"context"
5
+
"log"
6
+
7
+
"tangled.org/core/appview/db"
8
+
"tangled.org/core/appview/models"
9
+
"tangled.org/core/appview/notify"
10
+
"tangled.org/core/idresolver"
11
+
)
12
+
13
+
type databaseNotifier struct {
14
+
db *db.DB
15
+
res *idresolver.Resolver
16
+
}
17
+
18
+
func NewDatabaseNotifier(database *db.DB, resolver *idresolver.Resolver) notify.Notifier {
19
+
return &databaseNotifier{
20
+
db: database,
21
+
res: resolver,
22
+
}
23
+
}
24
+
25
+
var _ notify.Notifier = &databaseNotifier{}
26
+
27
+
func (n *databaseNotifier) NewRepo(ctx context.Context, repo *models.Repo) {
28
+
// no-op for now
29
+
}
30
+
31
+
func (n *databaseNotifier) NewStar(ctx context.Context, star *models.Star) {
32
+
var err error
33
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(star.RepoAt)))
34
+
if err != nil {
35
+
log.Printf("NewStar: failed to get repos: %v", err)
36
+
return
37
+
}
38
+
39
+
// don't notify yourself
40
+
if repo.Did == star.StarredByDid {
41
+
return
42
+
}
43
+
44
+
// check if user wants these notifications
45
+
prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did)
46
+
if err != nil {
47
+
log.Printf("NewStar: failed to get notification preferences for %s: %v", repo.Did, err)
48
+
return
49
+
}
50
+
if !prefs.RepoStarred {
51
+
return
52
+
}
53
+
54
+
notification := &models.Notification{
55
+
RecipientDid: repo.Did,
56
+
ActorDid: star.StarredByDid,
57
+
Type: models.NotificationTypeRepoStarred,
58
+
EntityType: "repo",
59
+
EntityId: string(star.RepoAt),
60
+
RepoId: &repo.Id,
61
+
}
62
+
err = n.db.CreateNotification(ctx, notification)
63
+
if err != nil {
64
+
log.Printf("NewStar: failed to create notification: %v", err)
65
+
return
66
+
}
67
+
}
68
+
69
+
func (n *databaseNotifier) DeleteStar(ctx context.Context, star *models.Star) {
70
+
// no-op
71
+
}
72
+
73
+
func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue) {
74
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt)))
75
+
if err != nil {
76
+
log.Printf("NewIssue: failed to get repos: %v", err)
77
+
return
78
+
}
79
+
80
+
if repo.Did == issue.Did {
81
+
return
82
+
}
83
+
84
+
prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did)
85
+
if err != nil {
86
+
log.Printf("NewIssue: failed to get notification preferences for %s: %v", repo.Did, err)
87
+
return
88
+
}
89
+
if !prefs.IssueCreated {
90
+
return
91
+
}
92
+
93
+
notification := &models.Notification{
94
+
RecipientDid: repo.Did,
95
+
ActorDid: issue.Did,
96
+
Type: models.NotificationTypeIssueCreated,
97
+
EntityType: "issue",
98
+
EntityId: string(issue.AtUri()),
99
+
RepoId: &repo.Id,
100
+
IssueId: &issue.Id,
101
+
}
102
+
103
+
err = n.db.CreateNotification(ctx, notification)
104
+
if err != nil {
105
+
log.Printf("NewIssue: failed to create notification: %v", err)
106
+
return
107
+
}
108
+
}
109
+
110
+
func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) {
111
+
issues, err := db.GetIssues(n.db, db.FilterEq("at_uri", comment.IssueAt))
112
+
if err != nil {
113
+
log.Printf("NewIssueComment: failed to get issues: %v", err)
114
+
return
115
+
}
116
+
if len(issues) == 0 {
117
+
log.Printf("NewIssueComment: no issue found for %s", comment.IssueAt)
118
+
return
119
+
}
120
+
issue := issues[0]
121
+
122
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt)))
123
+
if err != nil {
124
+
log.Printf("NewIssueComment: failed to get repos: %v", err)
125
+
return
126
+
}
127
+
128
+
recipients := make(map[string]bool)
129
+
130
+
// notify issue author (if not the commenter)
131
+
if issue.Did != comment.Did {
132
+
prefs, err := n.db.GetNotificationPreferences(ctx, issue.Did)
133
+
if err == nil && prefs.IssueCommented {
134
+
recipients[issue.Did] = true
135
+
} else if err != nil {
136
+
log.Printf("NewIssueComment: failed to get preferences for issue author %s: %v", issue.Did, err)
137
+
}
138
+
}
139
+
140
+
// notify repo owner (if not the commenter and not already added)
141
+
if repo.Did != comment.Did && repo.Did != issue.Did {
142
+
prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did)
143
+
if err == nil && prefs.IssueCommented {
144
+
recipients[repo.Did] = true
145
+
} else if err != nil {
146
+
log.Printf("NewIssueComment: failed to get preferences for repo owner %s: %v", repo.Did, err)
147
+
}
148
+
}
149
+
150
+
// create notifications for all recipients
151
+
for recipientDid := range recipients {
152
+
notification := &models.Notification{
153
+
RecipientDid: recipientDid,
154
+
ActorDid: comment.Did,
155
+
Type: models.NotificationTypeIssueCommented,
156
+
EntityType: "issue",
157
+
EntityId: string(issue.AtUri()),
158
+
RepoId: &repo.Id,
159
+
IssueId: &issue.Id,
160
+
}
161
+
162
+
err = n.db.CreateNotification(ctx, notification)
163
+
if err != nil {
164
+
log.Printf("NewIssueComment: failed to create notification for %s: %v", recipientDid, err)
165
+
}
166
+
}
167
+
}
168
+
169
+
func (n *databaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) {
170
+
prefs, err := n.db.GetNotificationPreferences(ctx, follow.SubjectDid)
171
+
if err != nil {
172
+
log.Printf("NewFollow: failed to get notification preferences for %s: %v", follow.SubjectDid, err)
173
+
return
174
+
}
175
+
if !prefs.Followed {
176
+
return
177
+
}
178
+
179
+
notification := &models.Notification{
180
+
RecipientDid: follow.SubjectDid,
181
+
ActorDid: follow.UserDid,
182
+
Type: models.NotificationTypeFollowed,
183
+
EntityType: "follow",
184
+
EntityId: follow.UserDid,
185
+
}
186
+
187
+
err = n.db.CreateNotification(ctx, notification)
188
+
if err != nil {
189
+
log.Printf("NewFollow: failed to create notification: %v", err)
190
+
return
191
+
}
192
+
}
193
+
194
+
func (n *databaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {
195
+
// no-op
196
+
}
197
+
198
+
func (n *databaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {
199
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt)))
200
+
if err != nil {
201
+
log.Printf("NewPull: failed to get repos: %v", err)
202
+
return
203
+
}
204
+
205
+
if repo.Did == pull.OwnerDid {
206
+
return
207
+
}
208
+
209
+
prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did)
210
+
if err != nil {
211
+
log.Printf("NewPull: failed to get notification preferences for %s: %v", repo.Did, err)
212
+
return
213
+
}
214
+
if !prefs.PullCreated {
215
+
return
216
+
}
217
+
218
+
notification := &models.Notification{
219
+
RecipientDid: repo.Did,
220
+
ActorDid: pull.OwnerDid,
221
+
Type: models.NotificationTypePullCreated,
222
+
EntityType: "pull",
223
+
EntityId: string(pull.RepoAt),
224
+
RepoId: &repo.Id,
225
+
PullId: func() *int64 { id := int64(pull.ID); return &id }(),
226
+
}
227
+
228
+
err = n.db.CreateNotification(ctx, notification)
229
+
if err != nil {
230
+
log.Printf("NewPull: failed to create notification: %v", err)
231
+
return
232
+
}
233
+
}
234
+
235
+
func (n *databaseNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) {
236
+
pulls, err := db.GetPulls(n.db,
237
+
db.FilterEq("repo_at", comment.RepoAt),
238
+
db.FilterEq("pull_id", comment.PullId))
239
+
if err != nil {
240
+
log.Printf("NewPullComment: failed to get pulls: %v", err)
241
+
return
242
+
}
243
+
if len(pulls) == 0 {
244
+
log.Printf("NewPullComment: no pull found for %s PR %d", comment.RepoAt, comment.PullId)
245
+
return
246
+
}
247
+
pull := pulls[0]
248
+
249
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", comment.RepoAt))
250
+
if err != nil {
251
+
log.Printf("NewPullComment: failed to get repos: %v", err)
252
+
return
253
+
}
254
+
255
+
recipients := make(map[string]bool)
256
+
257
+
// notify pull request author (if not the commenter)
258
+
if pull.OwnerDid != comment.OwnerDid {
259
+
prefs, err := n.db.GetNotificationPreferences(ctx, pull.OwnerDid)
260
+
if err == nil && prefs.PullCommented {
261
+
recipients[pull.OwnerDid] = true
262
+
} else if err != nil {
263
+
log.Printf("NewPullComment: failed to get preferences for pull author %s: %v", pull.OwnerDid, err)
264
+
}
265
+
}
266
+
267
+
// notify repo owner (if not the commenter and not already added)
268
+
if repo.Did != comment.OwnerDid && repo.Did != pull.OwnerDid {
269
+
prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did)
270
+
if err == nil && prefs.PullCommented {
271
+
recipients[repo.Did] = true
272
+
} else if err != nil {
273
+
log.Printf("NewPullComment: failed to get preferences for repo owner %s: %v", repo.Did, err)
274
+
}
275
+
}
276
+
277
+
for recipientDid := range recipients {
278
+
notification := &models.Notification{
279
+
RecipientDid: recipientDid,
280
+
ActorDid: comment.OwnerDid,
281
+
Type: models.NotificationTypePullCommented,
282
+
EntityType: "pull",
283
+
EntityId: comment.RepoAt,
284
+
RepoId: &repo.Id,
285
+
PullId: func() *int64 { id := int64(pull.ID); return &id }(),
286
+
}
287
+
288
+
err = n.db.CreateNotification(ctx, notification)
289
+
if err != nil {
290
+
log.Printf("NewPullComment: failed to create notification for %s: %v", recipientDid, err)
291
+
}
292
+
}
293
+
}
294
+
295
+
func (n *databaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {
296
+
// no-op
297
+
}
298
+
299
+
func (n *databaseNotifier) DeleteString(ctx context.Context, did, rkey string) {
300
+
// no-op
301
+
}
302
+
303
+
func (n *databaseNotifier) EditString(ctx context.Context, string *models.String) {
304
+
// no-op
305
+
}
306
+
307
+
func (n *databaseNotifier) NewString(ctx context.Context, string *models.String) {
308
+
// no-op
309
+
}
310
+
311
+
func (n *databaseNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) {
312
+
// Get repo details
313
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt)))
314
+
if err != nil {
315
+
log.Printf("NewIssueClosed: failed to get repos: %v", err)
316
+
return
317
+
}
318
+
319
+
// Don't notify yourself
320
+
if repo.Did == issue.Did {
321
+
return
322
+
}
323
+
324
+
// Check if user wants these notifications
325
+
prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did)
326
+
if err != nil {
327
+
log.Printf("NewIssueClosed: failed to get notification preferences for %s: %v", repo.Did, err)
328
+
return
329
+
}
330
+
if !prefs.IssueClosed {
331
+
return
332
+
}
333
+
334
+
notification := &models.Notification{
335
+
RecipientDid: repo.Did,
336
+
ActorDid: issue.Did,
337
+
Type: models.NotificationTypeIssueClosed,
338
+
EntityType: "issue",
339
+
EntityId: string(issue.AtUri()),
340
+
RepoId: &repo.Id,
341
+
IssueId: &issue.Id,
342
+
}
343
+
344
+
err = n.db.CreateNotification(ctx, notification)
345
+
if err != nil {
346
+
log.Printf("NewIssueClosed: failed to create notification: %v", err)
347
+
return
348
+
}
349
+
}
350
+
351
+
func (n *databaseNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) {
352
+
// Get repo details
353
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt)))
354
+
if err != nil {
355
+
log.Printf("NewPullMerged: failed to get repos: %v", err)
356
+
return
357
+
}
358
+
359
+
// Don't notify yourself
360
+
if repo.Did == pull.OwnerDid {
361
+
return
362
+
}
363
+
364
+
// Check if user wants these notifications
365
+
prefs, err := n.db.GetNotificationPreferences(ctx, pull.OwnerDid)
366
+
if err != nil {
367
+
log.Printf("NewPullMerged: failed to get notification preferences for %s: %v", pull.OwnerDid, err)
368
+
return
369
+
}
370
+
if !prefs.PullMerged {
371
+
return
372
+
}
373
+
374
+
notification := &models.Notification{
375
+
RecipientDid: pull.OwnerDid,
376
+
ActorDid: repo.Did,
377
+
Type: models.NotificationTypePullMerged,
378
+
EntityType: "pull",
379
+
EntityId: string(pull.RepoAt),
380
+
RepoId: &repo.Id,
381
+
PullId: func() *int64 { id := int64(pull.ID); return &id }(),
382
+
}
383
+
384
+
err = n.db.CreateNotification(ctx, notification)
385
+
if err != nil {
386
+
log.Printf("NewPullMerged: failed to create notification: %v", err)
387
+
return
388
+
}
389
+
}
390
+
391
+
func (n *databaseNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) {
392
+
// Get repo details
393
+
repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt)))
394
+
if err != nil {
395
+
log.Printf("NewPullClosed: failed to get repos: %v", err)
396
+
return
397
+
}
398
+
399
+
// Don't notify yourself
400
+
if repo.Did == pull.OwnerDid {
401
+
return
402
+
}
403
+
404
+
// Check if user wants these notifications - reuse pull_merged preference for now
405
+
prefs, err := n.db.GetNotificationPreferences(ctx, pull.OwnerDid)
406
+
if err != nil {
407
+
log.Printf("NewPullClosed: failed to get notification preferences for %s: %v", pull.OwnerDid, err)
408
+
return
409
+
}
410
+
if !prefs.PullMerged {
411
+
return
412
+
}
413
+
414
+
notification := &models.Notification{
415
+
RecipientDid: pull.OwnerDid,
416
+
ActorDid: repo.Did,
417
+
Type: models.NotificationTypePullClosed,
418
+
EntityType: "pull",
419
+
EntityId: string(pull.RepoAt),
420
+
RepoId: &repo.Id,
421
+
PullId: func() *int64 { id := int64(pull.ID); return &id }(),
422
+
}
423
+
424
+
err = n.db.CreateNotification(ctx, notification)
425
+
if err != nil {
426
+
log.Printf("NewPullClosed: failed to create notification: %v", err)
427
+
return
428
+
}
429
+
}
+51
-10
appview/notify/merged_notifier.go
+51
-10
appview/notify/merged_notifier.go
···
3
import (
4
"context"
5
6
-
"tangled.sh/tangled.sh/core/appview/db"
7
)
8
9
type mergedNotifier struct {
···
16
17
var _ Notifier = &mergedNotifier{}
18
19
-
func (m *mergedNotifier) NewRepo(ctx context.Context, repo *db.Repo) {
20
for _, notifier := range m.notifiers {
21
notifier.NewRepo(ctx, repo)
22
}
23
}
24
25
-
func (m *mergedNotifier) NewStar(ctx context.Context, star *db.Star) {
26
for _, notifier := range m.notifiers {
27
notifier.NewStar(ctx, star)
28
}
29
}
30
-
func (m *mergedNotifier) DeleteStar(ctx context.Context, star *db.Star) {
31
for _, notifier := range m.notifiers {
32
notifier.DeleteStar(ctx, star)
33
}
34
}
35
36
-
func (m *mergedNotifier) NewIssue(ctx context.Context, issue *db.Issue) {
37
for _, notifier := range m.notifiers {
38
notifier.NewIssue(ctx, issue)
39
}
40
}
41
42
-
func (m *mergedNotifier) NewFollow(ctx context.Context, follow *db.Follow) {
43
for _, notifier := range m.notifiers {
44
notifier.NewFollow(ctx, follow)
45
}
46
}
47
-
func (m *mergedNotifier) DeleteFollow(ctx context.Context, follow *db.Follow) {
48
for _, notifier := range m.notifiers {
49
notifier.DeleteFollow(ctx, follow)
50
}
51
}
52
53
-
func (m *mergedNotifier) NewPull(ctx context.Context, pull *db.Pull) {
54
for _, notifier := range m.notifiers {
55
notifier.NewPull(ctx, pull)
56
}
57
}
58
-
func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *db.PullComment) {
59
for _, notifier := range m.notifiers {
60
notifier.NewPullComment(ctx, comment)
61
}
62
}
63
64
-
func (m *mergedNotifier) UpdateProfile(ctx context.Context, profile *db.Profile) {
65
for _, notifier := range m.notifiers {
66
notifier.UpdateProfile(ctx, profile)
67
}
68
}
···
3
import (
4
"context"
5
6
+
"tangled.org/core/appview/models"
7
)
8
9
type mergedNotifier struct {
···
16
17
var _ Notifier = &mergedNotifier{}
18
19
+
func (m *mergedNotifier) NewRepo(ctx context.Context, repo *models.Repo) {
20
for _, notifier := range m.notifiers {
21
notifier.NewRepo(ctx, repo)
22
}
23
}
24
25
+
func (m *mergedNotifier) NewStar(ctx context.Context, star *models.Star) {
26
for _, notifier := range m.notifiers {
27
notifier.NewStar(ctx, star)
28
}
29
}
30
+
func (m *mergedNotifier) DeleteStar(ctx context.Context, star *models.Star) {
31
for _, notifier := range m.notifiers {
32
notifier.DeleteStar(ctx, star)
33
}
34
}
35
36
+
func (m *mergedNotifier) NewIssue(ctx context.Context, issue *models.Issue) {
37
for _, notifier := range m.notifiers {
38
notifier.NewIssue(ctx, issue)
39
}
40
}
41
+
func (m *mergedNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) {
42
+
for _, notifier := range m.notifiers {
43
+
notifier.NewIssueComment(ctx, comment)
44
+
}
45
+
}
46
47
+
func (m *mergedNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) {
48
+
for _, notifier := range m.notifiers {
49
+
notifier.NewIssueClosed(ctx, issue)
50
+
}
51
+
}
52
+
53
+
func (m *mergedNotifier) NewFollow(ctx context.Context, follow *models.Follow) {
54
for _, notifier := range m.notifiers {
55
notifier.NewFollow(ctx, follow)
56
}
57
}
58
+
func (m *mergedNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {
59
for _, notifier := range m.notifiers {
60
notifier.DeleteFollow(ctx, follow)
61
}
62
}
63
64
+
func (m *mergedNotifier) NewPull(ctx context.Context, pull *models.Pull) {
65
for _, notifier := range m.notifiers {
66
notifier.NewPull(ctx, pull)
67
}
68
}
69
+
func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) {
70
for _, notifier := range m.notifiers {
71
notifier.NewPullComment(ctx, comment)
72
}
73
}
74
75
+
func (m *mergedNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) {
76
+
for _, notifier := range m.notifiers {
77
+
notifier.NewPullMerged(ctx, pull)
78
+
}
79
+
}
80
+
81
+
func (m *mergedNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) {
82
+
for _, notifier := range m.notifiers {
83
+
notifier.NewPullClosed(ctx, pull)
84
+
}
85
+
}
86
+
87
+
func (m *mergedNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {
88
for _, notifier := range m.notifiers {
89
notifier.UpdateProfile(ctx, profile)
90
}
91
}
92
+
93
+
func (m *mergedNotifier) NewString(ctx context.Context, string *models.String) {
94
+
for _, notifier := range m.notifiers {
95
+
notifier.NewString(ctx, string)
96
+
}
97
+
}
98
+
99
+
func (m *mergedNotifier) EditString(ctx context.Context, string *models.String) {
100
+
for _, notifier := range m.notifiers {
101
+
notifier.EditString(ctx, string)
102
+
}
103
+
}
104
+
105
+
func (m *mergedNotifier) DeleteString(ctx context.Context, did, rkey string) {
106
+
for _, notifier := range m.notifiers {
107
+
notifier.DeleteString(ctx, did, rkey)
108
+
}
109
+
}
+35
-19
appview/notify/notifier.go
+35
-19
appview/notify/notifier.go
···
3
import (
4
"context"
5
6
-
"tangled.sh/tangled.sh/core/appview/db"
7
)
8
9
type Notifier interface {
10
-
NewRepo(ctx context.Context, repo *db.Repo)
11
12
-
NewStar(ctx context.Context, star *db.Star)
13
-
DeleteStar(ctx context.Context, star *db.Star)
14
15
-
NewIssue(ctx context.Context, issue *db.Issue)
16
17
-
NewFollow(ctx context.Context, follow *db.Follow)
18
-
DeleteFollow(ctx context.Context, follow *db.Follow)
19
20
-
NewPull(ctx context.Context, pull *db.Pull)
21
-
NewPullComment(ctx context.Context, comment *db.PullComment)
22
23
-
UpdateProfile(ctx context.Context, profile *db.Profile)
24
}
25
26
// BaseNotifier is a listener that does nothing
···
28
29
var _ Notifier = &BaseNotifier{}
30
31
-
func (m *BaseNotifier) NewRepo(ctx context.Context, repo *db.Repo) {}
32
33
-
func (m *BaseNotifier) NewStar(ctx context.Context, star *db.Star) {}
34
-
func (m *BaseNotifier) DeleteStar(ctx context.Context, star *db.Star) {}
35
36
-
func (m *BaseNotifier) NewIssue(ctx context.Context, issue *db.Issue) {}
37
38
-
func (m *BaseNotifier) NewFollow(ctx context.Context, follow *db.Follow) {}
39
-
func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *db.Follow) {}
40
41
-
func (m *BaseNotifier) NewPull(ctx context.Context, pull *db.Pull) {}
42
-
func (m *BaseNotifier) NewPullComment(ctx context.Context, comment *db.PullComment) {}
43
44
-
func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *db.Profile) {}
···
3
import (
4
"context"
5
6
+
"tangled.org/core/appview/models"
7
)
8
9
type Notifier interface {
10
+
NewRepo(ctx context.Context, repo *models.Repo)
11
12
+
NewStar(ctx context.Context, star *models.Star)
13
+
DeleteStar(ctx context.Context, star *models.Star)
14
15
+
NewIssue(ctx context.Context, issue *models.Issue)
16
+
NewIssueComment(ctx context.Context, comment *models.IssueComment)
17
+
NewIssueClosed(ctx context.Context, issue *models.Issue)
18
+
19
+
NewFollow(ctx context.Context, follow *models.Follow)
20
+
DeleteFollow(ctx context.Context, follow *models.Follow)
21
22
+
NewPull(ctx context.Context, pull *models.Pull)
23
+
NewPullComment(ctx context.Context, comment *models.PullComment)
24
+
NewPullMerged(ctx context.Context, pull *models.Pull)
25
+
NewPullClosed(ctx context.Context, pull *models.Pull)
26
27
+
UpdateProfile(ctx context.Context, profile *models.Profile)
28
29
+
NewString(ctx context.Context, s *models.String)
30
+
EditString(ctx context.Context, s *models.String)
31
+
DeleteString(ctx context.Context, did, rkey string)
32
}
33
34
// BaseNotifier is a listener that does nothing
···
36
37
var _ Notifier = &BaseNotifier{}
38
39
+
func (m *BaseNotifier) NewRepo(ctx context.Context, repo *models.Repo) {}
40
41
+
func (m *BaseNotifier) NewStar(ctx context.Context, star *models.Star) {}
42
+
func (m *BaseNotifier) DeleteStar(ctx context.Context, star *models.Star) {}
43
44
+
func (m *BaseNotifier) NewIssue(ctx context.Context, issue *models.Issue) {}
45
+
func (m *BaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) {}
46
+
func (m *BaseNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) {}
47
+
48
+
func (m *BaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) {}
49
+
func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {}
50
51
+
func (m *BaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {}
52
+
func (m *BaseNotifier) NewPullComment(ctx context.Context, models *models.PullComment) {}
53
+
func (m *BaseNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) {}
54
+
func (m *BaseNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) {}
55
56
+
func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {}
57
58
+
func (m *BaseNotifier) NewString(ctx context.Context, s *models.String) {}
59
+
func (m *BaseNotifier) EditString(ctx context.Context, s *models.String) {}
60
+
func (m *BaseNotifier) DeleteString(ctx context.Context, did, rkey string) {}
+219
appview/notify/posthog/notifier.go
+219
appview/notify/posthog/notifier.go
···
···
1
+
package posthog
2
+
3
+
import (
4
+
"context"
5
+
"log"
6
+
7
+
"github.com/posthog/posthog-go"
8
+
"tangled.org/core/appview/models"
9
+
"tangled.org/core/appview/notify"
10
+
)
11
+
12
+
type posthogNotifier struct {
13
+
client posthog.Client
14
+
notify.BaseNotifier
15
+
}
16
+
17
+
func NewPosthogNotifier(client posthog.Client) notify.Notifier {
18
+
return &posthogNotifier{
19
+
client,
20
+
notify.BaseNotifier{},
21
+
}
22
+
}
23
+
24
+
var _ notify.Notifier = &posthogNotifier{}
25
+
26
+
func (n *posthogNotifier) NewRepo(ctx context.Context, repo *models.Repo) {
27
+
err := n.client.Enqueue(posthog.Capture{
28
+
DistinctId: repo.Did,
29
+
Event: "new_repo",
30
+
Properties: posthog.Properties{"repo": repo.Name, "repo_at": repo.RepoAt()},
31
+
})
32
+
if err != nil {
33
+
log.Println("failed to enqueue posthog event:", err)
34
+
}
35
+
}
36
+
37
+
func (n *posthogNotifier) NewStar(ctx context.Context, star *models.Star) {
38
+
err := n.client.Enqueue(posthog.Capture{
39
+
DistinctId: star.StarredByDid,
40
+
Event: "star",
41
+
Properties: posthog.Properties{"repo_at": star.RepoAt.String()},
42
+
})
43
+
if err != nil {
44
+
log.Println("failed to enqueue posthog event:", err)
45
+
}
46
+
}
47
+
48
+
func (n *posthogNotifier) DeleteStar(ctx context.Context, star *models.Star) {
49
+
err := n.client.Enqueue(posthog.Capture{
50
+
DistinctId: star.StarredByDid,
51
+
Event: "unstar",
52
+
Properties: posthog.Properties{"repo_at": star.RepoAt.String()},
53
+
})
54
+
if err != nil {
55
+
log.Println("failed to enqueue posthog event:", err)
56
+
}
57
+
}
58
+
59
+
func (n *posthogNotifier) NewIssue(ctx context.Context, issue *models.Issue) {
60
+
err := n.client.Enqueue(posthog.Capture{
61
+
DistinctId: issue.Did,
62
+
Event: "new_issue",
63
+
Properties: posthog.Properties{
64
+
"repo_at": issue.RepoAt.String(),
65
+
"issue_id": issue.IssueId,
66
+
},
67
+
})
68
+
if err != nil {
69
+
log.Println("failed to enqueue posthog event:", err)
70
+
}
71
+
}
72
+
73
+
func (n *posthogNotifier) NewPull(ctx context.Context, pull *models.Pull) {
74
+
err := n.client.Enqueue(posthog.Capture{
75
+
DistinctId: pull.OwnerDid,
76
+
Event: "new_pull",
77
+
Properties: posthog.Properties{
78
+
"repo_at": pull.RepoAt,
79
+
"pull_id": pull.PullId,
80
+
},
81
+
})
82
+
if err != nil {
83
+
log.Println("failed to enqueue posthog event:", err)
84
+
}
85
+
}
86
+
87
+
func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) {
88
+
err := n.client.Enqueue(posthog.Capture{
89
+
DistinctId: comment.OwnerDid,
90
+
Event: "new_pull_comment",
91
+
Properties: posthog.Properties{
92
+
"repo_at": comment.RepoAt,
93
+
"pull_id": comment.PullId,
94
+
},
95
+
})
96
+
if err != nil {
97
+
log.Println("failed to enqueue posthog event:", err)
98
+
}
99
+
}
100
+
101
+
func (n *posthogNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) {
102
+
err := n.client.Enqueue(posthog.Capture{
103
+
DistinctId: pull.OwnerDid,
104
+
Event: "pull_closed",
105
+
Properties: posthog.Properties{
106
+
"repo_at": pull.RepoAt,
107
+
"pull_id": pull.PullId,
108
+
},
109
+
})
110
+
if err != nil {
111
+
log.Println("failed to enqueue posthog event:", err)
112
+
}
113
+
}
114
+
115
+
func (n *posthogNotifier) NewFollow(ctx context.Context, follow *models.Follow) {
116
+
err := n.client.Enqueue(posthog.Capture{
117
+
DistinctId: follow.UserDid,
118
+
Event: "follow",
119
+
Properties: posthog.Properties{"subject": follow.SubjectDid},
120
+
})
121
+
if err != nil {
122
+
log.Println("failed to enqueue posthog event:", err)
123
+
}
124
+
}
125
+
126
+
func (n *posthogNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {
127
+
err := n.client.Enqueue(posthog.Capture{
128
+
DistinctId: follow.UserDid,
129
+
Event: "unfollow",
130
+
Properties: posthog.Properties{"subject": follow.SubjectDid},
131
+
})
132
+
if err != nil {
133
+
log.Println("failed to enqueue posthog event:", err)
134
+
}
135
+
}
136
+
137
+
func (n *posthogNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {
138
+
err := n.client.Enqueue(posthog.Capture{
139
+
DistinctId: profile.Did,
140
+
Event: "edit_profile",
141
+
})
142
+
if err != nil {
143
+
log.Println("failed to enqueue posthog event:", err)
144
+
}
145
+
}
146
+
147
+
func (n *posthogNotifier) DeleteString(ctx context.Context, did, rkey string) {
148
+
err := n.client.Enqueue(posthog.Capture{
149
+
DistinctId: did,
150
+
Event: "delete_string",
151
+
Properties: posthog.Properties{"rkey": rkey},
152
+
})
153
+
if err != nil {
154
+
log.Println("failed to enqueue posthog event:", err)
155
+
}
156
+
}
157
+
158
+
func (n *posthogNotifier) EditString(ctx context.Context, string *models.String) {
159
+
err := n.client.Enqueue(posthog.Capture{
160
+
DistinctId: string.Did.String(),
161
+
Event: "edit_string",
162
+
Properties: posthog.Properties{"rkey": string.Rkey},
163
+
})
164
+
if err != nil {
165
+
log.Println("failed to enqueue posthog event:", err)
166
+
}
167
+
}
168
+
169
+
func (n *posthogNotifier) NewString(ctx context.Context, string *models.String) {
170
+
err := n.client.Enqueue(posthog.Capture{
171
+
DistinctId: string.Did.String(),
172
+
Event: "new_string",
173
+
Properties: posthog.Properties{"rkey": string.Rkey},
174
+
})
175
+
if err != nil {
176
+
log.Println("failed to enqueue posthog event:", err)
177
+
}
178
+
}
179
+
180
+
func (n *posthogNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) {
181
+
err := n.client.Enqueue(posthog.Capture{
182
+
DistinctId: comment.Did,
183
+
Event: "new_issue_comment",
184
+
Properties: posthog.Properties{
185
+
"issue_at": comment.IssueAt,
186
+
},
187
+
})
188
+
if err != nil {
189
+
log.Println("failed to enqueue posthog event:", err)
190
+
}
191
+
}
192
+
193
+
func (n *posthogNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) {
194
+
err := n.client.Enqueue(posthog.Capture{
195
+
DistinctId: issue.Did,
196
+
Event: "issue_closed",
197
+
Properties: posthog.Properties{
198
+
"repo_at": issue.RepoAt.String(),
199
+
"issue_id": issue.IssueId,
200
+
},
201
+
})
202
+
if err != nil {
203
+
log.Println("failed to enqueue posthog event:", err)
204
+
}
205
+
}
206
+
207
+
func (n *posthogNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) {
208
+
err := n.client.Enqueue(posthog.Capture{
209
+
DistinctId: pull.OwnerDid,
210
+
Event: "pull_merged",
211
+
Properties: posthog.Properties{
212
+
"repo_at": pull.RepoAt,
213
+
"pull_id": pull.PullId,
214
+
},
215
+
})
216
+
if err != nil {
217
+
log.Println("failed to enqueue posthog event:", err)
218
+
}
219
+
}
+18
-25
appview/oauth/handler/handler.go
+18
-25
appview/oauth/handler/handler.go
···
16
"github.com/gorilla/sessions"
17
"github.com/lestrrat-go/jwx/v2/jwk"
18
"github.com/posthog/posthog-go"
19
"tangled.sh/icyphox.sh/atproto-oauth/helpers"
20
-
tangled "tangled.sh/tangled.sh/core/api/tangled"
21
-
sessioncache "tangled.sh/tangled.sh/core/appview/cache/session"
22
-
"tangled.sh/tangled.sh/core/appview/config"
23
-
"tangled.sh/tangled.sh/core/appview/db"
24
-
"tangled.sh/tangled.sh/core/appview/middleware"
25
-
"tangled.sh/tangled.sh/core/appview/oauth"
26
-
"tangled.sh/tangled.sh/core/appview/oauth/client"
27
-
"tangled.sh/tangled.sh/core/appview/pages"
28
-
"tangled.sh/tangled.sh/core/idresolver"
29
-
"tangled.sh/tangled.sh/core/rbac"
30
-
"tangled.sh/tangled.sh/core/tid"
31
)
32
33
const (
···
353
return pubKey, nil
354
}
355
356
-
var (
357
-
tangledDid = "did:plc:wshs7t2adsemcrrd4snkeqli"
358
-
icyDid = "did:plc:hwevmowznbiukdf6uk5dwrrq"
359
-
360
-
defaultSpindle = "spindle.tangled.sh"
361
-
defaultKnot = "knot1.tangled.sh"
362
-
)
363
-
364
func (o *OAuthHandler) addToDefaultSpindle(did string) {
365
// use the tangled.sh app password to get an accessJwt
366
// and create an sh.tangled.spindle.member record with that
···
380
}
381
382
log.Printf("adding %s to default spindle", did)
383
-
session, err := o.createAppPasswordSession(o.config.Core.AppPassword, tangledDid)
384
if err != nil {
385
log.Printf("failed to create session: %s", err)
386
return
···
389
record := tangled.SpindleMember{
390
LexiconTypeID: "sh.tangled.spindle.member",
391
Subject: did,
392
-
Instance: defaultSpindle,
393
CreatedAt: time.Now().Format(time.RFC3339),
394
}
395
···
411
return
412
}
413
414
-
if slices.Contains(allKnots, defaultKnot) {
415
log.Printf("did %s is already a member of the default knot", did)
416
return
417
}
418
419
log.Printf("adding %s to default knot", did)
420
-
session, err := o.createAppPasswordSession(o.config.Core.TmpAltAppPassword, icyDid)
421
if err != nil {
422
log.Printf("failed to create session: %s", err)
423
return
···
426
record := tangled.KnotMember{
427
LexiconTypeID: "sh.tangled.knot.member",
428
Subject: did,
429
-
Domain: defaultKnot,
430
CreatedAt: time.Now().Format(time.RFC3339),
431
}
432
···
435
return
436
}
437
438
-
if err := o.enforcer.AddKnotMember(defaultKnot, did); err != nil {
439
log.Printf("failed to set up enforcer rules: %s", err)
440
return
441
}
···
16
"github.com/gorilla/sessions"
17
"github.com/lestrrat-go/jwx/v2/jwk"
18
"github.com/posthog/posthog-go"
19
+
tangled "tangled.org/core/api/tangled"
20
+
sessioncache "tangled.org/core/appview/cache/session"
21
+
"tangled.org/core/appview/config"
22
+
"tangled.org/core/appview/db"
23
+
"tangled.org/core/appview/middleware"
24
+
"tangled.org/core/appview/oauth"
25
+
"tangled.org/core/appview/oauth/client"
26
+
"tangled.org/core/appview/pages"
27
+
"tangled.org/core/consts"
28
+
"tangled.org/core/idresolver"
29
+
"tangled.org/core/rbac"
30
+
"tangled.org/core/tid"
31
"tangled.sh/icyphox.sh/atproto-oauth/helpers"
32
)
33
34
const (
···
354
return pubKey, nil
355
}
356
357
func (o *OAuthHandler) addToDefaultSpindle(did string) {
358
// use the tangled.sh app password to get an accessJwt
359
// and create an sh.tangled.spindle.member record with that
···
373
}
374
375
log.Printf("adding %s to default spindle", did)
376
+
session, err := o.createAppPasswordSession(o.config.Core.AppPassword, consts.TangledDid)
377
if err != nil {
378
log.Printf("failed to create session: %s", err)
379
return
···
382
record := tangled.SpindleMember{
383
LexiconTypeID: "sh.tangled.spindle.member",
384
Subject: did,
385
+
Instance: consts.DefaultSpindle,
386
CreatedAt: time.Now().Format(time.RFC3339),
387
}
388
···
404
return
405
}
406
407
+
if slices.Contains(allKnots, consts.DefaultKnot) {
408
log.Printf("did %s is already a member of the default knot", did)
409
return
410
}
411
412
log.Printf("adding %s to default knot", did)
413
+
session, err := o.createAppPasswordSession(o.config.Core.TmpAltAppPassword, consts.IcyDid)
414
if err != nil {
415
log.Printf("failed to create session: %s", err)
416
return
···
419
record := tangled.KnotMember{
420
LexiconTypeID: "sh.tangled.knot.member",
421
Subject: did,
422
+
Domain: consts.DefaultKnot,
423
CreatedAt: time.Now().Format(time.RFC3339),
424
}
425
···
428
return
429
}
430
431
+
if err := o.enforcer.AddKnotMember(consts.DefaultKnot, did); err != nil {
432
log.Printf("failed to set up enforcer rules: %s", err)
433
return
434
}
+4
-4
appview/oauth/oauth.go
+4
-4
appview/oauth/oauth.go
···
9
10
indigo_xrpc "github.com/bluesky-social/indigo/xrpc"
11
"github.com/gorilla/sessions"
12
oauth "tangled.sh/icyphox.sh/atproto-oauth"
13
"tangled.sh/icyphox.sh/atproto-oauth/helpers"
14
-
sessioncache "tangled.sh/tangled.sh/core/appview/cache/session"
15
-
"tangled.sh/tangled.sh/core/appview/config"
16
-
"tangled.sh/tangled.sh/core/appview/oauth/client"
17
-
xrpc "tangled.sh/tangled.sh/core/appview/xrpcclient"
18
)
19
20
type OAuth struct {
···
9
10
indigo_xrpc "github.com/bluesky-social/indigo/xrpc"
11
"github.com/gorilla/sessions"
12
+
sessioncache "tangled.org/core/appview/cache/session"
13
+
"tangled.org/core/appview/config"
14
+
"tangled.org/core/appview/oauth/client"
15
+
xrpc "tangled.org/core/appview/xrpcclient"
16
oauth "tangled.sh/icyphox.sh/atproto-oauth"
17
"tangled.sh/icyphox.sh/atproto-oauth/helpers"
18
)
19
20
type OAuth struct {
+32
-18
appview/pages/funcmap.go
+32
-18
appview/pages/funcmap.go
···
19
20
"github.com/dustin/go-humanize"
21
"github.com/go-enry/go-enry/v2"
22
-
"tangled.sh/tangled.sh/core/appview/filetree"
23
-
"tangled.sh/tangled.sh/core/appview/pages/markup"
24
-
"tangled.sh/tangled.sh/core/crypto"
25
)
26
27
func (p *Pages) funcMap() template.FuncMap {
···
29
"split": func(s string) []string {
30
return strings.Split(s, "\n")
31
},
32
"contains": func(s string, target string) bool {
33
return strings.Contains(s, target)
34
},
35
"resolve": func(s string) string {
36
identity, err := p.resolver.ResolveIdent(context.Background(), s)
···
127
"relTimeFmt": humanize.Time,
128
"shortRelTimeFmt": func(t time.Time) string {
129
return humanize.CustomRelTime(t, time.Now(), "", "", []humanize.RelTimeMagnitude{
130
-
{time.Second, "now", time.Second},
131
-
{2 * time.Second, "1s %s", 1},
132
-
{time.Minute, "%ds %s", time.Second},
133
-
{2 * time.Minute, "1min %s", 1},
134
-
{time.Hour, "%dmin %s", time.Minute},
135
-
{2 * time.Hour, "1hr %s", 1},
136
-
{humanize.Day, "%dhrs %s", time.Hour},
137
-
{2 * humanize.Day, "1d %s", 1},
138
-
{20 * humanize.Day, "%dd %s", humanize.Day},
139
-
{8 * humanize.Week, "%dw %s", humanize.Week},
140
-
{humanize.Year, "%dmo %s", humanize.Month},
141
-
{18 * humanize.Month, "1y %s", 1},
142
-
{2 * humanize.Year, "2y %s", 1},
143
-
{humanize.LongTime, "%dy %s", humanize.Year},
144
-
{math.MaxInt64, "a long while %s", 1},
145
})
146
},
147
"longTimeFmt": func(t time.Time) string {
···
19
20
"github.com/dustin/go-humanize"
21
"github.com/go-enry/go-enry/v2"
22
+
"tangled.org/core/appview/filetree"
23
+
"tangled.org/core/appview/pages/markup"
24
+
"tangled.org/core/crypto"
25
)
26
27
func (p *Pages) funcMap() template.FuncMap {
···
29
"split": func(s string) []string {
30
return strings.Split(s, "\n")
31
},
32
+
"trimPrefix": func(s, prefix string) string {
33
+
return strings.TrimPrefix(s, prefix)
34
+
},
35
+
"join": func(elems []string, sep string) string {
36
+
return strings.Join(elems, sep)
37
+
},
38
"contains": func(s string, target string) bool {
39
return strings.Contains(s, target)
40
+
},
41
+
"mapContains": func(m any, key any) bool {
42
+
mapValue := reflect.ValueOf(m)
43
+
if mapValue.Kind() != reflect.Map {
44
+
return false
45
+
}
46
+
keyValue := reflect.ValueOf(key)
47
+
return mapValue.MapIndex(keyValue).IsValid()
48
},
49
"resolve": func(s string) string {
50
identity, err := p.resolver.ResolveIdent(context.Background(), s)
···
141
"relTimeFmt": humanize.Time,
142
"shortRelTimeFmt": func(t time.Time) string {
143
return humanize.CustomRelTime(t, time.Now(), "", "", []humanize.RelTimeMagnitude{
144
+
{D: time.Second, Format: "now", DivBy: time.Second},
145
+
{D: 2 * time.Second, Format: "1s %s", DivBy: 1},
146
+
{D: time.Minute, Format: "%ds %s", DivBy: time.Second},
147
+
{D: 2 * time.Minute, Format: "1min %s", DivBy: 1},
148
+
{D: time.Hour, Format: "%dmin %s", DivBy: time.Minute},
149
+
{D: 2 * time.Hour, Format: "1hr %s", DivBy: 1},
150
+
{D: humanize.Day, Format: "%dhrs %s", DivBy: time.Hour},
151
+
{D: 2 * humanize.Day, Format: "1d %s", DivBy: 1},
152
+
{D: 20 * humanize.Day, Format: "%dd %s", DivBy: humanize.Day},
153
+
{D: 8 * humanize.Week, Format: "%dw %s", DivBy: humanize.Week},
154
+
{D: humanize.Year, Format: "%dmo %s", DivBy: humanize.Month},
155
+
{D: 18 * humanize.Month, Format: "1y %s", DivBy: 1},
156
+
{D: 2 * humanize.Year, Format: "2y %s", DivBy: 1},
157
+
{D: humanize.LongTime, Format: "%dy %s", DivBy: humanize.Year},
158
+
{D: math.MaxInt64, Format: "a long while %s", DivBy: 1},
159
})
160
},
161
"longTimeFmt": func(t time.Time) string {
+30
appview/pages/funcmap_test.go
+30
appview/pages/funcmap_test.go
···
···
1
+
package pages
2
+
3
+
import (
4
+
"html/template"
5
+
"tangled.org/core/appview/config"
6
+
"tangled.org/core/idresolver"
7
+
"testing"
8
+
)
9
+
10
+
func TestPages_funcMap(t *testing.T) {
11
+
tests := []struct {
12
+
name string // description of this test case
13
+
// Named input parameters for receiver constructor.
14
+
config *config.Config
15
+
res *idresolver.Resolver
16
+
want template.FuncMap
17
+
}{
18
+
// TODO: Add test cases.
19
+
}
20
+
for _, tt := range tests {
21
+
t.Run(tt.name, func(t *testing.T) {
22
+
p := NewPages(tt.config, tt.res)
23
+
got := p.funcMap()
24
+
// TODO: update the condition below to compare got with tt.want.
25
+
if true {
26
+
t.Errorf("funcMap() = %v, want %v", got, tt.want)
27
+
}
28
+
})
29
+
}
30
+
}
+156
appview/pages/legal/privacy.md
+156
appview/pages/legal/privacy.md
···
···
1
+
**Last updated:** September 26, 2025
2
+
3
+
This Privacy Policy describes how Tangled ("we," "us," or "our")
4
+
collects, uses, and shares your personal information when you use our
5
+
platform and services (the "Service").
6
+
7
+
## 1. Information We Collect
8
+
9
+
### Account Information
10
+
11
+
When you create an account, we collect:
12
+
13
+
- Your chosen username
14
+
- Email address
15
+
- Profile information you choose to provide
16
+
- Authentication data
17
+
18
+
### Content and Activity
19
+
20
+
We store:
21
+
22
+
- Code repositories and associated metadata
23
+
- Issues, pull requests, and comments
24
+
- Activity logs and usage patterns
25
+
- Public keys for authentication
26
+
27
+
## 2. Data Location and Hosting
28
+
29
+
### EU Data Hosting
30
+
31
+
**All Tangled service data is hosted within the European Union.**
32
+
Specifically:
33
+
34
+
- **Personal Data Servers (PDS):** Accounts hosted on Tangled PDS
35
+
(*.tngl.sh) are located in Finland
36
+
- **Application Data:** All other service data is stored on EU-based
37
+
servers
38
+
- **Data Processing:** All data processing occurs within EU
39
+
jurisdiction
40
+
41
+
### External PDS Notice
42
+
43
+
**Important:** If your account is hosted on Bluesky's PDS or other
44
+
self-hosted Personal Data Servers (not *.tngl.sh), we do not control
45
+
that data. The data protection, storage location, and privacy
46
+
practices for such accounts are governed by the respective PDS
47
+
provider's policies, not this Privacy Policy. We only control data
48
+
processing within our own services and infrastructure.
49
+
50
+
## 3. Third-Party Data Processors
51
+
52
+
We only share your data with the following third-party processors:
53
+
54
+
### Resend (Email Services)
55
+
56
+
- **Purpose:** Sending transactional emails (account verification,
57
+
notifications)
58
+
- **Data Shared:** Email address and necessary message content
59
+
60
+
### Cloudflare (Image Caching)
61
+
62
+
- **Purpose:** Caching and optimizing image delivery
63
+
- **Data Shared:** Public images and associated metadata for caching
64
+
purposes
65
+
66
+
### Posthog (Usage Metrics Tracking)
67
+
68
+
- **Purpose:** Tracking usage and platform metrics
69
+
- **Data Shared:** Anonymous usage data, IP addresses, DIDs, and browser
70
+
information
71
+
72
+
## 4. How We Use Your Information
73
+
74
+
We use your information to:
75
+
76
+
- Provide and maintain the Service
77
+
- Process your transactions and requests
78
+
- Send you technical notices and support messages
79
+
- Improve and develop new features
80
+
- Ensure security and prevent fraud
81
+
- Comply with legal obligations
82
+
83
+
## 5. Data Sharing and Disclosure
84
+
85
+
We do not sell, trade, or rent your personal information. We may share
86
+
your information only in the following circumstances:
87
+
88
+
- With the third-party processors listed above
89
+
- When required by law or legal process
90
+
- To protect our rights, property, or safety, or that of our users
91
+
- In connection with a merger, acquisition, or sale of assets (with
92
+
appropriate protections)
93
+
94
+
## 6. Data Security
95
+
96
+
We implement appropriate technical and organizational measures to
97
+
protect your personal information against unauthorized access,
98
+
alteration, disclosure, or destruction. However, no method of
99
+
transmission over the Internet is 100% secure.
100
+
101
+
## 7. Data Retention
102
+
103
+
We retain your personal information for as long as necessary to provide
104
+
the Service and fulfill the purposes outlined in this Privacy Policy,
105
+
unless a longer retention period is required by law.
106
+
107
+
## 8. Your Rights
108
+
109
+
Under applicable data protection laws, you have the right to:
110
+
111
+
- Access your personal information
112
+
- Correct inaccurate information
113
+
- Request deletion of your information
114
+
- Object to processing of your information
115
+
- Data portability
116
+
- Withdraw consent (where applicable)
117
+
118
+
## 9. Cookies and Tracking
119
+
120
+
We use cookies and similar technologies to:
121
+
122
+
- Maintain your login session
123
+
- Remember your preferences
124
+
- Analyze usage patterns to improve the Service
125
+
126
+
You can control cookie settings through your browser preferences.
127
+
128
+
## 10. Children's Privacy
129
+
130
+
The Service is not intended for children under 16 years of age. We do
131
+
not knowingly collect personal information from children under 16. If
132
+
we become aware that we have collected such information, we will take
133
+
steps to delete it.
134
+
135
+
## 11. International Data Transfers
136
+
137
+
While all our primary data processing occurs within the EU, some of our
138
+
third-party processors may process data outside the EU. When this
139
+
occurs, we ensure appropriate safeguards are in place, such as Standard
140
+
Contractual Clauses or adequacy decisions.
141
+
142
+
## 12. Changes to This Privacy Policy
143
+
144
+
We may update this Privacy Policy from time to time. We will notify you
145
+
of any changes by posting the new Privacy Policy on this page and
146
+
updating the "Last updated" date.
147
+
148
+
## 13. Contact Information
149
+
150
+
If you have any questions about this Privacy Policy or wish to exercise
151
+
your rights, please contact us through our platform or via email.
152
+
153
+
---
154
+
155
+
This Privacy Policy complies with the EU General Data Protection
156
+
Regulation (GDPR) and other applicable data protection laws.
+107
appview/pages/legal/terms.md
+107
appview/pages/legal/terms.md
···
···
1
+
**Last updated:** September 26, 2025
2
+
3
+
Welcome to Tangled. These Terms of Service ("Terms") govern your access
4
+
to and use of the Tangled platform and services (the "Service")
5
+
operated by us ("Tangled," "we," "us," or "our").
6
+
7
+
## 1. Acceptance of Terms
8
+
9
+
By accessing or using our Service, you agree to be bound by these Terms.
10
+
If you disagree with any part of these terms, then you may not access
11
+
the Service.
12
+
13
+
## 2. Account Registration
14
+
15
+
To use certain features of the Service, you must register for an
16
+
account. You agree to provide accurate, current, and complete
17
+
information during the registration process and to update such
18
+
information to keep it accurate, current, and complete.
19
+
20
+
## 3. Account Termination
21
+
22
+
> **Important Notice**
23
+
>
24
+
> **We reserve the right to terminate, suspend, or restrict access to
25
+
> your account at any time, for any reason, or for no reason at all, at
26
+
> our sole discretion.** This includes, but is not limited to,
27
+
> termination for violation of these Terms, inappropriate conduct, spam,
28
+
> abuse, or any other behavior we deem harmful to the Service or other
29
+
> users.
30
+
>
31
+
> Account termination may result in the loss of access to your
32
+
> repositories, data, and other content associated with your account. We
33
+
> are not obligated to provide advance notice of termination, though we
34
+
> may do so in our discretion.
35
+
36
+
## 4. Acceptable Use
37
+
38
+
You agree not to use the Service to:
39
+
40
+
- Violate any applicable laws or regulations
41
+
- Infringe upon the rights of others
42
+
- Upload, store, or share content that is illegal, harmful, threatening,
43
+
abusive, harassing, defamatory, vulgar, obscene, or otherwise
44
+
objectionable
45
+
- Engage in spam, phishing, or other deceptive practices
46
+
- Attempt to gain unauthorized access to the Service or other users'
47
+
accounts
48
+
- Interfere with or disrupt the Service or servers connected to the
49
+
Service
50
+
51
+
## 5. Content and Intellectual Property
52
+
53
+
You retain ownership of the content you upload to the Service. By
54
+
uploading content, you grant us a non-exclusive, worldwide, royalty-free
55
+
license to use, reproduce, modify, and distribute your content as
56
+
necessary to provide the Service.
57
+
58
+
## 6. Privacy
59
+
60
+
Your privacy is important to us. Please review our [Privacy
61
+
Policy](/privacy), which also governs your use of the Service.
62
+
63
+
## 7. Disclaimers
64
+
65
+
The Service is provided on an "AS IS" and "AS AVAILABLE" basis. We make
66
+
no warranties, expressed or implied, and hereby disclaim and negate all
67
+
other warranties including without limitation, implied warranties or
68
+
conditions of merchantability, fitness for a particular purpose, or
69
+
non-infringement of intellectual property or other violation of rights.
70
+
71
+
## 8. Limitation of Liability
72
+
73
+
In no event shall Tangled, nor its directors, employees, partners,
74
+
agents, suppliers, or affiliates, be liable for any indirect,
75
+
incidental, special, consequential, or punitive damages, including
76
+
without limitation, loss of profits, data, use, goodwill, or other
77
+
intangible losses, resulting from your use of the Service.
78
+
79
+
## 9. Indemnification
80
+
81
+
You agree to defend, indemnify, and hold harmless Tangled and its
82
+
affiliates, officers, directors, employees, and agents from and against
83
+
any and all claims, damages, obligations, losses, liabilities, costs,
84
+
or debt, and expenses (including attorney's fees).
85
+
86
+
## 10. Governing Law
87
+
88
+
These Terms shall be interpreted and governed by the laws of Finland,
89
+
without regard to its conflict of law provisions.
90
+
91
+
## 11. Changes to Terms
92
+
93
+
We reserve the right to modify or replace these Terms at any time. If a
94
+
revision is material, we will try to provide at least 30 days notice
95
+
prior to any new terms taking effect.
96
+
97
+
## 12. Contact Information
98
+
99
+
If you have any questions about these Terms of Service, please contact
100
+
us through our platform or via email.
101
+
102
+
---
103
+
104
+
These terms are effective as of the last updated date shown above and
105
+
will remain in effect except with respect to any changes in their
106
+
provisions in the future, which will be in effect immediately after
107
+
being posted on this page.
+15
-17
appview/pages/markup/format.go
+15
-17
appview/pages/markup/format.go
···
1
package markup
2
3
-
import "strings"
4
5
type Format string
6
···
10
)
11
12
var FileTypes map[Format][]string = map[Format][]string{
13
-
FormatMarkdown: []string{".md", ".markdown", ".mdown", ".mkdn", ".mkd"},
14
}
15
16
-
// ReadmeFilenames contains the list of common README filenames to search for,
17
-
// in order of preference. Only includes well-supported formats.
18
-
var ReadmeFilenames = []string{
19
-
"README.md", "readme.md",
20
-
"README",
21
-
"readme",
22
-
"README.markdown",
23
-
"readme.markdown",
24
-
"README.txt",
25
-
"readme.txt",
26
}
27
28
func GetFormat(filename string) Format {
29
-
for format, extensions := range FileTypes {
30
-
for _, extension := range extensions {
31
-
if strings.HasSuffix(filename, extension) {
32
-
return format
33
-
}
34
}
35
}
36
// default format
···
1
package markup
2
3
+
import (
4
+
"regexp"
5
+
)
6
7
type Format string
8
···
12
)
13
14
var FileTypes map[Format][]string = map[Format][]string{
15
+
FormatMarkdown: {".md", ".markdown", ".mdown", ".mkdn", ".mkd"},
16
}
17
18
+
var FileTypePatterns = map[Format]*regexp.Regexp{
19
+
FormatMarkdown: regexp.MustCompile(`(?i)\.(md|markdown|mdown|mkdn|mkd)$`),
20
+
}
21
+
22
+
var ReadmePattern = regexp.MustCompile(`(?i)^readme(\.(md|markdown|txt))?$`)
23
+
24
+
func IsReadmeFile(filename string) bool {
25
+
return ReadmePattern.MatchString(filename)
26
}
27
28
func GetFormat(filename string) Format {
29
+
for format, pattern := range FileTypePatterns {
30
+
if pattern.MatchString(filename) {
31
+
return format
32
}
33
}
34
// default format
+3
-3
appview/pages/markup/markdown.go
+3
-3
appview/pages/markup/markdown.go
···
22
"github.com/yuin/goldmark/util"
23
htmlparse "golang.org/x/net/html"
24
25
-
"tangled.sh/tangled.sh/core/api/tangled"
26
-
"tangled.sh/tangled.sh/core/appview/pages/repoinfo"
27
)
28
29
// RendererType defines the type of renderer to use based on context
···
235
repoName := fmt.Sprintf("%s/%s", rctx.RepoInfo.OwnerDid, rctx.RepoInfo.Name)
236
237
query := fmt.Sprintf("repo=%s&ref=%s&path=%s&raw=true",
238
-
repoName, url.PathEscape(rctx.RepoInfo.Ref), actualPath)
239
240
parsedURL := &url.URL{
241
Scheme: scheme,
···
22
"github.com/yuin/goldmark/util"
23
htmlparse "golang.org/x/net/html"
24
25
+
"tangled.org/core/api/tangled"
26
+
"tangled.org/core/appview/pages/repoinfo"
27
)
28
29
// RendererType defines the type of renderer to use based on context
···
235
repoName := fmt.Sprintf("%s/%s", rctx.RepoInfo.OwnerDid, rctx.RepoInfo.Name)
236
237
query := fmt.Sprintf("repo=%s&ref=%s&path=%s&raw=true",
238
+
url.PathEscape(repoName), url.PathEscape(rctx.RepoInfo.Ref), actualPath)
239
240
parsedURL := &url.URL{
241
Scheme: scheme,
+243
-115
appview/pages/pages.go
+243
-115
appview/pages/pages.go
···
16
"strings"
17
"sync"
18
19
-
"tangled.sh/tangled.sh/core/api/tangled"
20
-
"tangled.sh/tangled.sh/core/appview/commitverify"
21
-
"tangled.sh/tangled.sh/core/appview/config"
22
-
"tangled.sh/tangled.sh/core/appview/db"
23
-
"tangled.sh/tangled.sh/core/appview/oauth"
24
-
"tangled.sh/tangled.sh/core/appview/pages/markup"
25
-
"tangled.sh/tangled.sh/core/appview/pages/repoinfo"
26
-
"tangled.sh/tangled.sh/core/appview/pagination"
27
-
"tangled.sh/tangled.sh/core/idresolver"
28
-
"tangled.sh/tangled.sh/core/patchutil"
29
-
"tangled.sh/tangled.sh/core/types"
30
31
"github.com/alecthomas/chroma/v2"
32
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
···
38
"github.com/go-git/go-git/v5/plumbing/object"
39
)
40
41
-
//go:embed templates/* static
42
var Files embed.FS
43
44
type Pages struct {
···
81
}
82
83
return p
84
-
}
85
-
86
-
func (p *Pages) pathToName(s string) string {
87
-
return strings.TrimSuffix(strings.TrimPrefix(s, "templates/"), ".html")
88
}
89
90
// reverse of pathToName
···
219
}
220
221
func (p *Pages) Favicon(w io.Writer) error {
222
-
return p.executePlain("favicon", w, nil)
223
}
224
225
type LoginParams struct {
···
230
return p.executePlain("user/login", w, params)
231
}
232
233
-
func (p *Pages) Signup(w io.Writer) error {
234
-
return p.executePlain("user/signup", w, nil)
235
}
236
237
func (p *Pages) CompleteSignup(w io.Writer) error {
···
246
func (p *Pages) TermsOfService(w io.Writer, params TermsOfServiceParams) error {
247
filename := "terms.md"
248
filePath := filepath.Join("legal", filename)
249
-
markdownBytes, err := os.ReadFile(filePath)
250
if err != nil {
251
return fmt.Errorf("failed to read %s: %w", filename, err)
252
}
···
267
func (p *Pages) PrivacyPolicy(w io.Writer, params PrivacyPolicyParams) error {
268
filename := "privacy.md"
269
filePath := filepath.Join("legal", filename)
270
-
markdownBytes, err := os.ReadFile(filePath)
271
if err != nil {
272
return fmt.Errorf("failed to read %s: %w", filename, err)
273
}
···
280
return p.execute("legal/privacy", w, params)
281
}
282
283
type TimelineParams struct {
284
LoggedInUser *oauth.User
285
-
Timeline []db.TimelineEvent
286
-
Repos []db.Repo
287
}
288
289
func (p *Pages) Timeline(w io.Writer, params TimelineParams) error {
290
return p.execute("timeline/timeline", w, params)
291
}
292
293
type UserProfileSettingsParams struct {
294
LoggedInUser *oauth.User
295
Tabs []map[string]any
···
300
return p.execute("user/settings/profile", w, params)
301
}
302
303
type UserKeysSettingsParams struct {
304
LoggedInUser *oauth.User
305
-
PubKeys []db.PublicKey
306
Tabs []map[string]any
307
Tab string
308
}
···
313
314
type UserEmailsSettingsParams struct {
315
LoggedInUser *oauth.User
316
-
Emails []db.Email
317
Tabs []map[string]any
318
Tab string
319
}
···
322
return p.execute("user/settings/emails", w, params)
323
}
324
325
type UpgradeBannerParams struct {
326
-
Registrations []db.Registration
327
-
Spindles []db.Spindle
328
}
329
330
func (p *Pages) UpgradeBanner(w io.Writer, params UpgradeBannerParams) error {
···
333
334
type KnotsParams struct {
335
LoggedInUser *oauth.User
336
-
Registrations []db.Registration
337
}
338
339
func (p *Pages) Knots(w io.Writer, params KnotsParams) error {
···
342
343
type KnotParams struct {
344
LoggedInUser *oauth.User
345
-
Registration *db.Registration
346
Members []string
347
-
Repos map[string][]db.Repo
348
IsOwner bool
349
}
350
···
353
}
354
355
type KnotListingParams struct {
356
-
*db.Registration
357
}
358
359
func (p *Pages) KnotListing(w io.Writer, params KnotListingParams) error {
···
362
363
type SpindlesParams struct {
364
LoggedInUser *oauth.User
365
-
Spindles []db.Spindle
366
}
367
368
func (p *Pages) Spindles(w io.Writer, params SpindlesParams) error {
···
370
}
371
372
type SpindleListingParams struct {
373
-
db.Spindle
374
}
375
376
func (p *Pages) SpindleListing(w io.Writer, params SpindleListingParams) error {
···
379
380
type SpindleDashboardParams struct {
381
LoggedInUser *oauth.User
382
-
Spindle db.Spindle
383
Members []string
384
-
Repos map[string][]db.Repo
385
}
386
387
func (p *Pages) SpindleDashboard(w io.Writer, params SpindleDashboardParams) error {
···
410
type ProfileCard struct {
411
UserDid string
412
UserHandle string
413
-
FollowStatus db.FollowStatus
414
-
Punchcard *db.Punchcard
415
-
Profile *db.Profile
416
Stats ProfileStats
417
Active string
418
}
···
438
439
type ProfileOverviewParams struct {
440
LoggedInUser *oauth.User
441
-
Repos []db.Repo
442
-
CollaboratingRepos []db.Repo
443
-
ProfileTimeline *db.ProfileTimeline
444
Card *ProfileCard
445
Active string
446
}
···
452
453
type ProfileReposParams struct {
454
LoggedInUser *oauth.User
455
-
Repos []db.Repo
456
Card *ProfileCard
457
Active string
458
}
···
464
465
type ProfileStarredParams struct {
466
LoggedInUser *oauth.User
467
-
Repos []db.Repo
468
Card *ProfileCard
469
Active string
470
}
···
476
477
type ProfileStringsParams struct {
478
LoggedInUser *oauth.User
479
-
Strings []db.String
480
Card *ProfileCard
481
Active string
482
}
···
488
489
type FollowCard struct {
490
UserDid string
491
-
FollowStatus db.FollowStatus
492
FollowersCount int64
493
FollowingCount int64
494
-
Profile *db.Profile
495
}
496
497
type ProfileFollowersParams struct {
···
520
521
type FollowFragmentParams struct {
522
UserDid string
523
-
FollowStatus db.FollowStatus
524
}
525
526
func (p *Pages) FollowFragment(w io.Writer, params FollowFragmentParams) error {
···
529
530
type EditBioParams struct {
531
LoggedInUser *oauth.User
532
-
Profile *db.Profile
533
}
534
535
func (p *Pages) EditBioFragment(w io.Writer, params EditBioParams) error {
···
538
539
type EditPinsParams struct {
540
LoggedInUser *oauth.User
541
-
Profile *db.Profile
542
AllRepos []PinnedRepo
543
}
544
545
type PinnedRepo struct {
546
IsPinned bool
547
-
db.Repo
548
}
549
550
func (p *Pages) EditPinsFragment(w io.Writer, params EditPinsParams) error {
···
554
type RepoStarFragmentParams struct {
555
IsStarred bool
556
RepoAt syntax.ATURI
557
-
Stats db.RepoStats
558
}
559
560
func (p *Pages) RepoStarFragment(w io.Writer, params RepoStarFragmentParams) error {
···
587
EmailToDidOrHandle map[string]string
588
VerifiedCommits commitverify.VerifiedCommits
589
Languages []types.RepoLanguageDetails
590
-
Pipelines map[string]db.Pipeline
591
NeedsKnotUpgrade bool
592
types.RepoIndexResponse
593
}
···
630
Active string
631
EmailToDidOrHandle map[string]string
632
VerifiedCommits commitverify.VerifiedCommits
633
-
Pipelines map[string]db.Pipeline
634
}
635
636
func (p *Pages) RepoLog(w io.Writer, params RepoLogParams) error {
···
643
RepoInfo repoinfo.RepoInfo
644
Active string
645
EmailToDidOrHandle map[string]string
646
-
Pipeline *db.Pipeline
647
DiffOpts types.DiffOpts
648
649
// singular because it's always going to be just one
···
663
Active string
664
BreadCrumbs [][]string
665
TreePath string
666
types.RepoTreeResponse
667
}
668
···
689
690
func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error {
691
params.Active = "overview"
692
return p.executeRepo("repo/tree", w, params)
693
}
694
···
709
RepoInfo repoinfo.RepoInfo
710
Active string
711
types.RepoTagsResponse
712
-
ArtifactMap map[plumbing.Hash][]db.Artifact
713
-
DanglingArtifacts []db.Artifact
714
}
715
716
func (p *Pages) RepoTags(w io.Writer, params RepoTagsParams) error {
···
721
type RepoArtifactParams struct {
722
LoggedInUser *oauth.User
723
RepoInfo repoinfo.RepoInfo
724
-
Artifact db.Artifact
725
}
726
727
func (p *Pages) RepoArtifactFragment(w io.Writer, params RepoArtifactParams) error {
···
818
}
819
820
type RepoGeneralSettingsParams struct {
821
-
LoggedInUser *oauth.User
822
-
RepoInfo repoinfo.RepoInfo
823
-
Active string
824
-
Tabs []map[string]any
825
-
Tab string
826
-
Branches []types.Branch
827
}
828
829
func (p *Pages) RepoGeneralSettings(w io.Writer, params RepoGeneralSettingsParams) error {
···
865
LoggedInUser *oauth.User
866
RepoInfo repoinfo.RepoInfo
867
Active string
868
-
Issues []db.Issue
869
Page pagination.Page
870
FilteringByOpen bool
871
}
···
876
}
877
878
type RepoSingleIssueParams struct {
879
-
LoggedInUser *oauth.User
880
-
RepoInfo repoinfo.RepoInfo
881
-
Active string
882
-
Issue *db.Issue
883
-
CommentList []db.CommentListItem
884
-
IssueOwnerHandle string
885
886
-
OrderedReactionKinds []db.ReactionKind
887
-
Reactions map[db.ReactionKind]int
888
-
UserReacted map[db.ReactionKind]bool
889
}
890
891
func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error {
···
896
type EditIssueParams struct {
897
LoggedInUser *oauth.User
898
RepoInfo repoinfo.RepoInfo
899
-
Issue *db.Issue
900
Action string
901
}
902
···
907
908
type ThreadReactionFragmentParams struct {
909
ThreadAt syntax.ATURI
910
-
Kind db.ReactionKind
911
Count int
912
IsReacted bool
913
}
···
919
type RepoNewIssueParams struct {
920
LoggedInUser *oauth.User
921
RepoInfo repoinfo.RepoInfo
922
-
Issue *db.Issue // existing issue if any -- passed when editing
923
Active string
924
Action string
925
}
···
933
type EditIssueCommentParams struct {
934
LoggedInUser *oauth.User
935
RepoInfo repoinfo.RepoInfo
936
-
Issue *db.Issue
937
-
Comment *db.IssueComment
938
}
939
940
func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error {
···
944
type ReplyIssueCommentPlaceholderParams struct {
945
LoggedInUser *oauth.User
946
RepoInfo repoinfo.RepoInfo
947
-
Issue *db.Issue
948
-
Comment *db.IssueComment
949
}
950
951
func (p *Pages) ReplyIssueCommentPlaceholderFragment(w io.Writer, params ReplyIssueCommentPlaceholderParams) error {
···
955
type ReplyIssueCommentParams struct {
956
LoggedInUser *oauth.User
957
RepoInfo repoinfo.RepoInfo
958
-
Issue *db.Issue
959
-
Comment *db.IssueComment
960
}
961
962
func (p *Pages) ReplyIssueCommentFragment(w io.Writer, params ReplyIssueCommentParams) error {
···
966
type IssueCommentBodyParams struct {
967
LoggedInUser *oauth.User
968
RepoInfo repoinfo.RepoInfo
969
-
Issue *db.Issue
970
-
Comment *db.IssueComment
971
}
972
973
func (p *Pages) IssueCommentBodyFragment(w io.Writer, params IssueCommentBodyParams) error {
···
994
type RepoPullsParams struct {
995
LoggedInUser *oauth.User
996
RepoInfo repoinfo.RepoInfo
997
-
Pulls []*db.Pull
998
Active string
999
-
FilteringBy db.PullState
1000
-
Stacks map[string]db.Stack
1001
-
Pipelines map[string]db.Pipeline
1002
}
1003
1004
func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error {
···
1028
LoggedInUser *oauth.User
1029
RepoInfo repoinfo.RepoInfo
1030
Active string
1031
-
Pull *db.Pull
1032
-
Stack db.Stack
1033
-
AbandonedPulls []*db.Pull
1034
MergeCheck types.MergeCheckResponse
1035
ResubmitCheck ResubmitResult
1036
-
Pipelines map[string]db.Pipeline
1037
1038
-
OrderedReactionKinds []db.ReactionKind
1039
-
Reactions map[db.ReactionKind]int
1040
-
UserReacted map[db.ReactionKind]bool
1041
}
1042
1043
func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error {
···
1048
type RepoPullPatchParams struct {
1049
LoggedInUser *oauth.User
1050
RepoInfo repoinfo.RepoInfo
1051
-
Pull *db.Pull
1052
-
Stack db.Stack
1053
Diff *types.NiceDiff
1054
Round int
1055
-
Submission *db.PullSubmission
1056
-
OrderedReactionKinds []db.ReactionKind
1057
DiffOpts types.DiffOpts
1058
}
1059
···
1065
type RepoPullInterdiffParams struct {
1066
LoggedInUser *oauth.User
1067
RepoInfo repoinfo.RepoInfo
1068
-
Pull *db.Pull
1069
Round int
1070
Interdiff *patchutil.InterdiffResult
1071
-
OrderedReactionKinds []db.ReactionKind
1072
DiffOpts types.DiffOpts
1073
}
1074
···
1097
1098
type PullCompareForkParams struct {
1099
RepoInfo repoinfo.RepoInfo
1100
-
Forks []db.Repo
1101
Selected string
1102
}
1103
···
1118
type PullResubmitParams struct {
1119
LoggedInUser *oauth.User
1120
RepoInfo repoinfo.RepoInfo
1121
-
Pull *db.Pull
1122
SubmissionId int
1123
}
1124
···
1129
type PullActionsParams struct {
1130
LoggedInUser *oauth.User
1131
RepoInfo repoinfo.RepoInfo
1132
-
Pull *db.Pull
1133
RoundNumber int
1134
MergeCheck types.MergeCheckResponse
1135
ResubmitCheck ResubmitResult
1136
-
Stack db.Stack
1137
}
1138
1139
func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error {
···
1143
type PullNewCommentParams struct {
1144
LoggedInUser *oauth.User
1145
RepoInfo repoinfo.RepoInfo
1146
-
Pull *db.Pull
1147
RoundNumber int
1148
}
1149
···
1154
type RepoCompareParams struct {
1155
LoggedInUser *oauth.User
1156
RepoInfo repoinfo.RepoInfo
1157
-
Forks []db.Repo
1158
Branches []types.Branch
1159
Tags []*types.TagReference
1160
Base string
···
1173
type RepoCompareNewParams struct {
1174
LoggedInUser *oauth.User
1175
RepoInfo repoinfo.RepoInfo
1176
-
Forks []db.Repo
1177
Branches []types.Branch
1178
Tags []*types.TagReference
1179
Base string
···
1208
return p.executePlain("repo/fragments/diff", w, []any{params.RepoInfo.FullName, ¶ms.Diff})
1209
}
1210
1211
type PipelinesParams struct {
1212
LoggedInUser *oauth.User
1213
RepoInfo repoinfo.RepoInfo
1214
-
Pipelines []db.Pipeline
1215
Active string
1216
}
1217
···
1243
type WorkflowParams struct {
1244
LoggedInUser *oauth.User
1245
RepoInfo repoinfo.RepoInfo
1246
-
Pipeline db.Pipeline
1247
Workflow string
1248
LogUrl string
1249
Active string
···
1259
Action string
1260
1261
// this is supplied in the case of editing an existing string
1262
-
String db.String
1263
}
1264
1265
func (p *Pages) PutString(w io.Writer, params PutStringParams) error {
···
1269
type StringsDashboardParams struct {
1270
LoggedInUser *oauth.User
1271
Card ProfileCard
1272
-
Strings []db.String
1273
}
1274
1275
func (p *Pages) StringsDashboard(w io.Writer, params StringsDashboardParams) error {
···
1278
1279
type StringTimelineParams struct {
1280
LoggedInUser *oauth.User
1281
-
Strings []db.String
1282
}
1283
1284
func (p *Pages) StringsTimeline(w io.Writer, params StringTimelineParams) error {
···
1290
ShowRendered bool
1291
RenderToggle bool
1292
RenderedContents template.HTML
1293
-
String db.String
1294
-
Stats db.StringStats
1295
Owner identity.Identity
1296
}
1297
···
16
"strings"
17
"sync"
18
19
+
"tangled.org/core/api/tangled"
20
+
"tangled.org/core/appview/commitverify"
21
+
"tangled.org/core/appview/config"
22
+
"tangled.org/core/appview/models"
23
+
"tangled.org/core/appview/oauth"
24
+
"tangled.org/core/appview/pages/markup"
25
+
"tangled.org/core/appview/pages/repoinfo"
26
+
"tangled.org/core/appview/pagination"
27
+
"tangled.org/core/idresolver"
28
+
"tangled.org/core/patchutil"
29
+
"tangled.org/core/types"
30
31
"github.com/alecthomas/chroma/v2"
32
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
···
38
"github.com/go-git/go-git/v5/plumbing/object"
39
)
40
41
+
//go:embed templates/* static legal
42
var Files embed.FS
43
44
type Pages struct {
···
81
}
82
83
return p
84
}
85
86
// reverse of pathToName
···
215
}
216
217
func (p *Pages) Favicon(w io.Writer) error {
218
+
return p.executePlain("fragments/dolly/silhouette", w, nil)
219
}
220
221
type LoginParams struct {
···
226
return p.executePlain("user/login", w, params)
227
}
228
229
+
type SignupParams struct {
230
+
CloudflareSiteKey string
231
+
}
232
+
233
+
func (p *Pages) Signup(w io.Writer, params SignupParams) error {
234
+
return p.executePlain("user/signup", w, params)
235
}
236
237
func (p *Pages) CompleteSignup(w io.Writer) error {
···
246
func (p *Pages) TermsOfService(w io.Writer, params TermsOfServiceParams) error {
247
filename := "terms.md"
248
filePath := filepath.Join("legal", filename)
249
+
250
+
file, err := p.embedFS.Open(filePath)
251
+
if err != nil {
252
+
return fmt.Errorf("failed to read %s: %w", filename, err)
253
+
}
254
+
defer file.Close()
255
+
256
+
markdownBytes, err := io.ReadAll(file)
257
if err != nil {
258
return fmt.Errorf("failed to read %s: %w", filename, err)
259
}
···
274
func (p *Pages) PrivacyPolicy(w io.Writer, params PrivacyPolicyParams) error {
275
filename := "privacy.md"
276
filePath := filepath.Join("legal", filename)
277
+
278
+
file, err := p.embedFS.Open(filePath)
279
+
if err != nil {
280
+
return fmt.Errorf("failed to read %s: %w", filename, err)
281
+
}
282
+
defer file.Close()
283
+
284
+
markdownBytes, err := io.ReadAll(file)
285
if err != nil {
286
return fmt.Errorf("failed to read %s: %w", filename, err)
287
}
···
294
return p.execute("legal/privacy", w, params)
295
}
296
297
+
type BrandParams struct {
298
+
LoggedInUser *oauth.User
299
+
}
300
+
301
+
func (p *Pages) Brand(w io.Writer, params BrandParams) error {
302
+
return p.execute("brand/brand", w, params)
303
+
}
304
+
305
type TimelineParams struct {
306
LoggedInUser *oauth.User
307
+
Timeline []models.TimelineEvent
308
+
Repos []models.Repo
309
+
GfiLabel *models.LabelDefinition
310
}
311
312
func (p *Pages) Timeline(w io.Writer, params TimelineParams) error {
313
return p.execute("timeline/timeline", w, params)
314
}
315
316
+
type GoodFirstIssuesParams struct {
317
+
LoggedInUser *oauth.User
318
+
Issues []models.Issue
319
+
RepoGroups []*models.RepoGroup
320
+
LabelDefs map[string]*models.LabelDefinition
321
+
GfiLabel *models.LabelDefinition
322
+
Page pagination.Page
323
+
}
324
+
325
+
func (p *Pages) GoodFirstIssues(w io.Writer, params GoodFirstIssuesParams) error {
326
+
return p.execute("goodfirstissues/index", w, params)
327
+
}
328
+
329
type UserProfileSettingsParams struct {
330
LoggedInUser *oauth.User
331
Tabs []map[string]any
···
336
return p.execute("user/settings/profile", w, params)
337
}
338
339
+
type NotificationsParams struct {
340
+
LoggedInUser *oauth.User
341
+
Notifications []*models.NotificationWithEntity
342
+
UnreadCount int
343
+
Page pagination.Page
344
+
Total int64
345
+
}
346
+
347
+
func (p *Pages) Notifications(w io.Writer, params NotificationsParams) error {
348
+
return p.execute("notifications/list", w, params)
349
+
}
350
+
351
+
type NotificationItemParams struct {
352
+
Notification *models.Notification
353
+
}
354
+
355
+
func (p *Pages) NotificationItem(w io.Writer, params NotificationItemParams) error {
356
+
return p.executePlain("notifications/fragments/item", w, params)
357
+
}
358
+
359
+
type NotificationCountParams struct {
360
+
Count int64
361
+
}
362
+
363
+
func (p *Pages) NotificationCount(w io.Writer, params NotificationCountParams) error {
364
+
return p.executePlain("notifications/fragments/count", w, params)
365
+
}
366
+
367
type UserKeysSettingsParams struct {
368
LoggedInUser *oauth.User
369
+
PubKeys []models.PublicKey
370
Tabs []map[string]any
371
Tab string
372
}
···
377
378
type UserEmailsSettingsParams struct {
379
LoggedInUser *oauth.User
380
+
Emails []models.Email
381
Tabs []map[string]any
382
Tab string
383
}
···
386
return p.execute("user/settings/emails", w, params)
387
}
388
389
+
type UserNotificationSettingsParams struct {
390
+
LoggedInUser *oauth.User
391
+
Preferences *models.NotificationPreferences
392
+
Tabs []map[string]any
393
+
Tab string
394
+
}
395
+
396
+
func (p *Pages) UserNotificationSettings(w io.Writer, params UserNotificationSettingsParams) error {
397
+
return p.execute("user/settings/notifications", w, params)
398
+
}
399
+
400
type UpgradeBannerParams struct {
401
+
Registrations []models.Registration
402
+
Spindles []models.Spindle
403
}
404
405
func (p *Pages) UpgradeBanner(w io.Writer, params UpgradeBannerParams) error {
···
408
409
type KnotsParams struct {
410
LoggedInUser *oauth.User
411
+
Registrations []models.Registration
412
}
413
414
func (p *Pages) Knots(w io.Writer, params KnotsParams) error {
···
417
418
type KnotParams struct {
419
LoggedInUser *oauth.User
420
+
Registration *models.Registration
421
Members []string
422
+
Repos map[string][]models.Repo
423
IsOwner bool
424
}
425
···
428
}
429
430
type KnotListingParams struct {
431
+
*models.Registration
432
}
433
434
func (p *Pages) KnotListing(w io.Writer, params KnotListingParams) error {
···
437
438
type SpindlesParams struct {
439
LoggedInUser *oauth.User
440
+
Spindles []models.Spindle
441
}
442
443
func (p *Pages) Spindles(w io.Writer, params SpindlesParams) error {
···
445
}
446
447
type SpindleListingParams struct {
448
+
models.Spindle
449
}
450
451
func (p *Pages) SpindleListing(w io.Writer, params SpindleListingParams) error {
···
454
455
type SpindleDashboardParams struct {
456
LoggedInUser *oauth.User
457
+
Spindle models.Spindle
458
Members []string
459
+
Repos map[string][]models.Repo
460
}
461
462
func (p *Pages) SpindleDashboard(w io.Writer, params SpindleDashboardParams) error {
···
485
type ProfileCard struct {
486
UserDid string
487
UserHandle string
488
+
FollowStatus models.FollowStatus
489
+
Punchcard *models.Punchcard
490
+
Profile *models.Profile
491
Stats ProfileStats
492
Active string
493
}
···
513
514
type ProfileOverviewParams struct {
515
LoggedInUser *oauth.User
516
+
Repos []models.Repo
517
+
CollaboratingRepos []models.Repo
518
+
ProfileTimeline *models.ProfileTimeline
519
Card *ProfileCard
520
Active string
521
}
···
527
528
type ProfileReposParams struct {
529
LoggedInUser *oauth.User
530
+
Repos []models.Repo
531
Card *ProfileCard
532
Active string
533
}
···
539
540
type ProfileStarredParams struct {
541
LoggedInUser *oauth.User
542
+
Repos []models.Repo
543
Card *ProfileCard
544
Active string
545
}
···
551
552
type ProfileStringsParams struct {
553
LoggedInUser *oauth.User
554
+
Strings []models.String
555
Card *ProfileCard
556
Active string
557
}
···
563
564
type FollowCard struct {
565
UserDid string
566
+
LoggedInUser *oauth.User
567
+
FollowStatus models.FollowStatus
568
FollowersCount int64
569
FollowingCount int64
570
+
Profile *models.Profile
571
}
572
573
type ProfileFollowersParams struct {
···
596
597
type FollowFragmentParams struct {
598
UserDid string
599
+
FollowStatus models.FollowStatus
600
}
601
602
func (p *Pages) FollowFragment(w io.Writer, params FollowFragmentParams) error {
···
605
606
type EditBioParams struct {
607
LoggedInUser *oauth.User
608
+
Profile *models.Profile
609
}
610
611
func (p *Pages) EditBioFragment(w io.Writer, params EditBioParams) error {
···
614
615
type EditPinsParams struct {
616
LoggedInUser *oauth.User
617
+
Profile *models.Profile
618
AllRepos []PinnedRepo
619
}
620
621
type PinnedRepo struct {
622
IsPinned bool
623
+
models.Repo
624
}
625
626
func (p *Pages) EditPinsFragment(w io.Writer, params EditPinsParams) error {
···
630
type RepoStarFragmentParams struct {
631
IsStarred bool
632
RepoAt syntax.ATURI
633
+
Stats models.RepoStats
634
}
635
636
func (p *Pages) RepoStarFragment(w io.Writer, params RepoStarFragmentParams) error {
···
663
EmailToDidOrHandle map[string]string
664
VerifiedCommits commitverify.VerifiedCommits
665
Languages []types.RepoLanguageDetails
666
+
Pipelines map[string]models.Pipeline
667
NeedsKnotUpgrade bool
668
types.RepoIndexResponse
669
}
···
706
Active string
707
EmailToDidOrHandle map[string]string
708
VerifiedCommits commitverify.VerifiedCommits
709
+
Pipelines map[string]models.Pipeline
710
}
711
712
func (p *Pages) RepoLog(w io.Writer, params RepoLogParams) error {
···
719
RepoInfo repoinfo.RepoInfo
720
Active string
721
EmailToDidOrHandle map[string]string
722
+
Pipeline *models.Pipeline
723
DiffOpts types.DiffOpts
724
725
// singular because it's always going to be just one
···
739
Active string
740
BreadCrumbs [][]string
741
TreePath string
742
+
Raw bool
743
+
HTMLReadme template.HTML
744
types.RepoTreeResponse
745
}
746
···
767
768
func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error {
769
params.Active = "overview"
770
+
771
+
p.rctx.RepoInfo = params.RepoInfo
772
+
p.rctx.RepoInfo.Ref = params.Ref
773
+
p.rctx.RendererType = markup.RendererTypeRepoMarkdown
774
+
775
+
if params.ReadmeFileName != "" {
776
+
ext := filepath.Ext(params.ReadmeFileName)
777
+
switch ext {
778
+
case ".md", ".markdown", ".mdown", ".mkdn", ".mkd":
779
+
params.Raw = false
780
+
htmlString := p.rctx.RenderMarkdown(params.Readme)
781
+
sanitized := p.rctx.SanitizeDefault(htmlString)
782
+
params.HTMLReadme = template.HTML(sanitized)
783
+
default:
784
+
params.Raw = true
785
+
}
786
+
}
787
+
788
return p.executeRepo("repo/tree", w, params)
789
}
790
···
805
RepoInfo repoinfo.RepoInfo
806
Active string
807
types.RepoTagsResponse
808
+
ArtifactMap map[plumbing.Hash][]models.Artifact
809
+
DanglingArtifacts []models.Artifact
810
}
811
812
func (p *Pages) RepoTags(w io.Writer, params RepoTagsParams) error {
···
817
type RepoArtifactParams struct {
818
LoggedInUser *oauth.User
819
RepoInfo repoinfo.RepoInfo
820
+
Artifact models.Artifact
821
}
822
823
func (p *Pages) RepoArtifactFragment(w io.Writer, params RepoArtifactParams) error {
···
914
}
915
916
type RepoGeneralSettingsParams struct {
917
+
LoggedInUser *oauth.User
918
+
RepoInfo repoinfo.RepoInfo
919
+
Labels []models.LabelDefinition
920
+
DefaultLabels []models.LabelDefinition
921
+
SubscribedLabels map[string]struct{}
922
+
ShouldSubscribeAll bool
923
+
Active string
924
+
Tabs []map[string]any
925
+
Tab string
926
+
Branches []types.Branch
927
}
928
929
func (p *Pages) RepoGeneralSettings(w io.Writer, params RepoGeneralSettingsParams) error {
···
965
LoggedInUser *oauth.User
966
RepoInfo repoinfo.RepoInfo
967
Active string
968
+
Issues []models.Issue
969
+
LabelDefs map[string]*models.LabelDefinition
970
Page pagination.Page
971
FilteringByOpen bool
972
}
···
977
}
978
979
type RepoSingleIssueParams struct {
980
+
LoggedInUser *oauth.User
981
+
RepoInfo repoinfo.RepoInfo
982
+
Active string
983
+
Issue *models.Issue
984
+
CommentList []models.CommentListItem
985
+
LabelDefs map[string]*models.LabelDefinition
986
987
+
OrderedReactionKinds []models.ReactionKind
988
+
Reactions map[models.ReactionKind]int
989
+
UserReacted map[models.ReactionKind]bool
990
}
991
992
func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error {
···
997
type EditIssueParams struct {
998
LoggedInUser *oauth.User
999
RepoInfo repoinfo.RepoInfo
1000
+
Issue *models.Issue
1001
Action string
1002
}
1003
···
1008
1009
type ThreadReactionFragmentParams struct {
1010
ThreadAt syntax.ATURI
1011
+
Kind models.ReactionKind
1012
Count int
1013
IsReacted bool
1014
}
···
1020
type RepoNewIssueParams struct {
1021
LoggedInUser *oauth.User
1022
RepoInfo repoinfo.RepoInfo
1023
+
Issue *models.Issue // existing issue if any -- passed when editing
1024
Active string
1025
Action string
1026
}
···
1034
type EditIssueCommentParams struct {
1035
LoggedInUser *oauth.User
1036
RepoInfo repoinfo.RepoInfo
1037
+
Issue *models.Issue
1038
+
Comment *models.IssueComment
1039
}
1040
1041
func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error {
···
1045
type ReplyIssueCommentPlaceholderParams struct {
1046
LoggedInUser *oauth.User
1047
RepoInfo repoinfo.RepoInfo
1048
+
Issue *models.Issue
1049
+
Comment *models.IssueComment
1050
}
1051
1052
func (p *Pages) ReplyIssueCommentPlaceholderFragment(w io.Writer, params ReplyIssueCommentPlaceholderParams) error {
···
1056
type ReplyIssueCommentParams struct {
1057
LoggedInUser *oauth.User
1058
RepoInfo repoinfo.RepoInfo
1059
+
Issue *models.Issue
1060
+
Comment *models.IssueComment
1061
}
1062
1063
func (p *Pages) ReplyIssueCommentFragment(w io.Writer, params ReplyIssueCommentParams) error {
···
1067
type IssueCommentBodyParams struct {
1068
LoggedInUser *oauth.User
1069
RepoInfo repoinfo.RepoInfo
1070
+
Issue *models.Issue
1071
+
Comment *models.IssueComment
1072
}
1073
1074
func (p *Pages) IssueCommentBodyFragment(w io.Writer, params IssueCommentBodyParams) error {
···
1095
type RepoPullsParams struct {
1096
LoggedInUser *oauth.User
1097
RepoInfo repoinfo.RepoInfo
1098
+
Pulls []*models.Pull
1099
Active string
1100
+
FilteringBy models.PullState
1101
+
Stacks map[string]models.Stack
1102
+
Pipelines map[string]models.Pipeline
1103
+
LabelDefs map[string]*models.LabelDefinition
1104
}
1105
1106
func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error {
···
1130
LoggedInUser *oauth.User
1131
RepoInfo repoinfo.RepoInfo
1132
Active string
1133
+
Pull *models.Pull
1134
+
Stack models.Stack
1135
+
AbandonedPulls []*models.Pull
1136
MergeCheck types.MergeCheckResponse
1137
ResubmitCheck ResubmitResult
1138
+
Pipelines map[string]models.Pipeline
1139
1140
+
OrderedReactionKinds []models.ReactionKind
1141
+
Reactions map[models.ReactionKind]int
1142
+
UserReacted map[models.ReactionKind]bool
1143
+
1144
+
LabelDefs map[string]*models.LabelDefinition
1145
}
1146
1147
func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error {
···
1152
type RepoPullPatchParams struct {
1153
LoggedInUser *oauth.User
1154
RepoInfo repoinfo.RepoInfo
1155
+
Pull *models.Pull
1156
+
Stack models.Stack
1157
Diff *types.NiceDiff
1158
Round int
1159
+
Submission *models.PullSubmission
1160
+
OrderedReactionKinds []models.ReactionKind
1161
DiffOpts types.DiffOpts
1162
}
1163
···
1169
type RepoPullInterdiffParams struct {
1170
LoggedInUser *oauth.User
1171
RepoInfo repoinfo.RepoInfo
1172
+
Pull *models.Pull
1173
Round int
1174
Interdiff *patchutil.InterdiffResult
1175
+
OrderedReactionKinds []models.ReactionKind
1176
DiffOpts types.DiffOpts
1177
}
1178
···
1201
1202
type PullCompareForkParams struct {
1203
RepoInfo repoinfo.RepoInfo
1204
+
Forks []models.Repo
1205
Selected string
1206
}
1207
···
1222
type PullResubmitParams struct {
1223
LoggedInUser *oauth.User
1224
RepoInfo repoinfo.RepoInfo
1225
+
Pull *models.Pull
1226
SubmissionId int
1227
}
1228
···
1233
type PullActionsParams struct {
1234
LoggedInUser *oauth.User
1235
RepoInfo repoinfo.RepoInfo
1236
+
Pull *models.Pull
1237
RoundNumber int
1238
MergeCheck types.MergeCheckResponse
1239
ResubmitCheck ResubmitResult
1240
+
Stack models.Stack
1241
}
1242
1243
func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error {
···
1247
type PullNewCommentParams struct {
1248
LoggedInUser *oauth.User
1249
RepoInfo repoinfo.RepoInfo
1250
+
Pull *models.Pull
1251
RoundNumber int
1252
}
1253
···
1258
type RepoCompareParams struct {
1259
LoggedInUser *oauth.User
1260
RepoInfo repoinfo.RepoInfo
1261
+
Forks []models.Repo
1262
Branches []types.Branch
1263
Tags []*types.TagReference
1264
Base string
···
1277
type RepoCompareNewParams struct {
1278
LoggedInUser *oauth.User
1279
RepoInfo repoinfo.RepoInfo
1280
+
Forks []models.Repo
1281
Branches []types.Branch
1282
Tags []*types.TagReference
1283
Base string
···
1312
return p.executePlain("repo/fragments/diff", w, []any{params.RepoInfo.FullName, ¶ms.Diff})
1313
}
1314
1315
+
type LabelPanelParams struct {
1316
+
LoggedInUser *oauth.User
1317
+
RepoInfo repoinfo.RepoInfo
1318
+
Defs map[string]*models.LabelDefinition
1319
+
Subject string
1320
+
State models.LabelState
1321
+
}
1322
+
1323
+
func (p *Pages) LabelPanel(w io.Writer, params LabelPanelParams) error {
1324
+
return p.executePlain("repo/fragments/labelPanel", w, params)
1325
+
}
1326
+
1327
+
type EditLabelPanelParams struct {
1328
+
LoggedInUser *oauth.User
1329
+
RepoInfo repoinfo.RepoInfo
1330
+
Defs map[string]*models.LabelDefinition
1331
+
Subject string
1332
+
State models.LabelState
1333
+
}
1334
+
1335
+
func (p *Pages) EditLabelPanel(w io.Writer, params EditLabelPanelParams) error {
1336
+
return p.executePlain("repo/fragments/editLabelPanel", w, params)
1337
+
}
1338
+
1339
type PipelinesParams struct {
1340
LoggedInUser *oauth.User
1341
RepoInfo repoinfo.RepoInfo
1342
+
Pipelines []models.Pipeline
1343
Active string
1344
}
1345
···
1371
type WorkflowParams struct {
1372
LoggedInUser *oauth.User
1373
RepoInfo repoinfo.RepoInfo
1374
+
Pipeline models.Pipeline
1375
Workflow string
1376
LogUrl string
1377
Active string
···
1387
Action string
1388
1389
// this is supplied in the case of editing an existing string
1390
+
String models.String
1391
}
1392
1393
func (p *Pages) PutString(w io.Writer, params PutStringParams) error {
···
1397
type StringsDashboardParams struct {
1398
LoggedInUser *oauth.User
1399
Card ProfileCard
1400
+
Strings []models.String
1401
}
1402
1403
func (p *Pages) StringsDashboard(w io.Writer, params StringsDashboardParams) error {
···
1406
1407
type StringTimelineParams struct {
1408
LoggedInUser *oauth.User
1409
+
Strings []models.String
1410
}
1411
1412
func (p *Pages) StringsTimeline(w io.Writer, params StringTimelineParams) error {
···
1418
ShowRendered bool
1419
RenderToggle bool
1420
RenderedContents template.HTML
1421
+
String models.String
1422
+
Stats models.StringStats
1423
Owner identity.Identity
1424
}
1425
+7
-6
appview/pages/repoinfo/repoinfo.go
+7
-6
appview/pages/repoinfo/repoinfo.go
···
7
"strings"
8
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
-
"tangled.sh/tangled.sh/core/appview/db"
11
-
"tangled.sh/tangled.sh/core/appview/state/userutil"
12
)
13
14
func (r RepoInfo) OwnerWithAt() string {
···
24
}
25
26
func (r RepoInfo) OwnerWithoutAt() string {
27
-
if strings.HasPrefix(r.OwnerWithAt(), "@") {
28
-
return strings.TrimPrefix(r.OwnerWithAt(), "@")
29
} else {
30
return userutil.FlattenDid(r.OwnerDid)
31
}
···
52
53
type RepoInfo struct {
54
Name string
55
OwnerDid string
56
OwnerHandle string
57
Description string
···
59
Spindle string
60
RepoAt syntax.ATURI
61
IsStarred bool
62
-
Stats db.RepoStats
63
Roles RolesInRepo
64
-
Source *db.Repo
65
SourceHandle string
66
Ref string
67
DisableFork bool
···
7
"strings"
8
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
+
"tangled.org/core/appview/models"
11
+
"tangled.org/core/appview/state/userutil"
12
)
13
14
func (r RepoInfo) OwnerWithAt() string {
···
24
}
25
26
func (r RepoInfo) OwnerWithoutAt() string {
27
+
if after, ok := strings.CutPrefix(r.OwnerWithAt(), "@"); ok {
28
+
return after
29
} else {
30
return userutil.FlattenDid(r.OwnerDid)
31
}
···
52
53
type RepoInfo struct {
54
Name string
55
+
Rkey string
56
OwnerDid string
57
OwnerHandle string
58
Description 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
+224
appview/pages/templates/brand/brand.html
+224
appview/pages/templates/brand/brand.html
···
···
1
+
{{ define "title" }}brand{{ end }}
2
+
3
+
{{ define "content" }}
4
+
<div class="grid grid-cols-10">
5
+
<header class="col-span-full md:col-span-10 px-6 py-2 mb-4">
6
+
<h1 class="text-2xl font-bold dark:text-white mb-1">Brand</h1>
7
+
<p class="text-gray-600 dark:text-gray-400 mb-1">
8
+
Assets and guidelines for using Tangled's logo and brand elements.
9
+
</p>
10
+
</header>
11
+
12
+
<main class="col-span-full md:col-span-10 bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10">
13
+
<div class="space-y-16">
14
+
15
+
<!-- Introduction Section -->
16
+
<section>
17
+
<p class="text-gray-600 dark:text-gray-400 mb-2">
18
+
Tangled's logo and mascot is <strong>Dolly</strong>, the first ever <em>cloned</em> mammal. Please
19
+
follow the below guidelines when using Dolly and the logotype.
20
+
</p>
21
+
<p class="text-gray-600 dark:text-gray-400 mb-2">
22
+
All assets are served as SVGs, and can be downloaded by right-clicking and clicking "Save image as".
23
+
</p>
24
+
</section>
25
+
26
+
<!-- Black Logotype Section -->
27
+
<section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
28
+
<div class="order-2 lg:order-1">
29
+
<div class="border border-gray-200 dark:border-gray-700 p-8 sm:p-16 bg-gray-50 dark:bg-gray-100 rounded">
30
+
<img src="https://assets.tangled.network/tangled_logotype_black_on_trans.svg"
31
+
alt="Tangled logo - black version"
32
+
class="w-full max-w-sm mx-auto" />
33
+
</div>
34
+
</div>
35
+
<div class="order-1 lg:order-2">
36
+
<h2 class="text-xl font-semibold dark:text-white mb-3">Black logotype</h2>
37
+
<p class="text-gray-600 dark:text-gray-400 mb-4">For use on light-colored backgrounds.</p>
38
+
<p class="text-gray-700 dark:text-gray-300">
39
+
This is the preferred version of the logotype, featuring dark text and elements, ideal for light
40
+
backgrounds and designs.
41
+
</p>
42
+
</div>
43
+
</section>
44
+
45
+
<!-- White Logotype Section -->
46
+
<section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
47
+
<div class="order-2 lg:order-1">
48
+
<div class="bg-black p-8 sm:p-16 rounded">
49
+
<img src="https://assets.tangled.network/tangled_logotype_white_on_trans.svg"
50
+
alt="Tangled logo - white version"
51
+
class="w-full max-w-sm mx-auto" />
52
+
</div>
53
+
</div>
54
+
<div class="order-1 lg:order-2">
55
+
<h2 class="text-xl font-semibold dark:text-white mb-3">White logotype</h2>
56
+
<p class="text-gray-600 dark:text-gray-400 mb-4">For use on dark-colored backgrounds.</p>
57
+
<p class="text-gray-700 dark:text-gray-300">
58
+
This version features white text and elements, ideal for dark backgrounds
59
+
and inverted designs.
60
+
</p>
61
+
</div>
62
+
</section>
63
+
64
+
<!-- Mark Only Section -->
65
+
<section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
66
+
<div class="order-2 lg:order-1">
67
+
<div class="grid grid-cols-2 gap-2">
68
+
<!-- Black mark on light background -->
69
+
<div class="border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-100 p-8 sm:p-12 rounded">
70
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg"
71
+
alt="Dolly face - black version"
72
+
class="w-full max-w-16 mx-auto" />
73
+
</div>
74
+
<!-- White mark on dark background -->
75
+
<div class="bg-black p-8 sm:p-12 rounded">
76
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg"
77
+
alt="Dolly face - white version"
78
+
class="w-full max-w-16 mx-auto" />
79
+
</div>
80
+
</div>
81
+
</div>
82
+
<div class="order-1 lg:order-2">
83
+
<h2 class="text-xl font-semibold dark:text-white mb-3">Mark only</h2>
84
+
<p class="text-gray-600 dark:text-gray-400 mb-4">
85
+
When a smaller 1:1 logo or icon is needed, Dolly's face may be used on its own.
86
+
</p>
87
+
<p class="text-gray-700 dark:text-gray-300 mb-4">
88
+
<strong class="font-semibold">Note</strong>: for situations where the background
89
+
is unknown, use the black version for ideal contrast in most environments.
90
+
</p>
91
+
</div>
92
+
</section>
93
+
94
+
<!-- Colored Backgrounds Section -->
95
+
<section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
96
+
<div class="order-2 lg:order-1">
97
+
<div class="grid grid-cols-2 gap-2">
98
+
<!-- Pastel Green background -->
99
+
<div class="bg-green-500 p-8 sm:p-12 rounded">
100
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg"
101
+
alt="Tangled logo on pastel green background"
102
+
class="w-full max-w-16 mx-auto" />
103
+
</div>
104
+
<!-- Pastel Blue background -->
105
+
<div class="bg-blue-500 p-8 sm:p-12 rounded">
106
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg"
107
+
alt="Tangled logo on pastel blue background"
108
+
class="w-full max-w-16 mx-auto" />
109
+
</div>
110
+
<!-- Pastel Yellow background -->
111
+
<div class="bg-yellow-500 p-8 sm:p-12 rounded">
112
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg"
113
+
alt="Tangled logo on pastel yellow background"
114
+
class="w-full max-w-16 mx-auto" />
115
+
</div>
116
+
<!-- Pastel Red background -->
117
+
<div class="bg-red-500 p-8 sm:p-12 rounded">
118
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg"
119
+
alt="Tangled logo on pastel red background"
120
+
class="w-full max-w-16 mx-auto" />
121
+
</div>
122
+
</div>
123
+
</div>
124
+
<div class="order-1 lg:order-2">
125
+
<h2 class="text-xl font-semibold dark:text-white mb-3">Colored backgrounds</h2>
126
+
<p class="text-gray-600 dark:text-gray-400 mb-4">
127
+
White logo mark on colored backgrounds.
128
+
</p>
129
+
<p class="text-gray-700 dark:text-gray-300 mb-4">
130
+
The white logo mark provides contrast on colored backgrounds.
131
+
Perfect for more fun design contexts.
132
+
</p>
133
+
</div>
134
+
</section>
135
+
136
+
<!-- Black Logo on Pastel Backgrounds Section -->
137
+
<section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
138
+
<div class="order-2 lg:order-1">
139
+
<div class="grid grid-cols-2 gap-2">
140
+
<!-- Pastel Green background -->
141
+
<div class="bg-green-200 p-8 sm:p-12 rounded">
142
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg"
143
+
alt="Tangled logo on pastel green background"
144
+
class="w-full max-w-16 mx-auto" />
145
+
</div>
146
+
<!-- Pastel Blue background -->
147
+
<div class="bg-blue-200 p-8 sm:p-12 rounded">
148
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg"
149
+
alt="Tangled logo on pastel blue background"
150
+
class="w-full max-w-16 mx-auto" />
151
+
</div>
152
+
<!-- Pastel Yellow background -->
153
+
<div class="bg-yellow-200 p-8 sm:p-12 rounded">
154
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg"
155
+
alt="Tangled logo on pastel yellow background"
156
+
class="w-full max-w-16 mx-auto" />
157
+
</div>
158
+
<!-- Pastel Pink background -->
159
+
<div class="bg-pink-200 p-8 sm:p-12 rounded">
160
+
<img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg"
161
+
alt="Tangled logo on pastel pink background"
162
+
class="w-full max-w-16 mx-auto" />
163
+
</div>
164
+
</div>
165
+
</div>
166
+
<div class="order-1 lg:order-2">
167
+
<h2 class="text-xl font-semibold dark:text-white mb-3">Lighter backgrounds</h2>
168
+
<p class="text-gray-600 dark:text-gray-400 mb-4">
169
+
Dark logo mark on lighter, pastel backgrounds.
170
+
</p>
171
+
<p class="text-gray-700 dark:text-gray-300 mb-4">
172
+
The dark logo mark works beautifully on pastel backgrounds,
173
+
providing crisp contrast.
174
+
</p>
175
+
</div>
176
+
</section>
177
+
178
+
<!-- Recoloring Section -->
179
+
<section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
180
+
<div class="order-2 lg:order-1">
181
+
<div class="bg-yellow-100 border border-yellow-200 p-8 sm:p-16 rounded">
182
+
<img src="https://assets.tangled.network/tangled_logotype_black_on_trans.svg"
183
+
alt="Recolored Tangled logotype in gray/sand color"
184
+
class="w-full max-w-sm mx-auto opacity-60 sepia contrast-75 saturate-50" />
185
+
</div>
186
+
</div>
187
+
<div class="order-1 lg:order-2">
188
+
<h2 class="text-xl font-semibold dark:text-white mb-3">Recoloring</h2>
189
+
<p class="text-gray-600 dark:text-gray-400 mb-4">
190
+
Custom coloring of the logotype is permitted.
191
+
</p>
192
+
<p class="text-gray-700 dark:text-gray-300 mb-4">
193
+
Recoloring the logotype is allowed as long as readability is maintained.
194
+
</p>
195
+
<p class="text-gray-700 dark:text-gray-300 text-sm">
196
+
<strong>Example:</strong> Gray/sand colored logotype on a light yellow/tan background.
197
+
</p>
198
+
</div>
199
+
</section>
200
+
201
+
<!-- Silhouette Section -->
202
+
<section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
203
+
<div class="order-2 lg:order-1">
204
+
<div class="border border-gray-200 dark:border-gray-700 p-8 sm:p-16 bg-gray-50 dark:bg-gray-100 rounded">
205
+
<img src="https://assets.tangled.network/tangled_dolly_silhouette.svg"
206
+
alt="Dolly silhouette"
207
+
class="w-full max-w-32 mx-auto" />
208
+
</div>
209
+
</div>
210
+
<div class="order-1 lg:order-2">
211
+
<h2 class="text-xl font-semibold dark:text-white mb-3">Dolly silhouette</h2>
212
+
<p class="text-gray-600 dark:text-gray-400 mb-4">A minimalist version of Dolly.</p>
213
+
<p class="text-gray-700 dark:text-gray-300">
214
+
The silhouette can be used where a subtle brand presence is needed,
215
+
or as a background element. Works on any background color with proper contrast.
216
+
For example, we use this as the site's favicon.
217
+
</p>
218
+
</div>
219
+
</section>
220
+
221
+
</div>
222
+
</main>
223
+
</div>
224
+
{{ end }}
+4
-11
appview/pages/templates/errors/500.html
+4
-11
appview/pages/templates/errors/500.html
···
5
<div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto">
6
<div class="mb-6">
7
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
8
-
{{ i "alert-triangle" "w-8 h-8 text-red-500 dark:text-red-400" }}
9
</div>
10
</div>
11
···
14
500 — internal server error
15
</h1>
16
<p class="text-gray-600 dark:text-gray-300">
17
-
Something went wrong on our end. We've been notified and are working to fix the issue.
18
-
</p>
19
-
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded p-3 text-sm text-yellow-800 dark:text-yellow-200">
20
-
<div class="flex items-center gap-2">
21
-
{{ i "info" "w-4 h-4" }}
22
-
<span class="font-medium">we're on it!</span>
23
-
</div>
24
-
<p class="mt-1">Our team has been automatically notified about this error.</p>
25
-
</div>
26
<div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6">
27
<button onclick="location.reload()" class="btn-create gap-2">
28
{{ i "refresh-cw" "w-4 h-4" }}
29
try again
30
</button>
31
<a href="/" class="btn no-underline hover:no-underline gap-2">
32
-
{{ i "home" "w-4 h-4" }}
33
back to home
34
</a>
35
</div>
···
5
<div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto">
6
<div class="mb-6">
7
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
8
+
{{ i "triangle-alert" "w-8 h-8 text-red-500 dark:text-red-400" }}
9
</div>
10
</div>
11
···
14
500 — internal server error
15
</h1>
16
<p class="text-gray-600 dark:text-gray-300">
17
+
We encountered an error while processing your request. Please try again later.
18
+
</p>
19
<div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6">
20
<button onclick="location.reload()" class="btn-create gap-2">
21
{{ i "refresh-cw" "w-4 h-4" }}
22
try again
23
</button>
24
<a href="/" class="btn no-underline hover:no-underline gap-2">
25
+
{{ i "arrow-left" "w-4 h-4" }}
26
back to home
27
</a>
28
</div>
-26
appview/pages/templates/favicon.html
-26
appview/pages/templates/favicon.html
···
1
-
{{ define "favicon" }}
2
-
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32">
3
-
<style>
4
-
.favicon-text {
5
-
fill: #000000;
6
-
stroke: none;
7
-
}
8
-
9
-
@media (prefers-color-scheme: dark) {
10
-
.favicon-text {
11
-
fill: #ffffff;
12
-
stroke: none;
13
-
}
14
-
}
15
-
</style>
16
-
17
-
<g style="display:inline">
18
-
<path d="M0-2.117h62.177v25.135H0z" style="display:inline;fill:none;fill-opacity:1;stroke-width:.396875" transform="translate(11.01 6.9)"/>
19
-
<path d="M3.64 22.787c-1.697 0-2.943-.45-3.74-1.35-.77-.9-1.156-2.094-1.156-3.585 0-.36.013-.72.038-1.08.052-.385.129-.873.232-1.464L.44 6.826h-5.089l.733-4.394h3.2c.822 0 1.439-.168 1.85-.502.437-.334.72-.938.848-1.812l.771-4.703h5.243L6.84 2.432h7.787l-.733 4.394H6.107L4.257 17.93l.77.27 6.015-4.742 2.775 3.161-2.313 2.005c-.822.694-1.568 1.31-2.236 1.85-.668.515-1.31.952-1.927 1.311a7.406 7.406 0 0 1-1.774.733c-.59.18-1.233.27-1.927.27z"
20
-
aria-label="tangled.sh"
21
-
class="favicon-text"
22
-
style="font-size:16.2278px;font-family:'IBM Plex Mono';-inkscape-font-specification:'IBM Plex Mono, Normal';display:inline;fill-opacity:1"
23
-
transform="translate(11.01 6.9)"/>
24
-
</g>
25
-
</svg>
26
-
{{ end }}
···
+56
appview/pages/templates/fragments/dolly/logo.html
+56
appview/pages/templates/fragments/dolly/logo.html
···
···
1
+
{{ define "fragments/dolly/logo" }}
2
+
<svg
3
+
version="1.1"
4
+
id="svg1"
5
+
class="{{.}}"
6
+
width="25"
7
+
height="25"
8
+
viewBox="0 0 25 25"
9
+
sodipodi:docname="tangled_dolly_face_only.png"
10
+
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
11
+
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
12
+
xmlns:xlink="http://www.w3.org/1999/xlink"
13
+
xmlns="http://www.w3.org/2000/svg"
14
+
xmlns:svg="http://www.w3.org/2000/svg">
15
+
<title>Dolly</title>
16
+
<defs
17
+
id="defs1" />
18
+
<sodipodi:namedview
19
+
id="namedview1"
20
+
pagecolor="#ffffff"
21
+
bordercolor="#000000"
22
+
borderopacity="0.25"
23
+
inkscape:showpageshadow="2"
24
+
inkscape:pageopacity="0.0"
25
+
inkscape:pagecheckerboard="true"
26
+
inkscape:deskcolor="#d5d5d5">
27
+
<inkscape:page
28
+
x="0"
29
+
y="0"
30
+
width="25"
31
+
height="25"
32
+
id="page2"
33
+
margin="0"
34
+
bleed="0" />
35
+
</sodipodi:namedview>
36
+
<g
37
+
inkscape:groupmode="layer"
38
+
inkscape:label="Image"
39
+
id="g1">
40
+
<image
41
+
width="252.48"
42
+
height="248.96001"
43
+
preserveAspectRatio="none"
44
+
xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAxUAAAMKCAYAAADznWlEAAABg2lDQ1BJQ0MgcHJvZmlsZQAAKJF9 kT1Iw0AcxV9TpVoqDmYQcchQneyiIo61CkWoEGqFVh1MLv2CJi1Jiouj4Fpw8GOx6uDirKuDqyAI foC4C06KLlLi/5JCixgPjvvx7t7j7h0gNCtMt3rigG7YZjqZkLK5VSn0in6EISKGsMKs2pwsp+A7 vu4R4OtdjGf5n/tzDGh5iwEBiTjOaqZNvEE8s2nXOO8Ti6ykaMTnxBMmXZD4keuqx2+ciy4LPFM0 M+l5YpFYKnax2sWsZOrE08RRTTcoX8h6rHHe4qxX6qx9T/7CSN5YWeY6zVEksYglyJCgoo4yKrCp rzIMUiykaT/h4x9x/TK5VHKVwcixgCp0KK4f/A9+d2sVpia9pEgC6H1xnI8xILQLtBqO833sOK0T IPgMXBkdf7UJzH6S3uho0SNgcBu4uO5o6h5wuQMMP9UUU3GlIE2hUADez+ibcsDQLRBe83pr7+P0 AchQV6kb4OAQGC9S9rrPu/u6e/v3TLu/H4tGcrDFxPPTAAAABmJLR0QAAAAAAAD5Q7t/AAAACXBI WXMAAC4jAAAuIwF4pT92AAAAB3RJTUUH6QkPFQ8jl/6e6wAAABl0RVh0Q29tbWVudABDcmVhdGVk IHdpdGggR0lNUFeBDhcAACAASURBVHic7N3nc5xXmqb564UjCXoripShvKNMqVSSynWXmZ6emd39 NhH7R+6HjdiN6d3emZ7uru6ukqpKXUbeUKIMJZKitwBhzn64z1uZpEiJIAGkea9fRAZIgCCTQCLz 3O95nueAJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmS JEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmS JEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmS JEmSJEmSJEmSJEmSJOkONYO+A5IkjYtSSgNM0Ht9LX036vvbj7fvX+77K/pfl0vTNAVJGgGGCkmS vkUNCjRNU+qvG2ASmKpvJ+rb9n3T9e0EvTCx3Pfr/o8ttf9M/fVS/bOL9bbU97HlvrcT7d/bNE1/ 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 0q8XJ0mS+snQD3/4w5Ef//jHE/fdd9/Wffv2TW3ZsmViaGhotCzL4bIsR1qt1mir1RpvtVpbgJ3A rcC++utNwBSfDUD6Qe9kpjlSxnSW9D+crl9n69dFElhcIlmIebKUbqH+8ys2XEsbl0GFJEnrr7V/ //6he++9d2R8fHyCBBA3kuBiB7ANGCdlVC2gKMuyqKqqVZZlM962KIqiaLVaFEXRvJrMRxsYrf8Z k/XXYYCyLKuyLKmqqirLsvefUbXb7SaT0DQ8T9TvZ0/93qZI1UNJt1zpEgkQLtSvi6Tv4QIpZTpf f21+b5YEHIskC2HwIPUhgwpJkvpPb5lUqyiK1k9/+tPioYceKu6++2727dvH1q1bi8nJyWJoaKhF HvzHSRAwBdxAPalqaWmJxcXFanl5uVpZWSlarVZreHiYkZGRamxsbLlufl6u/503ALcAdwN3kgzK KAkoLvKPWYhpulmIWbpZhw4JRD77H2U5k9S37KmQJKn/VEDnBz/4QTk1NcXu3bu5/fbb2b59O+Pj 4wwNDdFkLGoFyRAUn3v9g38yqar3Qb8gQcRWkknZTrIgcyRDMUMChyVghbr3ge5uiQp7IaSBZKZC kiR9aVVVNVmSofpV0G267gAdgwZJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJktbC /wcO9A7eMaXQEQAAAABJRU5ErkJggg== "
45
+
id="image1"
46
+
x="-233.6257"
47
+
y="10.383364"
48
+
style="display:none" />
49
+
<path
50
+
fill="currentColor"
51
+
style="stroke-width:0.111183"
52
+
d="m 16.348974,24.09935 -0.06485,-0.03766 -0.202005,-0.0106 -0.202008,-0.01048 -0.275736,-0.02601 -0.275734,-0.02602 v -0.02649 -0.02648 l -0.204577,-0.04019 -0.204578,-0.04019 -0.167616,-0.08035 -0.167617,-0.08035 -0.0014,-0.04137 -0.0014,-0.04137 -0.266473,-0.143735 -0.266475,-0.143735 -0.276098,-0.20335 -0.2761,-0.203347 -0.262064,-0.251949 -0.262064,-0.25195 -0.22095,-0.284628 -0.220948,-0.284629 -0.170253,-0.284631 -0.170252,-0.284628 -0.01341,-0.0144 -0.0134,-0.0144 -0.141982,0.161297 -0.14198,0.1613 -0.22313,0.21426 -0.223132,0.214264 -0.186025,0.146053 -0.186023,0.14605 -0.252501,0.163342 -0.252502,0.163342 -0.249014,0.115348 -0.249013,0.115336 0.0053,0.03241 0.0053,0.03241 -0.1716725,0.04599 -0.171669,0.046 -0.3379966,0.101058 -0.3379972,0.101058 -0.1778925,0.04506 -0.1778935,0.04508 -0.3913655,0.02601 -0.3913643,0.02603 -0.3557868,-0.03514 -0.3557863,-0.03514 -0.037426,-0.03029 -0.037427,-0.03029 -0.076924,0.02011 -0.076924,0.02011 -0.050508,-0.05051 -0.050405,-0.05056 L 6.6604532,23.110188 6.451745,23.063961 6.1546135,22.960559 5.8574835,22.857156 5.5319879,22.694039 5.2064938,22.530922 4.8793922,22.302961 4.5522905,22.075005 4.247598,21.786585 3.9429055,21.49817 3.7185335,21.208777 3.4941628,20.919385 3.3669822,20.705914 3.239803,20.492443 3.1335213,20.278969 3.0272397,20.065499 2.9015252,19.7275 2.7758105,19.389504 2.6925225,18.998139 2.6092345,18.606774 2.6096814,17.91299 2.6101284,17.219208 2.6744634,16.90029 2.7387984,16.581374 2.8474286,16.242088 2.9560588,15.9028 3.1137374,15.583492 3.2714148,15.264182 3.3415068,15.150766 3.4115988,15.03735 3.3127798,14.96945 3.2139618,14.90157 3.0360685,14.800239 2.8581753,14.698908 2.5913347,14.503228 2.3244955,14.307547 2.0621238,14.055599 1.7997507,13.803651 1.6111953,13.56878 1.4226411,13.333906 1.2632237,13.087474 1.1038089,12.841042 0.97442,12.575195 0.8450307,12.30935 0.724603,11.971351 0.6041766,11.633356 0.52150365,11.241991 0.4388285,10.850626 0.44091592,10.156842 0.44300333,9.4630594 0.54235911,9.0369608 0.6417149,8.6108622 0.7741173,8.2694368 0.9065196,7.9280115 1.0736303,7.6214262 1.2407515,7.3148397 1.45931,7.0191718 1.6778685,6.7235039 1.9300326,6.4611321 2.1821966,6.1987592 2.4134579,6.0137228 2.6447193,5.8286865 2.8759792,5.6776409 3.1072406,5.526594 3.4282004,5.3713977 3.7491603,5.2162016 3.9263009,5.1508695 4.1034416,5.0855373 4.2813348,4.7481598 4.4592292,4.4107823 4.6718,4.108422 4.8843733,3.8060618 5.198353,3.4805372 5.5123313,3.155014 5.7685095,2.9596425 6.0246877,2.7642722 6.329187,2.5851365 6.6336863,2.406002 6.9497657,2.2751596 7.2658453,2.1443184 7.4756394,2.0772947 7.6854348,2.01027 8.0825241,1.931086 8.4796139,1.851902 l 0.5870477,0.00291 0.5870469,0.00291 0.4447315,0.092455 0.444734,0.092455 0.302419,0.1105495 0.302417,0.1105495 0.329929,0.1646046 0.32993,0.1646033 0.239329,-0.2316919 0.239329,-0.2316919 0.160103,-0.1256767 0.160105,-0.1256767 0.160102,-0.1021909 0.160105,-0.1021899 0.142315,-0.082328 0.142314,-0.082328 0.231262,-0.1090091 0.231259,-0.1090091 0.26684,-0.098743 0.266839,-0.098743 0.320208,-0.073514 0.320209,-0.073527 0.355787,-0.041833 0.355785,-0.041834 0.426942,0.023827 0.426945,0.023828 0.355785,0.071179 0.355788,0.0711791 0.284627,0.09267 0.284629,0.09267 0.28514,0.1310267 0.28514,0.1310255 0.238179,0.1446969 0.238174,0.1446979 0.259413,0.1955332 0.259413,0.1955319 0.290757,0.296774 0.290758,0.2967753 0.151736,0.1941581 0.151734,0.1941594 0.135326,0.2149951 0.135327,0.2149952 0.154755,0.3202073 0.154758,0.3202085 0.09409,0.2677358 0.09409,0.267737 0.06948,0.3319087 0.06948,0.3319099 0.01111,0.00808 0.01111,0.00808 0.444734,0.2173653 0.444734,0.2173665 0.309499,0.2161102 0.309497,0.2161101 0.309694,0.2930023 0.309694,0.2930037 0.18752,0.2348726 0.187524,0.2348727 0.166516,0.2574092 0.166519,0.2574108 0.15273,0.3260252 0.152734,0.3260262 0.08972,0.2668403 0.08971,0.2668391 0.08295,0.3913655 0.08295,0.3913652 -6.21e-4,0.6582049 -6.21e-4,0.658204 -0.06362,0.315725 -0.06362,0.315725 -0.09046,0.289112 -0.09046,0.289112 -0.122759,0.281358 -0.12276,0.281356 -0.146626,0.252323 -0.146629,0.252322 -0.190443,0.258668 -0.190448,0.258671 -0.254911,0.268356 -0.254911,0.268355 -0.286872,0.223127 -0.286874,0.223127 -0.320203,0.187693 -0.320209,0.187693 -0.04347,0.03519 -0.04347,0.03521 0.0564,0.12989 0.0564,0.129892 0.08728,0.213472 0.08728,0.213471 0.189755,0.729363 0.189753,0.729362 0.0652,0.302417 0.0652,0.302419 -0.0018,0.675994 -0.0018,0.675995 -0.0801,0.373573 -0.08009,0.373577 -0.09,0.266839 -0.09,0.26684 -0.190389,0.391364 -0.19039,0.391366 -0.223169,0.320207 -0.223167,0.320209 -0.303585,0.315294 -0.303584,0.315291 -0.284631,0.220665 -0.284629,0.220663 -0.220128,0.132359 -0.220127,0.132358 -0.242395,0.106698 -0.242394,0.106699 -0.08895,0.04734 -0.08895,0.04733 -0.249052,0.07247 -0.24905,0.07247 -0.322042,0.0574 -0.322044,0.0574 -0.282794,-0.003 -0.282795,-0.003 -0.07115,-0.0031 -0.07115,-0.0031 -0.177894,-0.0033 -0.177893,-0.0033 -0.124528,0.02555 -0.124528,0.02555 z m -4.470079,-5.349839 0.214838,-0.01739 0.206601,-0.06782 0.206602,-0.06782 0.244389,-0.117874 0.244393,-0.11786 0.274473,-0.206822 0.27447,-0.20682 0.229308,-0.257201 0.229306,-0.2572 0.219161,-0.28463 0.219159,-0.284629 0.188541,-0.284628 0.188543,-0.28463 0.214594,-0.373574 0.214593,-0.373577 0.133861,-0.312006 0.133865,-0.312007 0.02861,-0.01769 0.02861,-0.01769 0.197275,0.26212 0.197278,0.262119 0.163613,0.150814 0.163614,0.150814 0.201914,0.09276 0.201914,0.09276 0.302417,0.01421 0.302418,0.01421 0.213472,-0.08025 0.213471,-0.08025 0.200606,-0.204641 0.200606,-0.204642 0.09242,-0.278887 0.09241,-0.278888 0.05765,-0.302418 0.05764,-0.302416 L 18.41327,13.768114 18.39502,13.34117 18.31849,12.915185 18.24196,12.4892 18.15595,12.168033 18.06994,11.846867 17.928869,11.444534 17.787801,11.042201 17.621278,10.73296 17.454757,10.423723 17.337388,10.263619 17.220021,10.103516 17.095645,9.9837986 16.971268,9.8640816 16.990048,9.6813736 17.008828,9.4986654 16.947568,9.249616 16.886308,9.0005655 16.752419,8.7159355 16.618521,8.4313217 16.435707,8.2294676 16.252892,8.0276114 16.079629,7.9004245 15.906366,7.773238 l -0.20429,0.1230127 -0.204289,0.1230121 -0.26702,0.059413 -0.267022,0.059413 -0.205761,-0.021508 -0.205766,-0.021508 -0.23495,-0.08844 -0.234953,-0.08844 -0.118429,-0.090334 -0.118428,-0.090333 h -0.03944 -0.03944 L 13.711268,7.8540732 13.655958,7.9706205 13.497227,8.1520709 13.338499,8.3335203 13.168394,8.4419112 12.998289,8.550301 12.777045,8.624223 12.5558,8.698155 H 12.275611 11.995429 L 11.799973,8.6309015 11.604513,8.5636472 11.491311,8.5051061 11.37811,8.446565 11.138172,8.2254579 10.898231,8.0043497 l -0.09565,-0.084618 -0.09565,-0.084613 -0.218822,0.198024 -0.218822,0.1980231 -0.165392,0.078387 -0.1653925,0.078387 -0.177894,0.047948 -0.177892,0.047948 L 9.3635263,8.4842631 9.144328,8.4846889 8.9195029,8.4147138 8.6946778,8.3447386 8.5931214,8.4414036 8.491565,8.5380686 8.3707618,8.7019598 8.2499597,8.8658478 8.0802403,8.9290726 7.9105231,8.9922974 7.7952769,9.0780061 7.6800299,9.1637148 7.5706169,9.2778257 7.4612038,9.3919481 7.1059768,9.9205267 6.7507497,10.449105 l -0.2159851,0.449834 -0.2159839,0.449834 -0.2216572,0.462522 -0.2216559,0.462523 -0.1459343,0.337996 -0.1459342,0.337998 -0.055483,0.220042 -0.055483,0.220041 -0.015885,0.206903 -0.015872,0.206901 0.034307,0.242939 0.034307,0.24294 0.096281,0.196632 0.096281,0.196634 0.143607,0.125222 0.1436071,0.125222 0.1873143,0.08737 0.1873141,0.08737 0.2752084,0.002 0.2752084,0.002 0.2312297,-0.09773 0.231231,-0.09772 0.1067615,-0.07603 0.1067614,-0.07603 0.3679062,-0.29377 0.3679065,-0.293771 0.026804,0.01656 0.026804,0.01656 0.023626,0.466819 0.023626,0.466815 0.088326,0.513195 0.088326,0.513193 0.08897,0.364413 0.08897,0.364411 0.1315362,0.302418 0.1315352,0.302418 0.1051964,0.160105 0.1051954,0.160103 0.1104741,0.11877 0.1104731,0.118769 0.2846284,0.205644 0.2846305,0.205642 0.144448,0.07312 0.144448,0.07312 0.214787,0.05566 0.214787,0.05566 0.245601,0.03075 0.245602,0.03075 0.204577,-0.0125 0.204578,-0.0125 z m 0.686342,-3.497495 -0.11281,-0.06077 -0.106155,-0.134033 -0.106155,-0.134031 -0.04406,-0.18371 -0.04406,-0.183707 0.02417,-0.553937 0.02417,-0.553936 0.03513,-0.426945 0.03513,-0.426942 0.07225,-0.373576 0.07225,-0.373575 0.05417,-0.211338 0.05417,-0.211339 0.0674,-0.132112 0.0674,-0.132112 0.132437,-0.10916 0.132437,-0.109161 0.187436,-0.04195 0.187438,-0.04195 0.170366,0.06469 0.170364,0.06469 0.114312,0.124073 0.114313,0.124086 0.04139,0.18495 0.04139,0.184951 -0.111218,0.459845 -0.111219,0.459844 -0.03383,0.26584 -0.03382,0.265841 -0.03986,0.818307 -0.03986,0.818309 -0.0378,0.15162 -0.03779,0.151621 -0.11089,0.110562 -0.110891,0.110561 -0.114489,0.04913 -0.114489,0.04913 -0.187932,-0.0016 -0.187929,-0.0016 z m -2.8087655,-0.358124 -0.146445,-0.06848 -0.088025,-0.119502 -0.088024,-0.119502 -0.038581,-0.106736 -0.038581,-0.106736 -0.02237,-0.134956 -0.02239,-0.134957 -0.031955,-0.46988 -0.031955,-0.469881 0.036203,-0.444733 0.036203,-0.444731 0.048862,-0.215257 0.048862,-0.215255 0.076082,-0.203349 0.076081,-0.203348 0.0936,-0.111244 0.0936,-0.111245 0.143787,-0.06531 0.1437865,-0.06532 h 0.142315 0.142314 l 0.142314,0.06588 0.142316,0.06588 0.093,0.102325 0.093,0.102325 0.04042,0.120942 0.04042,0.120942 v 0.152479 0.152477 l -0.03347,0.08804 -0.03347,0.08805 -0.05693,0.275653 -0.05693,0.275651 2.11e-4,0.430246 2.12e-4,0.430243 0.04294,0.392646 0.04295,0.392647 -0.09189,0.200702 -0.09189,0.200702 -0.148688,0.0984 -0.148687,0.0984 -0.20136,0.01212 -0.2013595,0.01212 z"
53
+
id="path4" />
54
+
</g>
55
+
</svg>
56
+
{{ end }}
+57
appview/pages/templates/fragments/dolly/silhouette.html
+57
appview/pages/templates/fragments/dolly/silhouette.html
···
···
1
+
{{ define "fragments/dolly/silhouette" }}
2
+
<svg
3
+
version="1.1"
4
+
id="svg1"
5
+
width="32"
6
+
height="32"
7
+
viewBox="0 0 25 25"
8
+
sodipodi:docname="tangled_dolly_silhouette.png"
9
+
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
10
+
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
11
+
xmlns="http://www.w3.org/2000/svg"
12
+
xmlns:svg="http://www.w3.org/2000/svg">
13
+
<style>
14
+
.dolly {
15
+
color: #000000;
16
+
}
17
+
18
+
@media (prefers-color-scheme: dark) {
19
+
.dolly {
20
+
color: #ffffff;
21
+
}
22
+
}
23
+
</style>
24
+
<title>Dolly</title>
25
+
<defs
26
+
id="defs1" />
27
+
<sodipodi:namedview
28
+
id="namedview1"
29
+
pagecolor="#ffffff"
30
+
bordercolor="#000000"
31
+
borderopacity="0.25"
32
+
inkscape:showpageshadow="2"
33
+
inkscape:pageopacity="0.0"
34
+
inkscape:pagecheckerboard="true"
35
+
inkscape:deskcolor="#d1d1d1">
36
+
<inkscape:page
37
+
x="0"
38
+
y="0"
39
+
width="25"
40
+
height="25"
41
+
id="page2"
42
+
margin="0"
43
+
bleed="0" />
44
+
</sodipodi:namedview>
45
+
<g
46
+
inkscape:groupmode="layer"
47
+
inkscape:label="Image"
48
+
id="g1">
49
+
<path
50
+
class="dolly"
51
+
fill="currentColor"
52
+
style="stroke-width:1.12248"
53
+
d="m 16.208435,23.914069 c -0.06147,-0.02273 -0.147027,-0.03034 -0.190158,-0.01691 -0.197279,0.06145 -1.31068,-0.230493 -1.388819,-0.364153 -0.01956,-0.03344 -0.163274,-0.134049 -0.319377,-0.223561 -0.550395,-0.315603 -1.010951,-0.696643 -1.428383,-1.181771 -0.264598,-0.307509 -0.597257,-0.785384 -0.597257,-0.857979 0,-0.0216 -0.02841,-0.06243 -0.06313,-0.0907 -0.04977,-0.04053 -0.160873,0.0436 -0.52488,0.397463 -0.479803,0.466432 -0.78924,0.689475 -1.355603,0.977118 -0.183693,0.0933 -0.323426,0.179989 -0.310516,0.192658 0.02801,0.02748 -0.7656391,0.270031 -1.209129,0.369517 -0.5378332,0.120647 -1.6341809,0.08626 -1.9721503,-0.06186 C 6.7977157,23.031391 6.56735,22.957551 6.3371134,22.889782 4.9717169,22.487902 3.7511914,21.481518 3.1172396,20.234838 2.6890391,19.392772 2.5582276,18.827446 2.5610489,17.831154 2.5639589,16.802192 2.7366641,16.125844 3.2142117,15.273187 3.3040457,15.112788 3.3713143,14.976533 3.3636956,14.9704 3.3560756,14.9643 3.2459634,14.90305 3.1189994,14.834381 1.7582586,14.098312 0.77760984,12.777439 0.44909837,11.23818 0.33531456,10.705039 0.33670119,9.7067968 0.45195381,9.1778795 0.72259241,7.9359287 1.3827188,6.8888436 2.4297498,6.0407205 2.6856126,5.8334648 3.2975489,5.4910878 3.6885849,5.3364049 L 4.0584319,5.190106 4.2333984,4.860432 C 4.8393906,3.7186139 5.8908314,2.7968028 7.1056396,2.3423025 7.7690673,2.0940921 8.2290216,2.0150935 9.01853,2.0137575 c 0.9625627,-0.00163 1.629181,0.1532762 2.485864,0.5776514 l 0.271744,0.1346134 0.42911,-0.3607688 c 1.082666,-0.9102346 2.185531,-1.3136811 3.578383,-1.3090327 0.916696,0.00306 1.573918,0.1517893 2.356121,0.5331927 1.465948,0.7148 2.54506,2.0625628 2.865177,3.57848 l 0.07653,0.362429 0.515095,0.2556611 c 1.022872,0.5076874 1.756122,1.1690944 2.288361,2.0641468 0.401896,0.6758594 0.537303,1.0442682 0.675505,1.8378683 0.288575,1.6570823 -0.266229,3.3548023 -1.490464,4.5608743 -0.371074,0.36557 -0.840205,0.718265 -1.203442,0.904754 -0.144112,0.07398 -0.271303,0.15826 -0.282647,0.187269 -0.01134,0.02901 0.02121,0.142764 0.07234,0.25279 0.184248,0.396467 0.451371,1.331823 0.619371,2.168779 0.463493,2.30908 -0.754646,4.693707 -2.92278,5.721632 -0.479538,0.227352 -0.717629,0.309322 -1.144194,0.39393 -0.321869,0.06383 -1.850573,0.09139 -2.000174,0.03604 z M 12.25443,18.636956 c 0.739923,-0.24652 1.382521,-0.718922 1.874623,-1.37812 0.0752,-0.100718 0.213883,-0.275851 0.308198,-0.389167 0.09432,-0.113318 0.210136,-0.271056 0.257381,-0.350531 0.416347,-0.700389 0.680936,-1.176102 0.766454,-1.378041 0.05594,-0.132087 0.114653,-0.239607 0.130477,-0.238929 0.01583,6.79e-4 0.08126,0.08531 0.145412,0.188069 0.178029,0.285173 0.614305,0.658998 0.868158,0.743878 0.259802,0.08686 0.656158,0.09598 0.911369,0.02095 0.213812,-0.06285 0.507296,-0.298016 0.645179,-0.516947 0.155165,-0.246374 0.327989,-0.989595 0.327989,-1.410501 0,-1.26718 -0.610975,-3.143405 -1.237774,-3.801045 -0.198483,-0.2082486 -0.208557,-0.2319396 -0.208557,-0.4904655 0,-0.2517771 -0.08774,-0.5704927 -0.258476,-0.938956 C 16.694963,8.50313 16.375697,8.1377479 16.135846,7.9543702 L 15.932296,7.7987471 15.683004,7.9356529 C 15.131767,8.2383821 14.435638,8.1945733 13.943459,7.8261812 L 13.782862,7.7059758 13.686773,7.8908012 C 13.338849,8.5600578 12.487087,8.8811064 11.743178,8.6233891 11.487199,8.5347109 11.358897,8.4505994 11.063189,8.1776138 L 10.69871,7.8411436 10.453484,8.0579255 C 10.318608,8.1771557 10.113778,8.3156283 9.9983037,8.3656417 9.7041488,8.4930449 9.1808299,8.5227884 8.8979004,8.4281886 8.7754792,8.3872574 8.6687415,8.3537661 8.6607053,8.3537661 c -0.03426,0 -0.3092864,0.3066098 -0.3791974,0.42275 -0.041935,0.069664 -0.1040482,0.1266636 -0.1380294,0.1266636 -0.1316419,0 -0.4197402,0.1843928 -0.6257041,0.4004735 -0.1923125,0.2017571 -0.6853701,0.9036038 -0.8926582,1.2706578 -0.042662,0.07554 -0.1803555,0.353687 -0.3059848,0.618091 -0.1256293,0.264406 -0.3270073,0.686768 -0.4475067,0.938581 -0.1204992,0.251816 -0.2469926,0.519654 -0.2810961,0.595199 -0.2592829,0.574347 -0.285919,1.391094 -0.057822,1.77304 0.1690683,0.283105 0.4224039,0.480895 0.7285507,0.568809 0.487122,0.139885 0.9109638,-0.004 1.6013422,-0.543768 l 0.4560939,-0.356568 0.0036,0.172041 c 0.01635,0.781837 0.1831084,1.813183 0.4016641,2.484154 0.1160449,0.356262 0.3781448,0.83968 0.5614081,1.035462 0.2171883,0.232025 0.7140951,0.577268 1.0100284,0.701749 0.121485,0.0511 0.351032,0.110795 0.510105,0.132647 0.396966,0.05452 1.2105,0.02265 1.448934,-0.05679 z"
54
+
id="path1" />
55
+
</g>
56
+
</svg>
57
+
{{ end }}
+9
appview/pages/templates/fragments/logotype.html
+9
appview/pages/templates/fragments/logotype.html
···
···
1
+
{{ define "fragments/logotype" }}
2
+
<span class="flex items-center gap-2">
3
+
{{ template "fragments/dolly/logo" "size-16 text-black dark:text-white" }}
4
+
<span class="font-bold text-4xl not-italic">tangled</span>
5
+
<span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1">
6
+
alpha
7
+
</span>
8
+
<span>
9
+
{{ end }}
+9
appview/pages/templates/fragments/logotypeSmall.html
+9
appview/pages/templates/fragments/logotypeSmall.html
···
···
1
+
{{ define "fragments/logotypeSmall" }}
2
+
<span class="flex items-center gap-2">
3
+
{{ template "fragments/dolly/logo" "size-8 text-black dark:text-white" }}
4
+
<span class="font-bold text-xl not-italic">tangled</span>
5
+
<span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1">
6
+
alpha
7
+
</span>
8
+
<span>
9
+
{{ end }}
+90
appview/pages/templates/fragments/multiline-select.html
+90
appview/pages/templates/fragments/multiline-select.html
···
···
1
+
{{ define "fragments/multiline-select" }}
2
+
<script>
3
+
function highlight(scroll = false) {
4
+
document.querySelectorAll(".hl").forEach(el => {
5
+
el.classList.remove("hl");
6
+
});
7
+
8
+
const hash = window.location.hash;
9
+
if (!hash || !hash.startsWith("#L")) {
10
+
return;
11
+
}
12
+
13
+
const rangeStr = hash.substring(2);
14
+
const parts = rangeStr.split("-");
15
+
let startLine, endLine;
16
+
17
+
if (parts.length === 2) {
18
+
startLine = parseInt(parts[0], 10);
19
+
endLine = parseInt(parts[1], 10);
20
+
} else {
21
+
startLine = parseInt(parts[0], 10);
22
+
endLine = startLine;
23
+
}
24
+
25
+
if (isNaN(startLine) || isNaN(endLine)) {
26
+
console.log("nan");
27
+
console.log(startLine);
28
+
console.log(endLine);
29
+
return;
30
+
}
31
+
32
+
let target = null;
33
+
34
+
for (let i = startLine; i<= endLine; i++) {
35
+
const idEl = document.getElementById(`L${i}`);
36
+
if (idEl) {
37
+
const el = idEl.closest(".line");
38
+
if (el) {
39
+
el.classList.add("hl");
40
+
target = el;
41
+
}
42
+
}
43
+
}
44
+
45
+
if (scroll && target) {
46
+
target.scrollIntoView({
47
+
behavior: "smooth",
48
+
block: "center",
49
+
});
50
+
}
51
+
}
52
+
53
+
document.addEventListener("DOMContentLoaded", () => {
54
+
console.log("DOMContentLoaded");
55
+
highlight(true);
56
+
});
57
+
window.addEventListener("hashchange", () => {
58
+
console.log("hashchange");
59
+
highlight();
60
+
});
61
+
window.addEventListener("popstate", () => {
62
+
console.log("popstate");
63
+
highlight();
64
+
});
65
+
66
+
const lineNumbers = document.querySelectorAll('a[href^="#L"');
67
+
let startLine = null;
68
+
69
+
lineNumbers.forEach(el => {
70
+
el.addEventListener("click", (event) => {
71
+
event.preventDefault();
72
+
const currentLine = parseInt(el.href.split("#L")[1]);
73
+
74
+
if (event.shiftKey && startLine !== null) {
75
+
const endLine = currentLine;
76
+
const min = Math.min(startLine, endLine);
77
+
const max = Math.max(startLine, endLine);
78
+
const newHash = `#L${min}-${max}`;
79
+
history.pushState(null, '', newHash);
80
+
} else {
81
+
const newHash = `#L${currentLine}`;
82
+
history.pushState(null, '', newHash);
83
+
startLine = currentLine;
84
+
}
85
+
86
+
highlight();
87
+
});
88
+
});
89
+
</script>
90
+
{{ end }}
+167
appview/pages/templates/goodfirstissues/index.html
+167
appview/pages/templates/goodfirstissues/index.html
···
···
1
+
{{ define "title" }}good first issues{{ end }}
2
+
3
+
{{ define "extrameta" }}
4
+
<meta property="og:title" content="good first issues · tangled" />
5
+
<meta property="og:type" content="object" />
6
+
<meta property="og:url" content="https://tangled.org/goodfirstissues" />
7
+
<meta property="og:description" content="Find good first issues to contribute to open source projects" />
8
+
{{ end }}
9
+
10
+
{{ define "content" }}
11
+
<div class="grid grid-cols-10">
12
+
<header class="col-span-full md:col-span-10 px-6 py-2 text-center flex flex-col items-center justify-center py-8">
13
+
<h1 class="scale-150 dark:text-white mb-4">
14
+
{{ template "labels/fragments/label" (dict "def" .GfiLabel "val" "" "withPrefix" true) }}
15
+
</h1>
16
+
<p class="text-gray-600 dark:text-gray-400 mb-2">
17
+
Find beginner-friendly issues across all repositories to get started with open source contributions.
18
+
</p>
19
+
</header>
20
+
21
+
<div class="col-span-full md:col-span-10 space-y-6">
22
+
{{ if eq (len .RepoGroups) 0 }}
23
+
<div class="bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10">
24
+
<div class="text-center py-16">
25
+
<div class="text-gray-500 dark:text-gray-400 mb-4">
26
+
{{ i "circle-dot" "w-16 h-16 mx-auto" }}
27
+
</div>
28
+
<h3 class="text-xl font-medium text-gray-900 dark:text-white mb-2">No good first issues available</h3>
29
+
<p class="text-gray-600 dark:text-gray-400 mb-3 max-w-md mx-auto">
30
+
There are currently no open issues labeled as "good-first-issue" across all repositories.
31
+
</p>
32
+
<p class="text-gray-500 dark:text-gray-500 text-sm max-w-md mx-auto">
33
+
Repository maintainers can add the "good-first-issue" label to beginner-friendly issues to help newcomers get started.
34
+
</p>
35
+
</div>
36
+
</div>
37
+
{{ else }}
38
+
{{ range .RepoGroups }}
39
+
<div class="mb-4 gap-1 flex flex-col drop-shadow-sm rounded bg-white dark:bg-gray-800">
40
+
<div class="flex px-6 pt-4 flex-row gap-1 items-center justify-between flex-wrap">
41
+
<div class="font-medium dark:text-white flex items-center justify-between">
42
+
<div class="flex items-center min-w-0 flex-1 mr-2">
43
+
{{ if .Repo.Source }}
44
+
{{ i "git-fork" "w-4 h-4 mr-1.5 shrink-0" }}
45
+
{{ else }}
46
+
{{ i "book-marked" "w-4 h-4 mr-1.5 shrink-0" }}
47
+
{{ end }}
48
+
{{ $repoOwner := resolve .Repo.Did }}
49
+
<a href="/{{ $repoOwner }}/{{ .Repo.Name }}" class="truncate min-w-0">{{ $repoOwner }}/{{ .Repo.Name }}</a>
50
+
</div>
51
+
</div>
52
+
53
+
54
+
{{ if .Repo.RepoStats }}
55
+
<div class="text-gray-400 text-sm font-mono inline-flex gap-4">
56
+
{{ with .Repo.RepoStats.Language }}
57
+
<div class="flex gap-2 items-center text-sm">
58
+
{{ template "repo/fragments/colorBall" (dict "color" (langColor .)) }}
59
+
<span>{{ . }}</span>
60
+
</div>
61
+
{{ end }}
62
+
{{ with .Repo.RepoStats.StarCount }}
63
+
<div class="flex gap-1 items-center text-sm">
64
+
{{ i "star" "w-3 h-3 fill-current" }}
65
+
<span>{{ . }}</span>
66
+
</div>
67
+
{{ end }}
68
+
{{ with .Repo.RepoStats.IssueCount.Open }}
69
+
<div class="flex gap-1 items-center text-sm">
70
+
{{ i "circle-dot" "w-3 h-3" }}
71
+
<span>{{ . }}</span>
72
+
</div>
73
+
{{ end }}
74
+
{{ with .Repo.RepoStats.PullCount.Open }}
75
+
<div class="flex gap-1 items-center text-sm">
76
+
{{ i "git-pull-request" "w-3 h-3" }}
77
+
<span>{{ . }}</span>
78
+
</div>
79
+
{{ end }}
80
+
</div>
81
+
{{ end }}
82
+
</div>
83
+
84
+
{{ with .Repo.Description }}
85
+
<div class="pl-6 pb-2 text-gray-600 dark:text-gray-300 text-sm line-clamp-2">
86
+
{{ . | description }}
87
+
</div>
88
+
{{ end }}
89
+
90
+
{{ if gt (len .Issues) 0 }}
91
+
<div class="grid grid-cols-1 rounded-b border-b border-t border-gray-200 dark:border-gray-900 divide-y divide-gray-200 dark:divide-gray-900">
92
+
{{ range .Issues }}
93
+
<a href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}/issues/{{ .IssueId }}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25">
94
+
<div class="py-2 px-6">
95
+
<div class="flex-grow min-w-0 w-full">
96
+
<div class="flex text-sm items-center justify-between w-full">
97
+
<div class="flex items-center gap-2 min-w-0 flex-1 pr-2">
98
+
<span class="truncate text-sm text-gray-800 dark:text-gray-200">
99
+
<span class="text-gray-500 dark:text-gray-400">#{{ .IssueId }}</span>
100
+
{{ .Title | description }}
101
+
</span>
102
+
</div>
103
+
<div class="flex-shrink-0 flex items-center gap-2 text-gray-500 dark:text-gray-400">
104
+
<span>
105
+
<div class="inline-flex items-center gap-1">
106
+
{{ i "message-square" "w-3 h-3" }}
107
+
{{ len .Comments }}
108
+
</div>
109
+
</span>
110
+
<span class="before:content-['·'] before:select-none"></span>
111
+
<span class="text-sm">
112
+
{{ template "repo/fragments/shortTimeAgo" .Created }}
113
+
</span>
114
+
<div class="hidden md:inline-flex md:gap-1">
115
+
{{ $labelState := .Labels }}
116
+
{{ range $k, $d := $.LabelDefs }}
117
+
{{ range $v, $s := $labelState.GetValSet $d.AtUri.String }}
118
+
{{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }}
119
+
{{ end }}
120
+
{{ end }}
121
+
</div>
122
+
</div>
123
+
</div>
124
+
</div>
125
+
</div>
126
+
</a>
127
+
{{ end }}
128
+
</div>
129
+
{{ end }}
130
+
</div>
131
+
{{ end }}
132
+
133
+
{{ if or (gt .Page.Offset 0) (eq (len .RepoGroups) .Page.Limit) }}
134
+
<div class="flex justify-center mt-8">
135
+
<div class="flex gap-2">
136
+
{{ if gt .Page.Offset 0 }}
137
+
{{ $prev := .Page.Previous }}
138
+
<a
139
+
class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700"
140
+
hx-boost="true"
141
+
href="/goodfirstissues?offset={{ $prev.Offset }}&limit={{ $prev.Limit }}"
142
+
>
143
+
{{ i "chevron-left" "w-4 h-4" }}
144
+
previous
145
+
</a>
146
+
{{ else }}
147
+
<div></div>
148
+
{{ end }}
149
+
150
+
{{ if eq (len .RepoGroups) .Page.Limit }}
151
+
{{ $next := .Page.Next }}
152
+
<a
153
+
class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700"
154
+
hx-boost="true"
155
+
href="/goodfirstissues?offset={{ $next.Offset }}&limit={{ $next.Limit }}"
156
+
>
157
+
next
158
+
{{ i "chevron-right" "w-4 h-4" }}
159
+
</a>
160
+
{{ end }}
161
+
</div>
162
+
</div>
163
+
{{ end }}
164
+
{{ end }}
165
+
</div>
166
+
</div>
167
+
{{ end }}
+3
-6
appview/pages/templates/knots/index.html
+3
-6
appview/pages/templates/knots/index.html
···
1
{{ define "title" }}knots{{ end }}
2
3
{{ define "content" }}
4
-
<div class="px-6 py-4 flex items-end justify-start gap-4 align-bottom">
5
<h1 class="text-xl font-bold dark:text-white">Knots</h1>
6
-
7
-
<span class="flex items-center gap-1 text-sm">
8
{{ i "book" "w-3 h-3" }}
9
-
<a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/knot-hosting.md">
10
-
docs
11
-
</a>
12
</span>
13
</div>
14
···
1
{{ define "title" }}knots{{ end }}
2
3
{{ define "content" }}
4
+
<div class="px-6 py-4 flex items-center justify-between gap-4 align-bottom">
5
<h1 class="text-xl font-bold dark:text-white">Knots</h1>
6
+
<span class="flex items-center gap-1">
7
{{ i "book" "w-3 h-3" }}
8
+
<a href="https://tangled.org/@tangled.org/core/blob/master/docs/knot-hosting.md">docs</a>
9
</span>
10
</div>
11
+39
appview/pages/templates/labels/fragments/label.html
+39
appview/pages/templates/labels/fragments/label.html
···
···
1
+
{{ define "labels/fragments/label" }}
2
+
{{ $d := .def }}
3
+
{{ $v := .val }}
4
+
{{ $withPrefix := .withPrefix }}
5
+
<span class="w-fit flex items-center gap-2 font-normal normal-case rounded py-1 px-2 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-sm">
6
+
{{ template "repo/fragments/colorBall" (dict "color" $d.GetColor) }}
7
+
8
+
{{ $lhs := printf "%s" $d.Name }}
9
+
{{ $rhs := "" }}
10
+
11
+
{{ if not $d.ValueType.IsNull }}
12
+
{{ if $d.ValueType.IsDidFormat }}
13
+
{{ $v = resolve $v }}
14
+
{{ end }}
15
+
16
+
{{ if not $withPrefix }}
17
+
{{ $lhs = "" }}
18
+
{{ else }}
19
+
{{ $lhs = printf "%s/" $d.Name }}
20
+
{{ end }}
21
+
22
+
{{ $rhs = printf "%s" $v }}
23
+
{{ end }}
24
+
25
+
{{ printf "%s%s" $lhs $rhs }}
26
+
</span>
27
+
{{ end }}
28
+
29
+
30
+
{{ define "labelVal" }}
31
+
{{ $d := .def }}
32
+
{{ $v := .val }}
33
+
34
+
{{ if $d.ValueType.IsDidFormat }}
35
+
{{ resolve $v }}
36
+
{{ else }}
37
+
{{ $v }}
38
+
{{ end }}
39
+
{{ end }}
+6
appview/pages/templates/labels/fragments/labelDef.html
+6
appview/pages/templates/labels/fragments/labelDef.html
+16
-11
appview/pages/templates/layouts/base.html
+16
-11
appview/pages/templates/layouts/base.html
···
14
<link rel="preconnect" href="https://avatar.tangled.sh" />
15
<link rel="preconnect" href="https://camo.tangled.sh" />
16
17
<!-- preload main font -->
18
<link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin />
19
···
21
<title>{{ block "title" . }}{{ end }} · tangled</title>
22
{{ block "extrameta" . }}{{ end }}
23
</head>
24
-
<body class="min-h-screen grid grid-cols-1 grid-rows-[min-content_auto_min-content] md:grid-cols-12 gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200">
25
{{ block "topbarLayout" . }}
26
-
<header class="px-1 col-span-1 md:col-start-3 md:col-span-8" style="z-index: 20;">
27
28
{{ if .LoggedInUser }}
29
<div id="upgrade-banner"
···
37
{{ end }}
38
39
{{ block "mainLayout" . }}
40
-
<div class="px-1 col-span-1 md:col-start-3 md:col-span-8 flex flex-col gap-4">
41
-
{{ block "contentLayout" . }}
42
-
<main class="col-span-1 md:col-span-8">
43
{{ block "content" . }}{{ end }}
44
</main>
45
-
{{ end }}
46
-
47
-
{{ block "contentAfterLayout" . }}
48
-
<main class="col-span-1 md:col-span-8">
49
{{ block "contentAfter" . }}{{ end }}
50
</main>
51
-
{{ end }}
52
</div>
53
{{ end }}
54
55
{{ block "footerLayout" . }}
56
-
<footer class="px-1 col-span-1 md:col-start-3 md:col-span-8 mt-12">
57
{{ template "layouts/fragments/footer" . }}
58
</footer>
59
{{ end }}
···
14
<link rel="preconnect" href="https://avatar.tangled.sh" />
15
<link rel="preconnect" href="https://camo.tangled.sh" />
16
17
+
<!-- pwa manifest -->
18
+
<link rel="manifest" href="/pwa-manifest.json" />
19
+
20
<!-- preload main font -->
21
<link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin />
22
···
24
<title>{{ block "title" . }}{{ end }} · tangled</title>
25
{{ block "extrameta" . }}{{ end }}
26
</head>
27
+
<body class="min-h-screen flex flex-col gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200">
28
{{ block "topbarLayout" . }}
29
+
<header class="w-full bg-white dark:bg-gray-800 col-span-full md:col-span-1 md:col-start-2" style="z-index: 20;">
30
31
{{ if .LoggedInUser }}
32
<div id="upgrade-banner"
···
40
{{ end }}
41
42
{{ block "mainLayout" . }}
43
+
<div class="flex-grow">
44
+
<div class="max-w-screen-lg mx-auto flex flex-col gap-4">
45
+
{{ block "contentLayout" . }}
46
+
<main>
47
{{ block "content" . }}{{ end }}
48
</main>
49
+
{{ end }}
50
+
51
+
{{ block "contentAfterLayout" . }}
52
+
<main>
53
{{ block "contentAfter" . }}{{ end }}
54
</main>
55
+
{{ end }}
56
+
</div>
57
</div>
58
{{ end }}
59
60
{{ block "footerLayout" . }}
61
+
<footer class="bg-white dark:bg-gray-800 mt-12">
62
{{ template "layouts/fragments/footer" . }}
63
</footer>
64
{{ end }}
+18
-8
appview/pages/templates/layouts/fragments/topbar.html
+18
-8
appview/pages/templates/layouts/fragments/topbar.html
···
1
{{ define "layouts/fragments/topbar" }}
2
-
<nav class="space-x-4 px-6 py-2 rounded bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm">
3
<div class="flex justify-between p-0 items-center">
4
<div id="left-items">
5
-
<a href="/" hx-boost="true" class="flex gap-2 font-bold italic">
6
-
tangled<sub>alpha</sub>
7
-
</a>
8
</div>
9
10
-
<div id="right-items" class="flex items-center gap-2">
11
{{ with .LoggedInUser }}
12
{{ block "newButton" . }} {{ end }}
13
{{ block "dropDown" . }} {{ end }}
14
{{ else }}
15
<a href="/login">login</a>
···
26
{{ define "newButton" }}
27
<details class="relative inline-block text-left nav-dropdown">
28
<summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2">
29
-
{{ i "plus" "w-4 h-4" }} new
30
</summary>
31
<div class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700">
32
<a href="/repo/new" class="flex items-center gap-2">
···
44
{{ define "dropDown" }}
45
<details class="relative inline-block text-left nav-dropdown">
46
<summary
47
-
class="cursor-pointer list-none flex items-center"
48
>
49
{{ $user := didOrHandle .Did .Handle }}
50
-
{{ template "user/fragments/picHandle" $user }}
51
</summary>
52
<div
53
class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700"
···
1
{{ define "layouts/fragments/topbar" }}
2
+
<nav class="mx-auto space-x-4 px-6 py-2 rounded-b dark:text-white drop-shadow-sm">
3
<div class="flex justify-between p-0 items-center">
4
<div id="left-items">
5
+
<a href="/" hx-boost="true" class="text-2xl no-underline hover:no-underline flex items-center gap-2">
6
+
{{ template "fragments/dolly/logo" "size-8 text-black dark:text-white" }}
7
+
<span class="font-bold text-xl not-italic hidden md:inline">tangled</span>
8
+
<span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1 hidden md:inline">
9
+
alpha
10
+
</span>
11
+
</a>
12
</div>
13
14
+
<div id="right-items" class="flex items-center gap-4">
15
{{ with .LoggedInUser }}
16
{{ block "newButton" . }} {{ end }}
17
+
{{ template "notifications/fragments/bell" }}
18
{{ block "dropDown" . }} {{ end }}
19
{{ else }}
20
<a href="/login">login</a>
···
31
{{ define "newButton" }}
32
<details class="relative inline-block text-left nav-dropdown">
33
<summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2">
34
+
{{ i "plus" "w-4 h-4" }} <span class="hidden md:inline">new</span>
35
</summary>
36
<div class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700">
37
<a href="/repo/new" class="flex items-center gap-2">
···
49
{{ define "dropDown" }}
50
<details class="relative inline-block text-left nav-dropdown">
51
<summary
52
+
class="cursor-pointer list-none flex items-center gap-1"
53
>
54
{{ $user := didOrHandle .Did .Handle }}
55
+
<img
56
+
src="{{ tinyAvatar $user }}"
57
+
alt=""
58
+
class="rounded-full h-6 w-6 border border-gray-300 dark:border-gray-700"
59
+
/>
60
+
<span class="hidden md:inline">{{ $user | resolve | truncateAt30 }}</span>
61
</summary>
62
<div
63
class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700"
+8
-4
appview/pages/templates/layouts/profilebase.html
+8
-4
appview/pages/templates/layouts/profilebase.html
···
3
{{ define "extrameta" }}
4
<meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" />
5
<meta property="og:type" content="profile" />
6
-
<meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}?tab={{ .Active }}" />
7
<meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" />
8
{{ end }}
9
10
{{ define "content" }}
11
{{ template "profileTabs" . }}
12
-
<section class="bg-white dark:bg-gray-800 p-6 rounded w-full dark:text-white drop-shadow-sm">
13
<div class="grid grid-cols-1 md:grid-cols-11 gap-4">
14
-
<div class="md:col-span-3 order-1 md:order-1">
15
<div class="flex flex-col gap-4">
16
{{ template "user/fragments/profileCard" .Card }}
17
{{ block "punchcard" .Card.Punchcard }} {{ end }}
18
</div>
19
</div>
20
{{ block "profileContent" . }} {{ end }}
21
</div>
22
</section>
···
101
{{ define "layouts/profilebase" }}
102
{{ template "layouts/base" . }}
103
{{ end }}
104
-
···
3
{{ define "extrameta" }}
4
<meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" />
5
<meta property="og:type" content="profile" />
6
+
<meta property="og:url" content="https://tangled.org/{{ or .Card.UserHandle .Card.UserDid }}?tab={{ .Active }}" />
7
<meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" />
8
{{ end }}
9
10
{{ define "content" }}
11
{{ template "profileTabs" . }}
12
+
<section class="bg-white dark:bg-gray-800 px-2 py-6 md:p-6 rounded w-full dark:text-white drop-shadow-sm">
13
<div class="grid grid-cols-1 md:grid-cols-11 gap-4">
14
+
{{ $style := "hidden md:block md:col-span-3" }}
15
+
{{ if eq $.Active "overview" }}
16
+
{{ $style = "md:col-span-3" }}
17
+
{{ end }}
18
+
<div class="{{ $style }} order-1 order-1">
19
<div class="flex flex-col gap-4">
20
{{ template "user/fragments/profileCard" .Card }}
21
{{ block "punchcard" .Card.Punchcard }} {{ end }}
22
</div>
23
</div>
24
+
25
{{ block "profileContent" . }} {{ end }}
26
</div>
27
</section>
···
106
{{ define "layouts/profilebase" }}
107
{{ template "layouts/base" . }}
108
{{ end }}
+6
-8
appview/pages/templates/layouts/repobase.html
+6
-8
appview/pages/templates/layouts/repobase.html
···
41
{{ template "repo/fragments/repoDescription" . }}
42
</section>
43
44
-
<section
45
-
class="w-full flex flex-col"
46
-
>
47
<nav class="w-full pl-4 overflow-auto">
48
<div class="flex z-60">
49
{{ $activeTabStyles := "-mb-px bg-white dark:bg-gray-800" }}
···
80
{{ end }}
81
</div>
82
</nav>
83
-
<section
84
-
class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white"
85
-
>
86
{{ block "repoContent" . }}{{ end }}
87
-
</section>
88
-
{{ block "repoAfter" . }}{{ end }}
89
</section>
90
{{ end }}
···
41
{{ template "repo/fragments/repoDescription" . }}
42
</section>
43
44
+
<section class="w-full flex flex-col" >
45
<nav class="w-full pl-4 overflow-auto">
46
<div class="flex z-60">
47
{{ $activeTabStyles := "-mb-px bg-white dark:bg-gray-800" }}
···
78
{{ end }}
79
</div>
80
</nav>
81
+
{{ block "repoContentLayout" . }}
82
+
<section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white">
83
{{ block "repoContent" . }}{{ end }}
84
+
</section>
85
+
{{ block "repoAfter" . }}{{ end }}
86
+
{{ end }}
87
</section>
88
{{ end }}
+13
-6
appview/pages/templates/legal/privacy.html
+13
-6
appview/pages/templates/legal/privacy.html
···
1
{{ define "title" }}privacy policy{{ end }}
2
3
{{ define "content" }}
4
-
<div class="max-w-4xl mx-auto px-4 py-8">
5
-
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8">
6
-
<div class="prose prose-gray dark:prose-invert max-w-none">
7
-
{{ .Content }}
8
-
</div>
9
</div>
10
</div>
11
-
{{ end }}
···
1
{{ define "title" }}privacy policy{{ end }}
2
3
{{ define "content" }}
4
+
<div class="grid grid-cols-10">
5
+
<header class="col-span-full md:col-span-10 px-6 py-2 mb-4">
6
+
<h1 class="text-2xl font-bold dark:text-white mb-1">Privacy Policy</h1>
7
+
<p class="text-gray-600 dark:text-gray-400 mb-1">
8
+
Learn how we collect, use, and protect your personal information.
9
+
</p>
10
+
</header>
11
+
12
+
<main class="col-span-full md:col-span-10 bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10">
13
+
<div class="prose prose-gray dark:prose-invert max-w-none">
14
+
{{ .Content }}
15
</div>
16
+
</main>
17
</div>
18
+
{{ end }}
+13
-6
appview/pages/templates/legal/terms.html
+13
-6
appview/pages/templates/legal/terms.html
···
1
{{ define "title" }}terms of service{{ end }}
2
3
{{ define "content" }}
4
-
<div class="max-w-4xl mx-auto px-4 py-8">
5
-
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8">
6
-
<div class="prose prose-gray dark:prose-invert max-w-none">
7
-
{{ .Content }}
8
-
</div>
9
</div>
10
</div>
11
-
{{ end }}
···
1
{{ define "title" }}terms of service{{ end }}
2
3
{{ define "content" }}
4
+
<div class="grid grid-cols-10">
5
+
<header class="col-span-full md:col-span-10 px-6 py-2 mb-4">
6
+
<h1 class="text-2xl font-bold dark:text-white mb-1">Terms of Service</h1>
7
+
<p class="text-gray-600 dark:text-gray-400 mb-1">
8
+
A few things you should know.
9
+
</p>
10
+
</header>
11
+
12
+
<main class="col-span-full md:col-span-10 bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10">
13
+
<div class="prose prose-gray dark:prose-invert max-w-none">
14
+
{{ .Content }}
15
</div>
16
+
</main>
17
</div>
18
+
{{ end }}
+11
appview/pages/templates/notifications/fragments/bell.html
+11
appview/pages/templates/notifications/fragments/bell.html
···
···
1
+
{{define "notifications/fragments/bell"}}
2
+
<div class="relative"
3
+
hx-get="/notifications/count"
4
+
hx-target="#notification-count"
5
+
hx-trigger="load, every 30s">
6
+
<a href="/notifications" class="text-gray-500 dark:text-gray-400 flex gap-1 items-end group">
7
+
{{ i "bell" "w-5 h-5" }}
8
+
<span id="notification-count"></span>
9
+
</a>
10
+
</div>
11
+
{{end}}
+7
appview/pages/templates/notifications/fragments/count.html
+7
appview/pages/templates/notifications/fragments/count.html
···
···
1
+
{{define "notifications/fragments/count"}}
2
+
{{if and .Count (gt .Count 0)}}
3
+
<span class="absolute -top-1.5 -right-0.5 min-w-[16px] h-[16px] px-1 bg-red-500 text-white text-xs font-medium rounded-full flex items-center justify-center">
4
+
{{if gt .Count 99}}99+{{else}}{{.Count}}{{end}}
5
+
</span>
6
+
{{end}}
7
+
{{end}}
+81
appview/pages/templates/notifications/fragments/item.html
+81
appview/pages/templates/notifications/fragments/item.html
···
···
1
+
{{define "notifications/fragments/item"}}
2
+
<a href="{{ template "notificationUrl" . }}" class="block no-underline hover:no-underline">
3
+
<div
4
+
class="
5
+
w-full mx-auto rounded drop-shadow-sm dark:text-white bg-white dark:bg-gray-800 px-2 md:px-6 py-4 transition-colors
6
+
{{if not .Read}}bg-blue-50 dark:bg-blue-800/20 border border-blue-500 dark:border-sky-800{{end}}
7
+
flex gap-2 items-center
8
+
">
9
+
{{ template "notificationIcon" . }}
10
+
<div class="flex-1 w-full flex flex-col gap-1">
11
+
<span>{{ template "notificationHeader" . }}</span>
12
+
<span class="text-sm text-gray-500 dark:text-gray-400">{{ template "notificationSummary" . }}</span>
13
+
</div>
14
+
15
+
</div>
16
+
</a>
17
+
{{end}}
18
+
19
+
{{ define "notificationIcon" }}
20
+
<div class="flex-shrink-0 max-h-full w-16 h-16 relative">
21
+
<img class="object-cover rounded-full p-2" src="{{ fullAvatar .ActorDid }}" />
22
+
<div class="absolute border-2 border-white dark:border-gray-800 bg-gray-200 dark:bg-gray-700 bottom-1 right-1 rounded-full p-2 flex items-center justify-center z-10">
23
+
{{ i .Icon "size-3 text-black dark:text-white" }}
24
+
</div>
25
+
</div>
26
+
{{ end }}
27
+
28
+
{{ define "notificationHeader" }}
29
+
{{ $actor := resolve .ActorDid }}
30
+
31
+
<span class="text-black dark:text-white w-fit">{{ $actor }}</span>
32
+
{{ if eq .Type "repo_starred" }}
33
+
starred <span class="text-black dark:text-white">{{ resolve .Repo.Did }}/{{ .Repo.Name }}</span>
34
+
{{ else if eq .Type "issue_created" }}
35
+
opened an issue
36
+
{{ else if eq .Type "issue_commented" }}
37
+
commented on an issue
38
+
{{ else if eq .Type "issue_closed" }}
39
+
closed an issue
40
+
{{ else if eq .Type "pull_created" }}
41
+
created a pull request
42
+
{{ else if eq .Type "pull_commented" }}
43
+
commented on a pull request
44
+
{{ else if eq .Type "pull_merged" }}
45
+
merged a pull request
46
+
{{ else if eq .Type "pull_closed" }}
47
+
closed a pull request
48
+
{{ else if eq .Type "followed" }}
49
+
followed you
50
+
{{ else }}
51
+
{{ end }}
52
+
{{ end }}
53
+
54
+
{{ define "notificationSummary" }}
55
+
{{ if eq .Type "repo_starred" }}
56
+
<!-- no summary -->
57
+
{{ else if .Issue }}
58
+
#{{.Issue.IssueId}} {{.Issue.Title}} on {{resolve .Repo.Did}}/{{.Repo.Name}}
59
+
{{ else if .Pull }}
60
+
#{{.Pull.PullId}} {{.Pull.Title}} on {{resolve .Repo.Did}}/{{.Repo.Name}}
61
+
{{ else if eq .Type "followed" }}
62
+
<!-- no summary -->
63
+
{{ else }}
64
+
{{ end }}
65
+
{{ end }}
66
+
67
+
{{ define "notificationUrl" }}
68
+
{{ $url := "" }}
69
+
{{ if eq .Type "repo_starred" }}
70
+
{{$url = printf "/%s/%s" (resolve .Repo.Did) .Repo.Name}}
71
+
{{ else if .Issue }}
72
+
{{$url = printf "/%s/%s/issues/%d" (resolve .Repo.Did) .Repo.Name .Issue.IssueId}}
73
+
{{ else if .Pull }}
74
+
{{$url = printf "/%s/%s/pulls/%d" (resolve .Repo.Did) .Repo.Name .Pull.PullId}}
75
+
{{ else if eq .Type "followed" }}
76
+
{{$url = printf "/%s" (resolve .ActorDid)}}
77
+
{{ else }}
78
+
{{ end }}
79
+
80
+
{{ $url }}
81
+
{{ end }}
+65
appview/pages/templates/notifications/list.html
+65
appview/pages/templates/notifications/list.html
···
···
1
+
{{ define "title" }}notifications{{ end }}
2
+
3
+
{{ define "content" }}
4
+
<div class="px-6 py-4">
5
+
<div class="flex items-center justify-between">
6
+
<p class="text-xl font-bold dark:text-white">Notifications</p>
7
+
<a href="/settings/notifications" class="flex items-center gap-2">
8
+
{{ i "settings" "w-4 h-4" }}
9
+
preferences
10
+
</a>
11
+
</div>
12
+
</div>
13
+
14
+
{{if .Notifications}}
15
+
<div class="flex flex-col gap-2" id="notifications-list">
16
+
{{range .Notifications}}
17
+
{{template "notifications/fragments/item" .}}
18
+
{{end}}
19
+
</div>
20
+
21
+
{{else}}
22
+
<div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
23
+
<div class="text-center py-12">
24
+
<div class="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600">
25
+
{{ i "bell-off" "w-16 h-16" }}
26
+
</div>
27
+
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">No notifications</h3>
28
+
<p class="text-gray-600 dark:text-gray-400">When you receive notifications, they'll appear here.</p>
29
+
</div>
30
+
</div>
31
+
{{end}}
32
+
33
+
{{ template "pagination" . }}
34
+
{{ end }}
35
+
36
+
{{ define "pagination" }}
37
+
<div class="flex justify-end mt-4 gap-2">
38
+
{{ if gt .Page.Offset 0 }}
39
+
{{ $prev := .Page.Previous }}
40
+
<a
41
+
class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700"
42
+
hx-boost="true"
43
+
href = "/notifications?offset={{ $prev.Offset }}&limit={{ $prev.Limit }}"
44
+
>
45
+
{{ i "chevron-left" "w-4 h-4" }}
46
+
previous
47
+
</a>
48
+
{{ else }}
49
+
<div></div>
50
+
{{ end }}
51
+
52
+
{{ $next := .Page.Next }}
53
+
{{ if lt $next.Offset .Total }}
54
+
{{ $next := .Page.Next }}
55
+
<a
56
+
class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700"
57
+
hx-boost="true"
58
+
href = "/notifications?offset={{ $next.Offset }}&limit={{ $next.Limit }}"
59
+
>
60
+
next
61
+
{{ i "chevron-right" "w-4 h-4" }}
62
+
</a>
63
+
{{ end }}
64
+
</div>
65
+
{{ end }}
+2
-1
appview/pages/templates/repo/blob.html
+2
-1
appview/pages/templates/repo/blob.html
···
4
{{ template "repo/fragments/meta" . }}
5
6
{{ $title := printf "%s at %s · %s" .Path .Ref .RepoInfo.FullName }}
7
-
{{ $url := printf "https://tangled.sh/%s/blob/%s/%s" .RepoInfo.FullName .Ref .Path }}
8
9
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
10
···
78
{{ end }}
79
</div>
80
{{ end }}
81
{{ end }}
···
4
{{ template "repo/fragments/meta" . }}
5
6
{{ $title := printf "%s at %s · %s" .Path .Ref .RepoInfo.FullName }}
7
+
{{ $url := printf "https://tangled.org/%s/blob/%s/%s" .RepoInfo.FullName .Ref .Path }}
8
9
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
10
···
78
{{ end }}
79
</div>
80
{{ end }}
81
+
{{ template "fragments/multiline-select" }}
82
{{ end }}
+2
-2
appview/pages/templates/repo/branches.html
+2
-2
appview/pages/templates/repo/branches.html
+2
-2
appview/pages/templates/repo/commit.html
+2
-2
appview/pages/templates/repo/commit.html
···
2
3
{{ define "extrameta" }}
4
{{ $title := printf "commit %s · %s" .Diff.Commit.This .RepoInfo.FullName }}
5
-
{{ $url := printf "https://tangled.sh/%s/commit/%s" .RepoInfo.FullName .Diff.Commit.This }}
6
7
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
8
{{ end }}
···
61
{{ $committerDidOrHandle := index $.EmailToDidOrHandle $commit.Committer.Email }}
62
<a href="/{{ $committerDidOrHandle }}">{{ template "user/fragments/picHandleLink" $committerDidOrHandle }}</a>
63
</div>
64
-
<div class="my-1 pt-2 text-xs border-t">
65
<div class="text-gray-600 dark:text-gray-300">SSH Key Fingerprint:</div>
66
<div class="break-all">{{ .VerifiedCommit.Fingerprint $commit.This }}</div>
67
</div>
···
2
3
{{ define "extrameta" }}
4
{{ $title := printf "commit %s · %s" .Diff.Commit.This .RepoInfo.FullName }}
5
+
{{ $url := printf "https://tangled.org/%s/commit/%s" .RepoInfo.FullName .Diff.Commit.This }}
6
7
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
8
{{ end }}
···
61
{{ $committerDidOrHandle := index $.EmailToDidOrHandle $commit.Committer.Email }}
62
<a href="/{{ $committerDidOrHandle }}">{{ template "user/fragments/picHandleLink" $committerDidOrHandle }}</a>
63
</div>
64
+
<div class="my-1 pt-2 text-xs border-t border-gray-200 dark:border-gray-700">
65
<div class="text-gray-600 dark:text-gray-300">SSH Key Fingerprint:</div>
66
<div class="break-all">{{ .VerifiedCommit.Fingerprint $commit.This }}</div>
67
</div>
+7
appview/pages/templates/repo/fork.html
+7
appview/pages/templates/repo/fork.html
···
6
</div>
7
<div class="p-6 bg-white dark:bg-gray-800 drop-shadow-sm rounded">
8
<form hx-post="/{{ .RepoInfo.FullName }}/fork" class="space-y-12" hx-swap="none" hx-indicator="#spinner">
9
<fieldset class="space-y-3">
10
<legend class="dark:text-white">Select a knot to fork into</legend>
11
<div class="space-y-2">
···
6
</div>
7
<div class="p-6 bg-white dark:bg-gray-800 drop-shadow-sm rounded">
8
<form hx-post="/{{ .RepoInfo.FullName }}/fork" class="space-y-12" hx-swap="none" hx-indicator="#spinner">
9
+
10
+
<fieldset class="space-y-3">
11
+
<legend for="repo_name" class="dark:text-white">Repository name</legend>
12
+
<input type="text" id="repo_name" name="repo_name" value="{{ .RepoInfo.Name }}"
13
+
class="w-full p-2 border rounded bg-gray-100 dark:bg-gray-700 dark:text-white dark:border-gray-600" />
14
+
</fieldset>
15
+
16
<fieldset class="space-y-3">
17
<legend class="dark:text-white">Select a knot to fork into</legend>
18
<div class="space-y-2">
+3
-3
appview/pages/templates/repo/fragments/cloneDropdown.html
+3
-3
appview/pages/templates/repo/fragments/cloneDropdown.html
···
1
{{ define "repo/fragments/cloneDropdown" }}
2
{{ $knot := .RepoInfo.Knot }}
3
{{ if eq $knot "knot1.tangled.sh" }}
4
-
{{ $knot = "tangled.sh" }}
5
{{ end }}
6
7
<details id="clone-dropdown" class="relative inline-block text-left group">
···
29
<code
30
class="flex-1 px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-l select-all cursor-pointer whitespace-nowrap overflow-x-auto"
31
onclick="window.getSelection().selectAllChildren(this)"
32
-
data-url="https://tangled.sh/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}"
33
-
>https://tangled.sh/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}</code>
34
<button
35
onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))"
36
class="px-3 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 border-l border-gray-300 dark:border-gray-600"
···
1
{{ define "repo/fragments/cloneDropdown" }}
2
{{ $knot := .RepoInfo.Knot }}
3
{{ if eq $knot "knot1.tangled.sh" }}
4
+
{{ $knot = "tangled.org" }}
5
{{ end }}
6
7
<details id="clone-dropdown" class="relative inline-block text-left group">
···
29
<code
30
class="flex-1 px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-l select-all cursor-pointer whitespace-nowrap overflow-x-auto"
31
onclick="window.getSelection().selectAllChildren(this)"
32
+
data-url="https://tangled.org/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}"
33
+
>https://tangled.org/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}</code>
34
<button
35
onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))"
36
class="px-3 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 border-l border-gray-300 dark:border-gray-600"
+6
appview/pages/templates/repo/fragments/colorBall.html
+6
appview/pages/templates/repo/fragments/colorBall.html
···
+208
appview/pages/templates/repo/fragments/editLabelPanel.html
+208
appview/pages/templates/repo/fragments/editLabelPanel.html
···
···
1
+
{{ define "repo/fragments/editLabelPanel" }}
2
+
<form
3
+
id="edit-label-panel"
4
+
hx-put="/{{ .RepoInfo.FullName }}/labels/perform"
5
+
hx-indicator="#spinner"
6
+
hx-disabled-elt="#save-btn,#cancel-btn"
7
+
hx-swap="none"
8
+
class="flex flex-col gap-6"
9
+
>
10
+
<input type="hidden" name="repo" value="{{ .RepoInfo.RepoAt }}">
11
+
<input type="hidden" name="subject" value="{{ .Subject }}">
12
+
{{ template "editBasicLabels" . }}
13
+
{{ template "editKvLabels" . }}
14
+
{{ template "editLabelPanelActions" . }}
15
+
<div id="add-label-error" class="text-red-500 dark:text-red-400"></div>
16
+
</form>
17
+
{{ end }}
18
+
19
+
{{ define "editBasicLabels" }}
20
+
{{ $defs := .Defs }}
21
+
{{ $subject := .Subject }}
22
+
{{ $state := .State }}
23
+
{{ $labelStyle := "flex items-center gap-2 rounded py-1 px-2 border border-gray-200 dark:border-gray-700 text-sm bg-white dark:bg-gray-800 text-black dark:text-white" }}
24
+
<div>
25
+
{{ template "repo/fragments/labelSectionHeaderText" "Labels" }}
26
+
27
+
<div class="flex gap-1 items-center flex-wrap">
28
+
{{ range $k, $d := $defs }}
29
+
{{ $isChecked := $state.ContainsLabel $k }}
30
+
{{ if $d.ValueType.IsNull }}
31
+
{{ $fieldName := $d.AtUri }}
32
+
<label class="{{$labelStyle}}">
33
+
<input type="checkbox" id="{{ $fieldName }}" name="{{ $fieldName }}" value="null" {{if $isChecked}}checked{{end}}>
34
+
{{ template "labels/fragments/labelDef" $d }}
35
+
</label>
36
+
{{ end }}
37
+
{{ else }}
38
+
<p class="text-gray-500 dark:text-gray-400 text-sm py-1">
39
+
No labels defined yet. You can choose default labels or define custom
40
+
labels in <a class="underline" href="/{{ $.RepoInfo.FullName }}/settings">settings</a>.
41
+
</p>
42
+
{{ end }}
43
+
</div>
44
+
</div>
45
+
{{ end }}
46
+
47
+
{{ define "editKvLabels" }}
48
+
{{ $defs := .Defs }}
49
+
{{ $subject := .Subject }}
50
+
{{ $state := .State }}
51
+
{{ $labelStyle := "font-normal normal-case flex items-center gap-2 p-0" }}
52
+
53
+
{{ range $k, $d := $defs }}
54
+
{{ if (not $d.ValueType.IsNull) }}
55
+
{{ $fieldName := $d.AtUri }}
56
+
{{ $valset := $state.GetValSet $k }}
57
+
<div id="label-{{$d.Id}}" class="flex flex-col gap-1">
58
+
{{ template "repo/fragments/labelSectionHeaderText" $d.Name }}
59
+
{{ if (and $d.Multiple $d.ValueType.IsEnum) }}
60
+
<!-- checkbox -->
61
+
{{ range $variant := $d.ValueType.Enum }}
62
+
<label class="{{$labelStyle}}">
63
+
<input type="checkbox" name="{{ $fieldName }}" value="{{$variant}}" {{if $state.ContainsLabelAndVal $k $variant}}checked{{end}}>
64
+
{{ $variant }}
65
+
</label>
66
+
{{ end }}
67
+
{{ else if $d.Multiple }}
68
+
<!-- dynamically growing input fields -->
69
+
{{ range $v, $s := $valset }}
70
+
{{ template "multipleInputField" (dict "def" $d "value" $v "key" $k) }}
71
+
{{ else }}
72
+
{{ template "multipleInputField" (dict "def" $d "value" "" "key" $k) }}
73
+
{{ end }}
74
+
{{ template "addFieldButton" $d }}
75
+
{{ else if $d.ValueType.IsEnum }}
76
+
<!-- radio buttons -->
77
+
{{ $isUsed := $state.ContainsLabel $k }}
78
+
{{ range $variant := $d.ValueType.Enum }}
79
+
<label class="{{$labelStyle}}">
80
+
<input type="radio" name="{{ $fieldName }}" value="{{$variant}}" {{if $state.ContainsLabelAndVal $k $variant}}checked{{end}}>
81
+
{{ $variant }}
82
+
</label>
83
+
{{ end }}
84
+
<label class="{{$labelStyle}}">
85
+
<input type="radio" name="{{ $fieldName }}" value="" {{ if not $isUsed }}checked{{ end }}>
86
+
None
87
+
</label>
88
+
{{ else }}
89
+
<!-- single input field based on value type -->
90
+
{{ range $v, $s := $valset }}
91
+
{{ template "valueTypeInput" (dict "def" $d "value" $v "key" $k) }}
92
+
{{ else }}
93
+
{{ template "valueTypeInput" (dict "def" $d "value" "" "key" $k) }}
94
+
{{ end }}
95
+
{{ end }}
96
+
</div>
97
+
{{ end }}
98
+
{{ end }}
99
+
{{ end }}
100
+
101
+
{{ define "multipleInputField" }}
102
+
<div class="flex gap-1 items-stretch">
103
+
{{ template "valueTypeInput" . }}
104
+
{{ template "removeFieldButton" }}
105
+
</div>
106
+
{{ end }}
107
+
108
+
{{ define "addFieldButton" }}
109
+
<div style="display:none" id="tpl-{{ .Id }}">
110
+
{{ template "multipleInputField" (dict "def" . "value" "" "key" .AtUri.String) }}
111
+
</div>
112
+
<button type="button" onClick="this.insertAdjacentHTML('beforebegin', document.getElementById('tpl-{{ .Id }}').innerHTML)" class="w-full btn flex items-center gap-2">
113
+
{{ i "plus" "size-4" }} add
114
+
</button>
115
+
{{ end }}
116
+
117
+
{{ define "removeFieldButton" }}
118
+
<button type="button" onClick="this.parentElement.remove()" class="btn flex items-center gap-2 text-red-400 dark:text-red-500">
119
+
{{ i "trash-2" "size-4" }}
120
+
</button>
121
+
{{ end }}
122
+
123
+
{{ define "valueTypeInput" }}
124
+
{{ $def := .def }}
125
+
{{ $valueType := $def.ValueType }}
126
+
{{ $value := .value }}
127
+
{{ $key := .key }}
128
+
129
+
{{ if $valueType.IsBool }}
130
+
{{ template "boolTypeInput" $ }}
131
+
{{ else if $valueType.IsInt }}
132
+
{{ template "intTypeInput" $ }}
133
+
{{ else if $valueType.IsString }}
134
+
{{ template "stringTypeInput" $ }}
135
+
{{ else if $valueType.IsNull }}
136
+
{{ template "nullTypeInput" $ }}
137
+
{{ end }}
138
+
{{ end }}
139
+
140
+
{{ define "boolTypeInput" }}
141
+
{{ $def := .def }}
142
+
{{ $fieldName := $def.AtUri }}
143
+
{{ $value := .value }}
144
+
{{ $labelStyle = "font-normal normal-case flex items-center gap-2" }}
145
+
<div class="flex flex-col gap-1">
146
+
<label class="{{$labelStyle}}">
147
+
<input type="radio" name="{{ $fieldName }}" value="true" {{ if not $value }}checked{{ end }}>
148
+
None
149
+
</label>
150
+
<label class="{{$labelStyle}}">
151
+
<input type="radio" name="{{ $fieldName }}" value="true" {{ if not $value }}checked{{ end }}>
152
+
None
153
+
</label>
154
+
<label class="{{$labelStyle}}">
155
+
<input type="radio" name="{{ $fieldName }}" value="true" {{ if not $value }}checked{{ end }}>
156
+
None
157
+
</label>
158
+
</div>
159
+
{{ end }}
160
+
161
+
{{ define "intTypeInput" }}
162
+
{{ $def := .def }}
163
+
{{ $fieldName := $def.AtUri }}
164
+
{{ $value := .value }}
165
+
<input class="p-1 w-full" type="number" name="{{$fieldName}}" value="{{$value}}">
166
+
{{ end }}
167
+
168
+
{{ define "stringTypeInput" }}
169
+
{{ $def := .def }}
170
+
{{ $fieldName := $def.AtUri }}
171
+
{{ $valueType := $def.ValueType }}
172
+
{{ $value := .value }}
173
+
{{ if $valueType.IsDidFormat }}
174
+
{{ $value = trimPrefix (resolve .value) "@" }}
175
+
{{ end }}
176
+
<input class="p-1 w-full" type="text" name="{{$fieldName}}" value="{{$value}}">
177
+
{{ end }}
178
+
179
+
{{ define "nullTypeInput" }}
180
+
{{ $def := .def }}
181
+
{{ $fieldName := $def.AtUri }}
182
+
<input class="p-1" type="hidden" name="{{$fieldName}}" value="null">
183
+
{{ end }}
184
+
185
+
{{ define "editLabelPanelActions" }}
186
+
<div class="flex gap-2 pt-2">
187
+
<button
188
+
id="cancel-btn"
189
+
type="button"
190
+
hx-get="/{{ .RepoInfo.FullName }}/label"
191
+
hx-vals='{"subject": "{{.Subject}}"}'
192
+
hx-swap="outerHTML"
193
+
hx-target="#edit-label-panel"
194
+
class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 group">
195
+
{{ i "x" "size-4" }} cancel
196
+
</button>
197
+
198
+
<button
199
+
id="save-btn"
200
+
type="submit"
201
+
class="btn w-1/2 flex items-center">
202
+
<span class="inline-flex gap-2 items-center">{{ i "check" "size-4" }} save</span>
203
+
<span id="spinner" class="group">
204
+
{{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
205
+
</span>
206
+
</button>
207
+
</div>
208
+
{{ end }}
+43
appview/pages/templates/repo/fragments/labelPanel.html
+43
appview/pages/templates/repo/fragments/labelPanel.html
···
···
1
+
{{ define "repo/fragments/labelPanel" }}
2
+
<div id="label-panel" class="flex flex-col gap-6 px-6 md:px-0">
3
+
{{ template "basicLabels" . }}
4
+
{{ template "kvLabels" . }}
5
+
</div>
6
+
{{ end }}
7
+
8
+
{{ define "basicLabels" }}
9
+
<div>
10
+
{{ template "repo/fragments/labelSectionHeader" (dict "Name" "Labels" "RepoInfo" .RepoInfo "Subject" .Subject) }}
11
+
12
+
{{ $hasLabel := false }}
13
+
<div class="flex gap-1 items-center flex-wrap">
14
+
{{ range $k, $d := .Defs }}
15
+
{{ if (and $d.ValueType.IsNull ($.State.ContainsLabel $k)) }}
16
+
{{ $hasLabel = true }}
17
+
{{ template "labels/fragments/label" (dict "def" $d "val" "") }}
18
+
{{ end }}
19
+
{{ end }}
20
+
21
+
{{ if not $hasLabel }}
22
+
<p class="text-gray-500 dark:text-gray-400 text-sm py-1">None yet.</p>
23
+
{{ end }}
24
+
</div>
25
+
</div>
26
+
{{ end }}
27
+
28
+
{{ define "kvLabels" }}
29
+
{{ range $k, $d := .Defs }}
30
+
{{ if (not $d.ValueType.IsNull) }}
31
+
<div id="label-{{$d.Id}}">
32
+
{{ template "repo/fragments/labelSectionHeader" (dict "Name" $d.Name "RepoInfo" $.RepoInfo "Subject" $.Subject) }}
33
+
<div class="flex gap-1 items-center flex-wrap">
34
+
{{ range $v, $s := $.State.GetValSet $d.AtUri.String }}
35
+
{{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" false) }}
36
+
{{ else }}
37
+
<p class="text-gray-500 dark:text-gray-400 text-sm py-1">None yet.</p>
38
+
{{ end }}
39
+
</div>
40
+
</div>
41
+
{{ end }}
42
+
{{ end }}
43
+
{{ end }}
+16
appview/pages/templates/repo/fragments/labelSectionHeader.html
+16
appview/pages/templates/repo/fragments/labelSectionHeader.html
···
···
1
+
{{ define "repo/fragments/labelSectionHeader" }}
2
+
3
+
<div class="flex justify-between items-center gap-2">
4
+
{{ template "repo/fragments/labelSectionHeaderText" .Name }}
5
+
{{ if (or .RepoInfo.Roles.IsOwner .RepoInfo.Roles.IsCollaborator) }}
6
+
<a
7
+
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
8
+
hx-get="/{{ .RepoInfo.FullName }}/label/edit"
9
+
hx-vals='{"subject": "{{.Subject}}"}'
10
+
hx-swap="outerHTML"
11
+
hx-target="#label-panel">
12
+
{{ i "pencil" "size-3" }}
13
+
</a>
14
+
{{ end }}
15
+
</div>
16
+
{{ end }}
+3
appview/pages/templates/repo/fragments/labelSectionHeaderText.html
+3
appview/pages/templates/repo/fragments/labelSectionHeaderText.html
-6
appview/pages/templates/repo/fragments/languageBall.html
-6
appview/pages/templates/repo/fragments/languageBall.html
···
1
-
{{ define "repo/fragments/languageBall" }}
2
-
<div
3
-
class="size-2 rounded-full"
4
-
style="background: radial-gradient(circle at 35% 35%, color-mix(in srgb, {{ langColor . }} 70%, white), {{ langColor . }} 30%, color-mix(in srgb, {{ langColor . }} 85%, black));"
5
-
></div>
6
-
{{ end }}
···
+9
-5
appview/pages/templates/repo/fragments/meta.html
+9
-5
appview/pages/templates/repo/fragments/meta.html
···
1
{{ define "repo/fragments/meta" }}
2
<meta
3
name="vcs:clone"
4
-
content="https://tangled.sh/{{ .RepoInfo.FullName }}"
5
/>
6
<meta
7
name="forge:summary"
8
-
content="https://tangled.sh/{{ .RepoInfo.FullName }}"
9
/>
10
<meta
11
name="forge:dir"
12
-
content="https://tangled.sh/{{ .RepoInfo.FullName }}/tree/{ref}/{path}"
13
/>
14
<meta
15
name="forge:file"
16
-
content="https://tangled.sh/{{ .RepoInfo.FullName }}/blob/{ref}/{path}"
17
/>
18
<meta
19
name="forge:line"
20
-
content="https://tangled.sh/{{ .RepoInfo.FullName }}/blob/{ref}/{path}#L{line}"
21
/>
22
<meta
23
name="go-import"
24
content="tangled.sh/{{ .RepoInfo.FullNameWithoutAt }} git https://tangled.sh/{{ .RepoInfo.FullName }}"
25
/>
26
{{ end }}
···
1
{{ define "repo/fragments/meta" }}
2
<meta
3
name="vcs:clone"
4
+
content="https://tangled.org/{{ .RepoInfo.FullName }}"
5
/>
6
<meta
7
name="forge:summary"
8
+
content="https://tangled.org/{{ .RepoInfo.FullName }}"
9
/>
10
<meta
11
name="forge:dir"
12
+
content="https://tangled.org/{{ .RepoInfo.FullName }}/tree/{ref}/{path}"
13
/>
14
<meta
15
name="forge:file"
16
+
content="https://tangled.org/{{ .RepoInfo.FullName }}/blob/{ref}/{path}"
17
/>
18
<meta
19
name="forge:line"
20
+
content="https://tangled.org/{{ .RepoInfo.FullName }}/blob/{ref}/{path}#L{line}"
21
/>
22
<meta
23
name="go-import"
24
content="tangled.sh/{{ .RepoInfo.FullNameWithoutAt }} git https://tangled.sh/{{ .RepoInfo.FullName }}"
25
+
/>
26
+
<meta
27
+
name="go-import"
28
+
content="tangled.org/{{ .RepoInfo.FullNameWithoutAt }} git https://tangled.org/{{ .RepoInfo.FullName }}"
29
/>
30
{{ end }}
+1
-1
appview/pages/templates/repo/fragments/og.html
+1
-1
appview/pages/templates/repo/fragments/og.html
+26
appview/pages/templates/repo/fragments/participants.html
+26
appview/pages/templates/repo/fragments/participants.html
···
···
1
+
{{ define "repo/fragments/participants" }}
2
+
{{ $all := . }}
3
+
{{ $ps := take $all 5 }}
4
+
<div class="px-6 md:px-0">
5
+
<div class="py-1 flex items-center text-sm">
6
+
<span class="font-bold text-gray-500 dark:text-gray-400 capitalize">Participants</span>
7
+
<span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 ml-1">{{ len $all }}</span>
8
+
</div>
9
+
<div class="flex items-center -space-x-3 mt-2">
10
+
{{ $c := "z-50 z-40 z-30 z-20 z-10" }}
11
+
{{ range $i, $p := $ps }}
12
+
<img
13
+
src="{{ tinyAvatar . }}"
14
+
alt=""
15
+
class="rounded-full h-8 w-8 mr-1 border-2 border-gray-100 dark:border-gray-900 z-{{sub 5 $i}}0"
16
+
/>
17
+
{{ end }}
18
+
19
+
{{ if gt (len $all) 5 }}
20
+
<span class="pl-4 text-gray-500 dark:text-gray-400 text-sm">
21
+
+{{ sub (len $all) 5 }}
22
+
</span>
23
+
{{ end }}
24
+
</div>
25
+
</div>
26
+
{{ end }}
+24
appview/pages/templates/repo/fragments/readme.html
+24
appview/pages/templates/repo/fragments/readme.html
···
···
1
+
{{ define "repo/fragments/readme" }}
2
+
<div class="mt-4 rounded bg-white dark:bg-gray-800 drop-shadow-sm w-full mx-auto overflow-hidden">
3
+
{{- if .ReadmeFileName -}}
4
+
<div class="px-4 py-2 border-b border-gray-200 dark:border-gray-600 flex items-center gap-2">
5
+
{{ i "file-text" "w-4 h-4" "text-gray-600 dark:text-gray-400" }}
6
+
<span class="font-mono text-sm text-gray-800 dark:text-gray-200">{{ .ReadmeFileName }}</span>
7
+
</div>
8
+
{{- end -}}
9
+
<section
10
+
class="px-6 pb-6 overflow-auto {{ if not .Raw }}
11
+
prose dark:prose-invert dark:[&_pre]:bg-gray-900
12
+
dark:[&_code]:text-gray-300 dark:[&_pre_code]:bg-gray-900
13
+
dark:[&_pre]:border dark:[&_pre]:border-gray-700
14
+
{{ end }}"
15
+
>
16
+
<article class="{{ if .Raw }}whitespace-pre{{ end }}">{{- if .Raw -}}<pre class="dark:bg-gray-800 dark:text-white overflow-x-auto">
17
+
{{- .Readme -}}
18
+
</pre>
19
+
{{- else -}}
20
+
{{ .HTMLReadme }}
21
+
{{- end -}}</article>
22
+
</section>
23
+
</div>
24
+
{{ end }}
+6
-1
appview/pages/templates/repo/fragments/shortTimeAgo.html
+6
-1
appview/pages/templates/repo/fragments/shortTimeAgo.html
···
1
{{ define "repo/fragments/shortTimeAgo" }}
2
+
{{ $formatted := shortRelTimeFmt . }}
3
+
{{ $content := printf "%s ago" $formatted }}
4
+
{{ if eq $formatted "now" }}
5
+
{{ $content = "now" }}
6
+
{{ end }}
7
+
{{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" $content) }}
8
{{ end }}
9
+2
-23
appview/pages/templates/repo/index.html
+2
-23
appview/pages/templates/repo/index.html
···
49
<div
50
class="flex flex-grow items-center gap-2 text-xs align-items-center justify-center"
51
>
52
-
{{ template "repo/fragments/languageBall" $value.Name }}
53
<div>{{ or $value.Name "Other" }}
54
<span class="text-gray-500 dark:text-gray-400">
55
{{ if lt $value.Percentage 0.05 }}
···
340
341
{{ define "repoAfter" }}
342
{{- if or .HTMLReadme .Readme -}}
343
-
<div class="mt-4 rounded bg-white dark:bg-gray-800 drop-shadow-sm w-full mx-auto overflow-hidden">
344
-
{{- if .ReadmeFileName -}}
345
-
<div class="px-4 py-2 bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600 flex items-center gap-2">
346
-
{{ i "file-text" "w-4 h-4" "text-gray-600 dark:text-gray-400" }}
347
-
<span class="font-mono text-sm text-gray-800 dark:text-gray-200">{{ .ReadmeFileName }}</span>
348
-
</div>
349
-
{{- end -}}
350
-
<section
351
-
class="p-6 overflow-auto {{ if not .Raw }}
352
-
prose dark:prose-invert dark:[&_pre]:bg-gray-900
353
-
dark:[&_code]:text-gray-300 dark:[&_pre_code]:bg-gray-900
354
-
dark:[&_pre]:border dark:[&_pre]:border-gray-700
355
-
{{ end }}"
356
-
>
357
-
<article class="{{ if .Raw }}whitespace-pre{{ end }}">{{- if .Raw -}}<pre class="dark:bg-gray-800 dark:text-white overflow-x-auto">
358
-
{{- .Readme -}}
359
-
</pre>
360
-
{{- else -}}
361
-
{{ .HTMLReadme }}
362
-
{{- end -}}</article>
363
-
</section>
364
-
</div>
365
{{- end -}}
366
{{ end }}
···
49
<div
50
class="flex flex-grow items-center gap-2 text-xs align-items-center justify-center"
51
>
52
+
{{ template "repo/fragments/colorBall" (dict "color" (langColor $value.Name)) }}
53
<div>{{ or $value.Name "Other" }}
54
<span class="text-gray-500 dark:text-gray-400">
55
{{ if lt $value.Percentage 0.05 }}
···
340
341
{{ define "repoAfter" }}
342
{{- if or .HTMLReadme .Readme -}}
343
+
{{ template "repo/fragments/readme" . }}
344
{{- end -}}
345
{{ end }}
+4
-4
appview/pages/templates/repo/issues/fragments/commentList.html
+4
-4
appview/pages/templates/repo/issues/fragments/commentList.html
···
3
{{ range $item := .CommentList }}
4
{{ template "commentListing" (list $ .) }}
5
{{ end }}
6
-
<div>
7
{{ end }}
8
9
{{ define "commentListing" }}
···
16
"Issue" $root.Issue
17
"Comment" $comment.Self) }}
18
19
-
<div class="rounded border border-gray-300 dark:border-gray-700 w-full overflow-hidden shadow-sm">
20
{{ template "topLevelComment" $params }}
21
22
-
<div class="relative ml-4 border-l border-gray-300 dark:border-gray-700">
23
{{ range $index, $reply := $comment.Replies }}
24
<div class="relative ">
25
<!-- Horizontal connector -->
26
-
<div class="absolute left-0 top-6 w-4 h-px bg-gray-300 dark:bg-gray-700"></div>
27
28
<div class="pl-2">
29
{{
···
3
{{ range $item := .CommentList }}
4
{{ template "commentListing" (list $ .) }}
5
{{ end }}
6
+
</div>
7
{{ end }}
8
9
{{ define "commentListing" }}
···
16
"Issue" $root.Issue
17
"Comment" $comment.Self) }}
18
19
+
<div class="rounded border border-gray-200 dark:border-gray-700 w-full overflow-hidden shadow-sm bg-gray-50 dark:bg-gray-800/50">
20
{{ template "topLevelComment" $params }}
21
22
+
<div class="relative ml-4 border-l-2 border-gray-200 dark:border-gray-700">
23
{{ range $index, $reply := $comment.Replies }}
24
<div class="relative ">
25
<!-- Horizontal connector -->
26
+
<div class="absolute left-0 top-6 w-4 h-1 bg-gray-200 dark:bg-gray-700"></div>
27
28
<div class="pl-2">
29
{{
+63
appview/pages/templates/repo/issues/fragments/globalIssueListing.html
+63
appview/pages/templates/repo/issues/fragments/globalIssueListing.html
···
···
1
+
{{ define "repo/issues/fragments/globalIssueListing" }}
2
+
<div class="flex flex-col gap-2">
3
+
{{ range .Issues }}
4
+
<div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700">
5
+
<div class="pb-2 mb-3">
6
+
<div class="flex items-center gap-3 mb-2">
7
+
<a
8
+
href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}"
9
+
class="text-blue-600 dark:text-blue-400 font-medium hover:underline text-sm"
10
+
>
11
+
{{ resolve .Repo.Did }}/{{ .Repo.Name }}
12
+
</a>
13
+
</div>
14
+
<a
15
+
href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}/issues/{{ .IssueId }}"
16
+
class="no-underline hover:underline"
17
+
>
18
+
{{ .Title | description }}
19
+
<span class="text-gray-500">#{{ .IssueId }}</span>
20
+
</a>
21
+
</div>
22
+
<div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1">
23
+
{{ $bgColor := "bg-gray-800 dark:bg-gray-700" }}
24
+
{{ $icon := "ban" }}
25
+
{{ $state := "closed" }}
26
+
{{ if .Open }}
27
+
{{ $bgColor = "bg-green-600 dark:bg-green-700" }}
28
+
{{ $icon = "circle-dot" }}
29
+
{{ $state = "open" }}
30
+
{{ end }}
31
+
32
+
<span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm">
33
+
{{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }}
34
+
<span class="text-white dark:text-white">{{ $state }}</span>
35
+
</span>
36
+
37
+
<span class="ml-1">
38
+
{{ template "user/fragments/picHandleLink" .Did }}
39
+
</span>
40
+
41
+
<span class="before:content-['·']">
42
+
{{ template "repo/fragments/time" .Created }}
43
+
</span>
44
+
45
+
<span class="before:content-['·']">
46
+
{{ $s := "s" }}
47
+
{{ if eq (len .Comments) 1 }}
48
+
{{ $s = "" }}
49
+
{{ end }}
50
+
<a href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ len .Comments }} comment{{$s}}</a>
51
+
</span>
52
+
53
+
{{ $state := .Labels }}
54
+
{{ range $k, $d := $.LabelDefs }}
55
+
{{ range $v, $s := $state.GetValSet $d.AtUri.String }}
56
+
{{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }}
57
+
{{ end }}
58
+
{{ end }}
59
+
</div>
60
+
</div>
61
+
{{ end }}
62
+
</div>
63
+
{{ end }}
+55
appview/pages/templates/repo/issues/fragments/issueListing.html
+55
appview/pages/templates/repo/issues/fragments/issueListing.html
···
···
1
+
{{ define "repo/issues/fragments/issueListing" }}
2
+
<div class="flex flex-col gap-2">
3
+
{{ range .Issues }}
4
+
<div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700">
5
+
<div class="pb-2">
6
+
<a
7
+
href="/{{ $.RepoPrefix }}/issues/{{ .IssueId }}"
8
+
class="no-underline hover:underline"
9
+
>
10
+
{{ .Title | description }}
11
+
<span class="text-gray-500">#{{ .IssueId }}</span>
12
+
</a>
13
+
</div>
14
+
<div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1">
15
+
{{ $bgColor := "bg-gray-800 dark:bg-gray-700" }}
16
+
{{ $icon := "ban" }}
17
+
{{ $state := "closed" }}
18
+
{{ if .Open }}
19
+
{{ $bgColor = "bg-green-600 dark:bg-green-700" }}
20
+
{{ $icon = "circle-dot" }}
21
+
{{ $state = "open" }}
22
+
{{ end }}
23
+
24
+
<span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm">
25
+
{{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }}
26
+
<span class="text-white dark:text-white">{{ $state }}</span>
27
+
</span>
28
+
29
+
<span class="ml-1">
30
+
{{ template "user/fragments/picHandleLink" .Did }}
31
+
</span>
32
+
33
+
<span class="before:content-['·']">
34
+
{{ template "repo/fragments/time" .Created }}
35
+
</span>
36
+
37
+
<span class="before:content-['·']">
38
+
{{ $s := "s" }}
39
+
{{ if eq (len .Comments) 1 }}
40
+
{{ $s = "" }}
41
+
{{ end }}
42
+
<a href="/{{ $.RepoPrefix }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ len .Comments }} comment{{$s}}</a>
43
+
</span>
44
+
45
+
{{ $state := .Labels }}
46
+
{{ range $k, $d := $.LabelDefs }}
47
+
{{ range $v, $s := $state.GetValSet $d.AtUri.String }}
48
+
{{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }}
49
+
{{ end }}
50
+
{{ end }}
51
+
</div>
52
+
</div>
53
+
{{ end }}
54
+
</div>
55
+
{{ end }}
+26
-5
appview/pages/templates/repo/issues/issue.html
+26
-5
appview/pages/templates/repo/issues/issue.html
···
3
4
{{ define "extrameta" }}
5
{{ $title := printf "%s · issue #%d · %s" .Issue.Title .Issue.IssueId .RepoInfo.FullName }}
6
-
{{ $url := printf "https://tangled.sh/%s/issues/%d" .RepoInfo.FullName .Issue.IssueId }}
7
8
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
9
{{ end }}
10
11
{{ define "repoContent" }}
12
<section id="issue-{{ .Issue.IssueId }}">
13
{{ template "issueHeader" .Issue }}
···
15
{{ if .Issue.Body }}
16
<article id="body" class="mt-4 prose dark:prose-invert">{{ .Issue.Body | markdown }}</article>
17
{{ end }}
18
-
{{ template "issueReactions" . }}
19
</section>
20
{{ end }}
21
···
86
{{ end }}
87
88
{{ define "issueReactions" }}
89
-
<div class="flex items-center gap-2 mt-2">
90
{{ template "repo/fragments/reactionsPopUp" .OrderedReactionKinds }}
91
{{ range $kind := .OrderedReactionKinds }}
92
{{
···
100
{{ end }}
101
</div>
102
{{ end }}
103
104
{{ define "repoAfter" }}
105
<div class="flex flex-col gap-4 mt-4">
···
113
}}
114
115
{{ template "repo/issues/fragments/newComment" . }}
116
-
<div>
117
{{ end }}
118
-
···
3
4
{{ define "extrameta" }}
5
{{ $title := printf "%s · issue #%d · %s" .Issue.Title .Issue.IssueId .RepoInfo.FullName }}
6
+
{{ $url := printf "https://tangled.org/%s/issues/%d" .RepoInfo.FullName .Issue.IssueId }}
7
8
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
9
{{ end }}
10
11
+
{{ define "repoContentLayout" }}
12
+
<div class="grid grid-cols-1 md:grid-cols-10 gap-4 w-full">
13
+
<div class="col-span-1 md:col-span-8">
14
+
<section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white">
15
+
{{ block "repoContent" . }}{{ end }}
16
+
</section>
17
+
{{ block "repoAfter" . }}{{ end }}
18
+
</div>
19
+
<div class="col-span-1 md:col-span-2 flex flex-col gap-6">
20
+
{{ template "repo/fragments/labelPanel"
21
+
(dict "RepoInfo" $.RepoInfo
22
+
"Defs" $.LabelDefs
23
+
"Subject" $.Issue.AtUri
24
+
"State" $.Issue.Labels) }}
25
+
{{ template "repo/fragments/participants" $.Issue.Participants }}
26
+
</div>
27
+
</div>
28
+
{{ end }}
29
+
30
{{ define "repoContent" }}
31
<section id="issue-{{ .Issue.IssueId }}">
32
{{ template "issueHeader" .Issue }}
···
34
{{ if .Issue.Body }}
35
<article id="body" class="mt-4 prose dark:prose-invert">{{ .Issue.Body | markdown }}</article>
36
{{ end }}
37
+
<div class="flex flex-wrap gap-2 items-stretch mt-4">
38
+
{{ template "issueReactions" . }}
39
+
</div>
40
</section>
41
{{ end }}
42
···
107
{{ end }}
108
109
{{ define "issueReactions" }}
110
+
<div class="flex items-center gap-2">
111
{{ template "repo/fragments/reactionsPopUp" .OrderedReactionKinds }}
112
{{ range $kind := .OrderedReactionKinds }}
113
{{
···
121
{{ end }}
122
</div>
123
{{ end }}
124
+
125
126
{{ define "repoAfter" }}
127
<div class="flex flex-col gap-4 mt-4">
···
135
}}
136
137
{{ template "repo/issues/fragments/newComment" . }}
138
+
</div>
139
{{ end }}
+3
-46
appview/pages/templates/repo/issues/issues.html
+3
-46
appview/pages/templates/repo/issues/issues.html
···
2
3
{{ define "extrameta" }}
4
{{ $title := "issues"}}
5
-
{{ $url := printf "https://tangled.sh/%s/issues" .RepoInfo.FullName }}
6
7
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
8
{{ end }}
···
37
{{ end }}
38
39
{{ define "repoAfter" }}
40
-
<div class="flex flex-col gap-2 mt-2">
41
-
{{ range .Issues }}
42
-
<div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700">
43
-
<div class="pb-2">
44
-
<a
45
-
href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}"
46
-
class="no-underline hover:underline"
47
-
>
48
-
{{ .Title | description }}
49
-
<span class="text-gray-500">#{{ .IssueId }}</span>
50
-
</a>
51
-
</div>
52
-
<p class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1">
53
-
{{ $bgColor := "bg-gray-800 dark:bg-gray-700" }}
54
-
{{ $icon := "ban" }}
55
-
{{ $state := "closed" }}
56
-
{{ if .Open }}
57
-
{{ $bgColor = "bg-green-600 dark:bg-green-700" }}
58
-
{{ $icon = "circle-dot" }}
59
-
{{ $state = "open" }}
60
-
{{ end }}
61
-
62
-
<span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm">
63
-
{{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }}
64
-
<span class="text-white dark:text-white">{{ $state }}</span>
65
-
</span>
66
-
67
-
<span class="ml-1">
68
-
{{ template "user/fragments/picHandleLink" .Did }}
69
-
</span>
70
-
71
-
<span class="before:content-['·']">
72
-
{{ template "repo/fragments/time" .Created }}
73
-
</span>
74
-
75
-
<span class="before:content-['·']">
76
-
{{ $s := "s" }}
77
-
{{ if eq (len .Comments) 1 }}
78
-
{{ $s = "" }}
79
-
{{ end }}
80
-
<a href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ len .Comments }} comment{{$s}}</a>
81
-
</span>
82
-
</p>
83
-
</div>
84
-
{{ end }}
85
</div>
86
{{ block "pagination" . }} {{ end }}
87
{{ end }}
···
2
3
{{ define "extrameta" }}
4
{{ $title := "issues"}}
5
+
{{ $url := printf "https://tangled.org/%s/issues" .RepoInfo.FullName }}
6
7
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
8
{{ end }}
···
37
{{ end }}
38
39
{{ define "repoAfter" }}
40
+
<div class="mt-2">
41
+
{{ template "repo/issues/fragments/issueListing" (dict "Issues" .Issues "RepoPrefix" .RepoInfo.FullName "LabelDefs" .LabelDefs) }}
42
</div>
43
{{ block "pagination" . }} {{ end }}
44
{{ end }}
+1
-1
appview/pages/templates/repo/log.html
+1
-1
appview/pages/templates/repo/log.html
+163
-61
appview/pages/templates/repo/new.html
+163
-61
appview/pages/templates/repo/new.html
···
1
{{ define "title" }}new repo{{ end }}
2
3
{{ define "content" }}
4
-
<div class="p-6">
5
-
<p class="text-xl font-bold dark:text-white">Create a new repository</p>
6
</div>
7
-
<div class="p-6 bg-white dark:bg-gray-800 drop-shadow-sm rounded">
8
-
<form hx-post="/repo/new" class="space-y-12" hx-swap="none" hx-indicator="#spinner">
9
-
<div class="space-y-2">
10
-
<label for="name" class="-mb-1 dark:text-white">Repository name</label>
11
-
<input
12
-
type="text"
13
-
id="name"
14
-
name="name"
15
-
required
16
-
class="w-full max-w-md dark:bg-gray-700 dark:text-white dark:border-gray-600"
17
-
/>
18
-
<p class="text-sm text-gray-500 dark:text-gray-400">All repositories are publicly visible.</p>
19
20
-
<label for="branch" class="dark:text-white">Default branch</label>
21
-
<input
22
-
type="text"
23
-
id="branch"
24
-
name="branch"
25
-
value="main"
26
-
required
27
-
class="w-full max-w-md dark:bg-gray-700 dark:text-white dark:border-gray-600"
28
-
/>
29
30
-
<label for="description" class="dark:text-white">Description</label>
31
-
<input
32
-
type="text"
33
-
id="description"
34
-
name="description"
35
-
class="w-full max-w-md dark:bg-gray-700 dark:text-white dark:border-gray-600"
36
-
/>
37
</div>
38
39
-
<fieldset class="space-y-3">
40
-
<legend class="dark:text-white">Select a knot</legend>
41
<div class="space-y-2">
42
-
<div class="flex flex-col">
43
-
{{ range .Knots }}
44
-
<div class="flex items-center">
45
-
<input
46
-
type="radio"
47
-
name="domain"
48
-
value="{{ . }}"
49
-
class="mr-2"
50
-
id="domain-{{ . }}"
51
-
/>
52
-
<label for="domain-{{ . }}" class="dark:text-white">{{ . }}</label>
53
-
</div>
54
-
{{ else }}
55
-
<p class="dark:text-white">No knots available.</p>
56
-
{{ end }}
57
-
</div>
58
</div>
59
-
<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>
60
-
</fieldset>
61
62
-
<div class="space-y-2">
63
-
<button type="submit" class="btn-create flex items-center gap-2">
64
-
{{ i "book-plus" "w-4 h-4" }}
65
-
create repo
66
-
<span id="spinner" class="group">
67
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
68
-
</span>
69
-
</button>
70
-
<div id="repo" class="error"></div>
71
</div>
72
-
</form>
73
-
</div>
74
{{ end }}
···
1
{{ define "title" }}new repo{{ end }}
2
3
{{ define "content" }}
4
+
<div class="grid grid-cols-12">
5
+
<div class="col-span-full md:col-start-3 md:col-span-8 px-6 py-2 mb-4">
6
+
<h1 class="text-xl font-bold dark:text-white mb-1">Create a new repository</h1>
7
+
<p class="text-gray-600 dark:text-gray-400 mb-1">
8
+
Repositories contain a project's files and version history. All
9
+
repositories are publicly accessible.
10
+
</p>
11
+
</div>
12
+
{{ template "newRepoPanel" . }}
13
</div>
14
+
{{ end }}
15
16
+
{{ define "newRepoPanel" }}
17
+
<div class="col-span-full md:col-start-3 md:col-span-8 bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10">
18
+
{{ template "newRepoForm" . }}
19
+
</div>
20
+
{{ end }}
21
22
+
{{ define "newRepoForm" }}
23
+
<form hx-post="/repo/new" hx-swap="none" hx-indicator="#spinner">
24
+
{{ template "step-1" . }}
25
+
{{ template "step-2" . }}
26
+
27
+
<div class="mt-8 flex justify-end">
28
+
<button type="submit" class="btn-create flex items-center gap-2">
29
+
{{ i "book-plus" "w-4 h-4" }}
30
+
create repo
31
+
<span id="spinner" class="group">
32
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
33
+
</span>
34
+
</button>
35
</div>
36
+
<div id="repo" class="error mt-2"></div>
37
38
+
</form>
39
+
{{ end }}
40
+
41
+
{{ define "step-1" }}
42
+
<div class="flex gap-4 relative border-l border-gray-200 dark:border-gray-700 pl-6">
43
+
<div class="absolute -left-3 -top-0">
44
+
{{ template "numberCircle" 1 }}
45
+
</div>
46
+
47
+
<!-- Content column -->
48
+
<div class="flex-1 pb-12">
49
+
<h2 class="text-lg font-semibold dark:text-white">General</h2>
50
+
<div class="text-sm text-gray-500 dark:text-gray-400 mb-4">Basic repository information.</div>
51
+
52
<div class="space-y-2">
53
+
{{ template "name" . }}
54
+
{{ template "description" . }}
55
</div>
56
+
</div>
57
+
</div>
58
+
{{ end }}
59
60
+
{{ define "step-2" }}
61
+
<div class="flex gap-4 relative border-l border-gray-200 dark:border-gray-700 pl-6">
62
+
<div class="absolute -left-3 -top-0">
63
+
{{ template "numberCircle" 2 }}
64
</div>
65
+
66
+
<div class="flex-1">
67
+
<h2 class="text-lg font-semibold dark:text-white">Configuration</h2>
68
+
<div class="text-sm text-gray-500 dark:text-gray-400 mb-4">Repository settings and hosting.</div>
69
+
70
+
<div class="space-y-2">
71
+
{{ template "defaultBranch" . }}
72
+
{{ template "knot" . }}
73
+
</div>
74
+
</div>
75
+
</div>
76
+
{{ end }}
77
+
78
+
{{ define "name" }}
79
+
<!-- Repository Name with Owner -->
80
+
<div>
81
+
<label class="block text-sm font-bold uppercase dark:text-white mb-1">
82
+
Repository name
83
+
</label>
84
+
<div class="flex flex-col md:flex-row md:items-center gap-2 md:gap-0 w-full">
85
+
<div class="shrink-0 hidden md:flex items-center px-2 py-2 gap-1 text-sm text-gray-700 dark:text-gray-300 md:border md:border-r-0 md:border-gray-300 md:dark:border-gray-600 md:rounded-l md:bg-gray-50 md:dark:bg-gray-700">
86
+
{{ template "user/fragments/picHandle" .LoggedInUser.Did }}
87
+
</div>
88
+
<input
89
+
type="text"
90
+
id="name"
91
+
name="name"
92
+
required
93
+
class="flex-1 dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded md:rounded-r md:rounded-l-none px-3 py-2"
94
+
placeholder="repository-name"
95
+
/>
96
+
</div>
97
+
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
98
+
Choose a unique, descriptive name for your repository. Use letters, numbers, and hyphens.
99
+
</p>
100
+
</div>
101
+
{{ end }}
102
+
103
+
{{ define "description" }}
104
+
<!-- Description -->
105
+
<div>
106
+
<label for="description" class="block text-sm font-bold uppercase dark:text-white mb-1">
107
+
Description
108
+
</label>
109
+
<input
110
+
type="text"
111
+
id="description"
112
+
name="description"
113
+
class="w-full w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded px-3 py-2"
114
+
placeholder="A brief description of your project..."
115
+
/>
116
+
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
117
+
Optional. A short description to help others understand what your project does.
118
+
</p>
119
+
</div>
120
+
{{ end }}
121
+
122
+
{{ define "defaultBranch" }}
123
+
<!-- Default Branch -->
124
+
<div>
125
+
<label for="branch" class="block text-sm font-bold uppercase dark:text-white mb-1">
126
+
Default branch
127
+
</label>
128
+
<input
129
+
type="text"
130
+
id="branch"
131
+
name="branch"
132
+
value="main"
133
+
required
134
+
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded px-3 py-2"
135
+
/>
136
+
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
137
+
The primary branch where development happens. Common choices are "main" or "master".
138
+
</p>
139
+
</div>
140
+
{{ end }}
141
+
142
+
{{ define "knot" }}
143
+
<!-- Knot Selection -->
144
+
<div>
145
+
<label class="block text-sm font-bold uppercase dark:text-white mb-1">
146
+
Select a knot
147
+
</label>
148
+
<div class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded p-3 space-y-2">
149
+
{{ range .Knots }}
150
+
<div class="flex items-center">
151
+
<input
152
+
type="radio"
153
+
name="domain"
154
+
value="{{ . }}"
155
+
class="mr-2"
156
+
id="domain-{{ . }}"
157
+
required
158
+
/>
159
+
<label for="domain-{{ . }}" class="dark:text-white lowercase">{{ . }}</label>
160
+
</div>
161
+
{{ else }}
162
+
<p class="dark:text-white">no knots available.</p>
163
+
{{ end }}
164
+
</div>
165
+
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
166
+
A knot hosts repository data and handles Git operations.
167
+
You can also <a href="/knots" class="underline">register your own knot</a>.
168
+
</p>
169
+
</div>
170
+
{{ end }}
171
+
172
+
{{ define "numberCircle" }}
173
+
<div class="w-6 h-6 bg-gray-200 dark:bg-gray-600 rounded-full flex items-center justify-center text-sm font-medium mt-1">
174
+
{{.}}
175
+
</div>
176
{{ end }}
+2
-2
appview/pages/templates/repo/pipelines/pipelines.html
+2
-2
appview/pages/templates/repo/pipelines/pipelines.html
···
2
3
{{ define "extrameta" }}
4
{{ $title := "pipelines"}}
5
-
{{ $url := printf "https://tangled.sh/%s/pipelines" .RepoInfo.FullName }}
6
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
7
{{ end }}
8
···
60
<span class="inline-flex gap-2 items-center">
61
<span class="font-bold">{{ $target }}</span>
62
{{ i "arrow-left" "size-4" }}
63
-
{{ .Trigger.PRSourceBranch }}
64
<span class="text-sm font-mono">
65
@
66
<a href="/{{ $root.RepoInfo.FullName }}/commit/{{ $sha }}">{{ slice $sha 0 8 }}</a>
···
2
3
{{ define "extrameta" }}
4
{{ $title := "pipelines"}}
5
+
{{ $url := printf "https://tangled.org/%s/pipelines" .RepoInfo.FullName }}
6
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
7
{{ end }}
8
···
60
<span class="inline-flex gap-2 items-center">
61
<span class="font-bold">{{ $target }}</span>
62
{{ i "arrow-left" "size-4" }}
63
+
{{ .Trigger.PRSourceBranch }}
64
<span class="text-sm font-mono">
65
@
66
<a href="/{{ $root.RepoInfo.FullName }}/commit/{{ $sha }}">{{ slice $sha 0 8 }}</a>
+1
-1
appview/pages/templates/repo/pipelines/workflow.html
+1
-1
appview/pages/templates/repo/pipelines/workflow.html
+2
-2
appview/pages/templates/repo/pulls/interdiff.html
+2
-2
appview/pages/templates/repo/pulls/interdiff.html
···
5
6
{{ define "extrameta" }}
7
{{ $title := printf "interdiff of %d and %d · %s · pull #%d · %s" .Round (sub .Round 1) .Pull.Title .Pull.PullId .RepoInfo.FullName }}
8
-
{{ $url := printf "https://tangled.sh/%s/pulls/%d/round/%d" .RepoInfo.FullName .Pull.PullId .Round }}
9
-
10
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" (unescapeHtml $title) "Url" $url) }}
11
{{ end }}
12
···
5
6
{{ define "extrameta" }}
7
{{ $title := printf "interdiff of %d and %d · %s · pull #%d · %s" .Round (sub .Round 1) .Pull.Title .Pull.PullId .RepoInfo.FullName }}
8
+
{{ $url := printf "https://tangled.org/%s/pulls/%d/round/%d" .RepoInfo.FullName .Pull.PullId .Round }}
9
+
10
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" (unescapeHtml $title) "Url" $url) }}
11
{{ end }}
12
+2
-2
appview/pages/templates/repo/pulls/patch.html
+2
-2
appview/pages/templates/repo/pulls/patch.html
···
5
6
{{ define "extrameta" }}
7
{{ $title := printf "patch of %s · pull #%d · %s" .Pull.Title .Pull.PullId .RepoInfo.FullName }}
8
-
{{ $url := printf "https://tangled.sh/%s/pulls/%d/round/%d" .RepoInfo.FullName .Pull.PullId .Round }}
9
-
10
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
11
{{ end }}
12
···
5
6
{{ define "extrameta" }}
7
{{ $title := printf "patch of %s · pull #%d · %s" .Pull.Title .Pull.PullId .RepoInfo.FullName }}
8
+
{{ $url := printf "https://tangled.org/%s/pulls/%d/round/%d" .RepoInfo.FullName .Pull.PullId .Round }}
9
+
10
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
11
{{ end }}
12
+31
-13
appview/pages/templates/repo/pulls/pull.html
+31
-13
appview/pages/templates/repo/pulls/pull.html
···
4
5
{{ define "extrameta" }}
6
{{ $title := printf "%s · pull #%d · %s" .Pull.Title .Pull.PullId .RepoInfo.FullName }}
7
-
{{ $url := printf "https://tangled.sh/%s/pulls/%d" .RepoInfo.FullName .Pull.PullId }}
8
9
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
10
{{ end }}
11
12
13
{{ define "repoContent" }}
14
{{ template "repo/pulls/fragments/pullHeader" . }}
···
39
{{ with $item }}
40
<details {{ if eq $idx $lastIdx }}open{{ end }}>
41
<summary id="round-#{{ .RoundNumber }}" class="list-none cursor-pointer">
42
-
<div class="flex flex-wrap gap-2 items-center">
43
<!-- round number -->
44
<div class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-3 py-2 dark:text-white">
45
<span class="flex items-center">{{ i "hash" "w-4 h-4" }}{{ .RoundNumber }}</span>
46
</div>
47
<!-- round summary -->
48
-
<div class="rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400">
49
<span class="gap-1 flex items-center">
50
{{ $owner := resolve $.Pull.OwnerDid }}
51
{{ $re := "re" }}
···
72
<span class="hidden md:inline">diff</span>
73
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
74
</a>
75
-
{{ if not (eq .RoundNumber 0) }}
76
-
<a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group"
77
-
hx-boost="true"
78
-
href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}/interdiff">
79
-
{{ i "chevrons-left-right-ellipsis" "w-4 h-4 rotate-90" }}
80
-
<span class="hidden md:inline">interdiff</span>
81
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
82
-
</a>
83
-
<span id="interdiff-error-{{.RoundNumber}}"></span>
84
{{ end }}
85
</div>
86
</summary>
87
···
146
147
<div class="md:pl-[3.5rem] flex flex-col gap-2 mt-2 relative">
148
{{ range $cidx, $c := .Comments }}
149
-
<div id="comment-{{$c.ID}}" class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full md:max-w-3/5 md:w-fit">
150
{{ if gt $cidx 0 }}
151
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div>
152
{{ end }}
···
4
5
{{ define "extrameta" }}
6
{{ $title := printf "%s · pull #%d · %s" .Pull.Title .Pull.PullId .RepoInfo.FullName }}
7
+
{{ $url := printf "https://tangled.org/%s/pulls/%d" .RepoInfo.FullName .Pull.PullId }}
8
9
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
10
{{ end }}
11
12
+
{{ define "repoContentLayout" }}
13
+
<div class="grid grid-cols-1 md:grid-cols-10 gap-4 w-full">
14
+
<div class="col-span-1 md:col-span-8">
15
+
<section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white">
16
+
{{ block "repoContent" . }}{{ end }}
17
+
</section>
18
+
{{ block "repoAfter" . }}{{ end }}
19
+
</div>
20
+
<div class="col-span-1 md:col-span-2 flex flex-col gap-6">
21
+
{{ template "repo/fragments/labelPanel"
22
+
(dict "RepoInfo" $.RepoInfo
23
+
"Defs" $.LabelDefs
24
+
"Subject" $.Pull.PullAt
25
+
"State" $.Pull.Labels) }}
26
+
{{ template "repo/fragments/participants" $.Pull.Participants }}
27
+
</div>
28
+
</div>
29
+
{{ end }}
30
31
{{ define "repoContent" }}
32
{{ template "repo/pulls/fragments/pullHeader" . }}
···
57
{{ with $item }}
58
<details {{ if eq $idx $lastIdx }}open{{ end }}>
59
<summary id="round-#{{ .RoundNumber }}" class="list-none cursor-pointer">
60
+
<div class="flex flex-wrap gap-2 items-stretch">
61
<!-- round number -->
62
<div class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-3 py-2 dark:text-white">
63
<span class="flex items-center">{{ i "hash" "w-4 h-4" }}{{ .RoundNumber }}</span>
64
</div>
65
<!-- round summary -->
66
+
<div class="flex-1 rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400">
67
<span class="gap-1 flex items-center">
68
{{ $owner := resolve $.Pull.OwnerDid }}
69
{{ $re := "re" }}
···
90
<span class="hidden md:inline">diff</span>
91
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
92
</a>
93
+
{{ if ne $idx 0 }}
94
+
<a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group"
95
+
hx-boost="true"
96
+
href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}/interdiff">
97
+
{{ i "chevrons-left-right-ellipsis" "w-4 h-4 rotate-90" }}
98
+
<span class="hidden md:inline">interdiff</span>
99
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
100
+
</a>
101
{{ end }}
102
+
<span id="interdiff-error-{{.RoundNumber}}"></span>
103
</div>
104
</summary>
105
···
164
165
<div class="md:pl-[3.5rem] flex flex-col gap-2 mt-2 relative">
166
{{ range $cidx, $c := .Comments }}
167
+
<div id="comment-{{$c.ID}}" class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full">
168
{{ if gt $cidx 0 }}
169
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div>
170
{{ end }}
+8
-1
appview/pages/templates/repo/pulls/pulls.html
+8
-1
appview/pages/templates/repo/pulls/pulls.html
···
2
3
{{ define "extrameta" }}
4
{{ $title := "pulls"}}
5
-
{{ $url := printf "https://tangled.sh/%s/pulls" .RepoInfo.FullName }}
6
7
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
8
{{ end }}
···
107
{{ if and $pipeline $pipeline.Id }}
108
<span class="before:content-['·']"></span>
109
{{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }}
110
{{ end }}
111
</div>
112
</div>
···
2
3
{{ define "extrameta" }}
4
{{ $title := "pulls"}}
5
+
{{ $url := printf "https://tangled.org/%s/pulls" .RepoInfo.FullName }}
6
7
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
8
{{ end }}
···
107
{{ if and $pipeline $pipeline.Id }}
108
<span class="before:content-['·']"></span>
109
{{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }}
110
+
{{ end }}
111
+
112
+
{{ $state := .Labels }}
113
+
{{ range $k, $d := $.LabelDefs }}
114
+
{{ range $v, $s := $state.GetValSet $d.AtUri.String }}
115
+
{{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }}
116
+
{{ end }}
117
{{ end }}
118
</div>
119
</div>
+165
appview/pages/templates/repo/settings/fragments/addLabelDefModal.html
+165
appview/pages/templates/repo/settings/fragments/addLabelDefModal.html
···
···
1
+
{{ define "repo/settings/fragments/addLabelDefModal" }}
2
+
<div class="grid grid-cols-2">
3
+
<input type="radio" name="tab" id="basic-tab" value="basic" class="hidden peer/basic" checked>
4
+
<input type="radio" name="tab" id="kv-tab" value="kv" class="hidden peer/kv">
5
+
6
+
<!-- Labels as direct siblings -->
7
+
{{ $base := "py-2 text-sm font-normal normal-case block hover:no-underline text-center cursor-pointer bg-gray-100 dark:bg-gray-800 shadow-inner border border-gray-200 dark:border-gray-700" }}
8
+
<label for="basic-tab" class="{{$base}} peer-checked/basic:bg-white peer-checked/basic:dark:bg-gray-700 peer-checked/basic:shadow-sm rounded-l">
9
+
Basic Labels
10
+
</label>
11
+
<label for="kv-tab" class="{{$base}} peer-checked/kv:bg-white peer-checked/kv:dark:bg-gray-700 peer-checked/kv:shadow-sm rounded-r">
12
+
Key-value Labels
13
+
</label>
14
+
15
+
<!-- Basic Labels Content - direct sibling -->
16
+
<div class="mt-4 hidden peer-checked/basic:block col-span-full">
17
+
{{ template "basicLabelDef" . }}
18
+
</div>
19
+
20
+
<!-- Key-value Labels Content - direct sibling -->
21
+
<div class="mt-4 hidden peer-checked/kv:block col-span-full">
22
+
{{ template "kvLabelDef" . }}
23
+
</div>
24
+
25
+
<div id="add-label-error" class="text-red-500 dark:text-red-400 col-span-full"></div>
26
+
</div>
27
+
{{ end }}
28
+
29
+
{{ define "basicLabelDef" }}
30
+
<form
31
+
hx-put="/{{ $.RepoInfo.FullName }}/settings/label"
32
+
hx-indicator="#spinner"
33
+
hx-swap="none"
34
+
hx-on::after-request="if(event.detail.successful) this.reset()"
35
+
class="flex flex-col space-y-4">
36
+
37
+
<p class="text-gray-500 dark:text-gray-400">These labels can have a name and a color.</p>
38
+
39
+
{{ template "nameInput" . }}
40
+
{{ template "scopeInput" . }}
41
+
{{ template "colorInput" . }}
42
+
43
+
<div class="flex gap-2 pt-2">
44
+
{{ template "cancelButton" . }}
45
+
{{ template "submitButton" . }}
46
+
</div>
47
+
</form>
48
+
{{ end }}
49
+
50
+
{{ define "kvLabelDef" }}
51
+
<form
52
+
hx-put="/{{ $.RepoInfo.FullName }}/settings/label"
53
+
hx-indicator="#spinner"
54
+
hx-swap="none"
55
+
hx-on::after-request="if(event.detail.successful) this.reset()"
56
+
class="flex flex-col space-y-4">
57
+
58
+
<p class="text-gray-500 dark:text-gray-400">
59
+
These labels are more detailed, they can have a key and an associated
60
+
value. You may define additional constraints on label values.
61
+
</p>
62
+
63
+
{{ template "nameInput" . }}
64
+
{{ template "valueInput" . }}
65
+
{{ template "multipleInput" . }}
66
+
{{ template "scopeInput" . }}
67
+
{{ template "colorInput" . }}
68
+
69
+
<div class="flex gap-2 pt-2">
70
+
{{ template "cancelButton" . }}
71
+
{{ template "submitButton" . }}
72
+
</div>
73
+
</form>
74
+
{{ end }}
75
+
76
+
{{ define "nameInput" }}
77
+
<div class="w-full">
78
+
<label for="name">Name</label>
79
+
<input class="w-full" type="text" id="label-name" name="name" required placeholder="improvement"/>
80
+
</div>
81
+
{{ end }}
82
+
83
+
{{ define "colorInput" }}
84
+
<div class="w-full">
85
+
<label for="color">Color</label>
86
+
<div class="grid grid-cols-4 grid-rows-2 place-items-center">
87
+
{{ $colors := list "#ef4444" "#3b82f6" "#10b981" "#f59e0b" "#8b5cf6" "#ec4899" "#06b6d4" "#64748b" }}
88
+
{{ range $i, $color := $colors }}
89
+
<label class="relative">
90
+
<input type="radio" name="color" value="{{ $color }}" class="sr-only peer" {{ if eq $i 0 }} checked {{ end }}>
91
+
{{ template "repo/fragments/colorBall" (dict "color" $color "classes" "size-4 peer-checked:size-8 transition-all") }}
92
+
</label>
93
+
{{ end }}
94
+
</div>
95
+
</div>
96
+
{{ end }}
97
+
98
+
{{ define "scopeInput" }}
99
+
<div class="w-full">
100
+
<label>Scope</label>
101
+
<label class="font-normal normal-case flex items-center gap-2 p-0">
102
+
<input type="checkbox" id="issue-scope" name="scope" value="sh.tangled.repo.issue" checked />
103
+
Issues
104
+
</label>
105
+
<label class="font-normal normal-case flex items-center gap-2 p-0">
106
+
<input type="checkbox" id="pulls-scope" name="scope" value="sh.tangled.repo.pull" checked />
107
+
Pull Requests
108
+
</label>
109
+
</div>
110
+
{{ end }}
111
+
112
+
{{ define "valueInput" }}
113
+
<div class="w-full">
114
+
<label for="valueType">Value Type</label>
115
+
<select id="value-type" name="valueType" class="w-full p-3 rounded border border-gray-300 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-600">
116
+
<option value="string">String</option>
117
+
<option value="integer">Integer</option>
118
+
</select>
119
+
</div>
120
+
121
+
<div class="w-full">
122
+
<label for="enumValues">Permitted values</label>
123
+
<input type="text" id="enumValues" name="enumValues" placeholder="value1, value2, value3" class="w-full"/>
124
+
<p class="text-sm text-gray-400 dark:text-gray-500 mt-1">
125
+
Enter comma-separated list of permitted values, or leave empty to allow any value.
126
+
</p>
127
+
</div>
128
+
129
+
<div class="w-full">
130
+
<label for="valueFormat">String format</label>
131
+
<select id="valueFormat" name="valueFormat" class="w-full p-3 rounded border border-gray-300 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-600">
132
+
<option value="any" selected>Any</option>
133
+
<option value="did">DID</option>
134
+
</select>
135
+
</div>
136
+
{{ end }}
137
+
138
+
{{ define "multipleInput" }}
139
+
<div class="w-full flex flex-wrap gap-2">
140
+
<input type="checkbox" id="multiple" name="multiple" value="true" />
141
+
<span>Allow multiple values</span>
142
+
</div>
143
+
{{ end }}
144
+
145
+
{{ define "cancelButton" }}
146
+
<button
147
+
type="button"
148
+
popovertarget="add-labeldef-modal"
149
+
popovertargetaction="hide"
150
+
class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
151
+
>
152
+
{{ i "x" "size-4" }} cancel
153
+
</button>
154
+
{{ end }}
155
+
156
+
{{ define "submitButton" }}
157
+
<button type="submit" class="btn-create w-1/2 flex items-center">
158
+
<span class="inline-flex gap-2 items-center">{{ i "plus" "size-4" }} add</span>
159
+
<span id="spinner" class="group">
160
+
{{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
161
+
</span>
162
+
</button>
163
+
{{ end }}
164
+
165
+
+32
appview/pages/templates/repo/settings/fragments/labelListing.html
+32
appview/pages/templates/repo/settings/fragments/labelListing.html
···
···
1
+
{{ define "repo/settings/fragments/labelListing" }}
2
+
{{ $root := index . 0 }}
3
+
{{ $label := index . 1 }}
4
+
<div class="flex flex-col gap-1 text-sm min-w-0 max-w-[80%]">
5
+
{{ template "labels/fragments/labelDef" $label }}
6
+
<div class="flex flex-wrap text items-center gap-1 text-gray-500 dark:text-gray-400">
7
+
{{ if $label.ValueType.IsNull }}
8
+
basic
9
+
{{ else }}
10
+
{{ $label.ValueType.Type }} type
11
+
{{ end }}
12
+
13
+
{{ if $label.ValueType.IsEnum }}
14
+
<span class="before:content-['·'] before:select-none"></span>
15
+
{{ join $label.ValueType.Enum ", " }}
16
+
{{ end }}
17
+
18
+
{{ if $label.ValueType.IsDidFormat }}
19
+
<span class="before:content-['·'] before:select-none"></span>
20
+
DID format
21
+
{{ end }}
22
+
23
+
{{ if $label.Multiple }}
24
+
<span class="before:content-['·'] before:select-none"></span>
25
+
multiple
26
+
{{ end }}
27
+
28
+
<span class="before:content-['·'] before:select-none"></span>
29
+
{{ join $label.Scope ", " }}
30
+
</div>
31
+
</div>
32
+
{{ end }}
+126
appview/pages/templates/repo/settings/general.html
+126
appview/pages/templates/repo/settings/general.html
···
7
</div>
8
<div class="col-span-1 md:col-span-3 flex flex-col gap-6 p-2">
9
{{ template "branchSettings" . }}
10
{{ template "deleteRepo" . }}
11
<div id="operation-error" class="text-red-500 dark:text-red-400"></div>
12
</div>
···
42
</div>
43
{{ end }}
44
45
{{ define "deleteRepo" }}
46
{{ if .RepoInfo.Roles.RepoDeleteAllowed }}
47
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center">
···
68
</div>
69
{{ end }}
70
{{ end }}
···
7
</div>
8
<div class="col-span-1 md:col-span-3 flex flex-col gap-6 p-2">
9
{{ template "branchSettings" . }}
10
+
{{ template "defaultLabelSettings" . }}
11
+
{{ template "customLabelSettings" . }}
12
{{ template "deleteRepo" . }}
13
<div id="operation-error" class="text-red-500 dark:text-red-400"></div>
14
</div>
···
44
</div>
45
{{ end }}
46
47
+
{{ define "defaultLabelSettings" }}
48
+
<div class="flex flex-col gap-2">
49
+
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center">
50
+
<div class="col-span-1 md:col-span-2">
51
+
<h2 class="text-sm pb-2 uppercase font-bold">Default Labels</h2>
52
+
<p class="text-gray-500 dark:text-gray-400">
53
+
Manage your issues and pulls by creating labels to categorize them. Only
54
+
repository owners may configure labels. You may choose to subscribe to
55
+
default labels, or create entirely custom labels.
56
+
<p>
57
+
</div>
58
+
<form class="col-span-1 md:col-span-1 md:justify-self-end">
59
+
{{ $title := "Unubscribe from all labels" }}
60
+
{{ $icon := "x" }}
61
+
{{ $text := "unsubscribe all" }}
62
+
{{ $action := "unsubscribe" }}
63
+
{{ if $.ShouldSubscribeAll }}
64
+
{{ $title = "Subscribe to all labels" }}
65
+
{{ $icon = "check-check" }}
66
+
{{ $text = "subscribe all" }}
67
+
{{ $action = "subscribe" }}
68
+
{{ end }}
69
+
{{ range .DefaultLabels }}
70
+
<input type="hidden" name="label" value="{{ .AtUri.String }}">
71
+
{{ end }}
72
+
<button
73
+
type="submit"
74
+
title="{{$title}}"
75
+
class="btn flex items-center gap-2 group"
76
+
hx-swap="none"
77
+
hx-post="/{{ $.RepoInfo.FullName }}/settings/label/{{$action}}"
78
+
{{ if not .RepoInfo.Roles.IsOwner }}disabled{{ end }}>
79
+
{{ i $icon "size-4" }}
80
+
{{ $text }}
81
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
82
+
</button>
83
+
</form>
84
+
</div>
85
+
<div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full">
86
+
{{ range .DefaultLabels }}
87
+
<div id="label-{{.Id}}" class="flex items-center justify-between p-2 pl-4">
88
+
{{ template "repo/settings/fragments/labelListing" (list $ .) }}
89
+
{{ $action := "subscribe" }}
90
+
{{ $icon := "plus" }}
91
+
{{ if mapContains $.SubscribedLabels .AtUri.String }}
92
+
{{ $action = "unsubscribe" }}
93
+
{{ $icon = "minus" }}
94
+
{{ end }}
95
+
<button
96
+
class="btn gap-2 group"
97
+
title="{{$action}} from label"
98
+
{{ if not $.RepoInfo.Roles.IsOwner }}disabled{{ end }}
99
+
hx-post="/{{ $.RepoInfo.FullName }}/settings/label/{{$action}}"
100
+
hx-swap="none"
101
+
hx-vals='{"label": "{{ .AtUri.String }}"}'>
102
+
{{ i $icon "size-4" }}
103
+
<span class="hidden md:inline">{{$action}}</span>
104
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
105
+
</button>
106
+
</div>
107
+
{{ else }}
108
+
<div class="flex items-center justify-center p-2 text-gray-500">
109
+
no labels added yet
110
+
</div>
111
+
{{ end }}
112
+
</div>
113
+
<div id="default-label-operation" class="error"></div>
114
+
</div>
115
+
{{ end }}
116
+
117
+
{{ define "customLabelSettings" }}
118
+
<div class="flex flex-col gap-2">
119
+
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center">
120
+
<div class="col-span-1 md:col-span-2">
121
+
<h2 class="text-sm pb-2 uppercase font-bold">Custom Labels</h2>
122
+
</div>
123
+
<div class="col-span-1 md:col-span-1 md:justify-self-end">
124
+
<button
125
+
title="Add custom label"
126
+
class="btn flex items-center gap-2"
127
+
popovertarget="add-labeldef-modal"
128
+
{{ if not .RepoInfo.Roles.IsOwner }}disabled{{ end }}
129
+
popovertargetaction="toggle">
130
+
{{ i "plus" "size-4" }}
131
+
add label
132
+
</button>
133
+
<div
134
+
id="add-labeldef-modal"
135
+
popover
136
+
class="bg-white w-full sm:w-[30rem] dark:bg-gray-800 p-6 max-h-dvh overflow-y-auto 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">
137
+
{{ template "repo/settings/fragments/addLabelDefModal" . }}
138
+
</div>
139
+
</div>
140
+
</div>
141
+
<div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full">
142
+
{{ range .Labels }}
143
+
<div id="label-{{.Id}}" class="flex items-center justify-between p-2 pl-4">
144
+
{{ template "repo/settings/fragments/labelListing" (list $ .) }}
145
+
{{ if $.RepoInfo.Roles.IsOwner }}
146
+
<button
147
+
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group"
148
+
title="Delete label"
149
+
hx-delete="/{{ $.RepoInfo.FullName }}/settings/label"
150
+
hx-swap="none"
151
+
hx-vals='{"label-id": "{{ .Id }}"}'
152
+
hx-confirm="Are you sure you want to delete the label `{{ .Name }}`?"
153
+
>
154
+
{{ i "trash-2" "w-5 h-5" }}
155
+
<span class="hidden md:inline">delete</span>
156
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
157
+
</button>
158
+
{{ end }}
159
+
</div>
160
+
{{ else }}
161
+
<div class="flex items-center justify-center p-2 text-gray-500">
162
+
no labels added yet
163
+
</div>
164
+
{{ end }}
165
+
</div>
166
+
<div id="label-operation" class="error"></div>
167
+
</div>
168
+
{{ end }}
169
+
170
{{ define "deleteRepo" }}
171
{{ if .RepoInfo.Roles.RepoDeleteAllowed }}
172
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center">
···
193
</div>
194
{{ end }}
195
{{ end }}
196
+
+2
-2
appview/pages/templates/repo/settings/pipelines.html
+2
-2
appview/pages/templates/repo/settings/pipelines.html
···
22
<p class="text-gray-500 dark:text-gray-400">
23
Choose a spindle to execute your workflows on. Only repository owners
24
can configure spindles. Spindles can be selfhosted,
25
-
<a class="text-gray-500 dark:text-gray-400 underline" href="https://tangled.sh/@tangled.sh/core/blob/master/docs/spindle/hosting.md">
26
click to learn more.
27
</a>
28
</p>
···
109
hx-swap="none"
110
class="flex flex-col gap-2"
111
>
112
-
<p class="uppercase p-0">ADD SECRET</p>
113
<p class="text-sm text-gray-500 dark:text-gray-400">Secrets are available as environment variables in the workflow.</p>
114
<input
115
type="text"
···
22
<p class="text-gray-500 dark:text-gray-400">
23
Choose a spindle to execute your workflows on. Only repository owners
24
can configure spindles. Spindles can be selfhosted,
25
+
<a class="text-gray-500 dark:text-gray-400 underline" href="https://tangled.org/@tangled.org/core/blob/master/docs/spindle/hosting.md">
26
click to learn more.
27
</a>
28
</p>
···
109
hx-swap="none"
110
class="flex flex-col gap-2"
111
>
112
+
<p class="uppercase p-0 font-bold">ADD SECRET</p>
113
<p class="text-sm text-gray-500 dark:text-gray-400">Secrets are available as environment variables in the workflow.</p>
114
<input
115
type="text"
+10
-4
appview/pages/templates/repo/tree.html
+10
-4
appview/pages/templates/repo/tree.html
···
10
11
{{ template "repo/fragments/meta" . }}
12
{{ $title := printf "%s at %s · %s" $path .Ref .RepoInfo.FullName }}
13
-
{{ $url := printf "https://tangled.sh/%s/tree/%s%s" .RepoInfo.FullName .Ref $path }}
14
15
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
16
{{ end }}
···
25
<div class="flex flex-col md:flex-row md:justify-between gap-2">
26
<div id="breadcrumbs" class="overflow-x-auto whitespace-nowrap text-gray-400 dark:text-gray-500">
27
{{ range .BreadCrumbs }}
28
-
<a href="{{ index . 1}}" class="text-bold text-gray-500 dark:text-gray-400 {{ $linkstyle }}">{{ pathUnescape (index . 0) }}</a> /
29
{{ end }}
30
</div>
31
<div id="dir-info" class="text-gray-500 dark:text-gray-400 text-xs md:text-sm flex flex-wrap items-center gap-1 md:gap-0">
32
{{ $stats := .TreeStats }}
33
34
-
<span>at <a href="/{{ $.RepoInfo.FullName }}/tree/{{ $.Ref }}">{{ $.Ref }}</a></span>
35
{{ if eq $stats.NumFolders 1 }}
36
<span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span>
37
<span>{{ $stats.NumFolders }} folder</span>
···
55
{{ range .Files }}
56
<div class="grid grid-cols-12 gap-4 items-center py-1">
57
<div class="col-span-8 md:col-span-4">
58
-
{{ $link := printf "/%s/%s/%s/%s/%s" $.RepoInfo.FullName "tree" (urlquery $.Ref) $.TreePath .Name }}
59
{{ $icon := "folder" }}
60
{{ $iconStyle := "size-4 fill-current" }}
61
···
88
</div>
89
</main>
90
{{end}}
···
10
11
{{ template "repo/fragments/meta" . }}
12
{{ $title := printf "%s at %s · %s" $path .Ref .RepoInfo.FullName }}
13
+
{{ $url := printf "https://tangled.org/%s/tree/%s%s" .RepoInfo.FullName .Ref $path }}
14
15
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
16
{{ end }}
···
25
<div class="flex flex-col md:flex-row md:justify-between gap-2">
26
<div id="breadcrumbs" class="overflow-x-auto whitespace-nowrap text-gray-400 dark:text-gray-500">
27
{{ range .BreadCrumbs }}
28
+
<a href="{{ index . 1 }}" class="text-bold text-gray-500 dark:text-gray-400 {{ $linkstyle }}">{{ pathUnescape (index . 0) }}</a> /
29
{{ end }}
30
</div>
31
<div id="dir-info" class="text-gray-500 dark:text-gray-400 text-xs md:text-sm flex flex-wrap items-center gap-1 md:gap-0">
32
{{ $stats := .TreeStats }}
33
34
+
<span>at <a href="/{{ $.RepoInfo.FullName }}/tree/{{ pathEscape $.Ref }}">{{ $.Ref }}</a></span>
35
{{ if eq $stats.NumFolders 1 }}
36
<span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span>
37
<span>{{ $stats.NumFolders }} folder</span>
···
55
{{ range .Files }}
56
<div class="grid grid-cols-12 gap-4 items-center py-1">
57
<div class="col-span-8 md:col-span-4">
58
+
{{ $link := printf "/%s/%s/%s/%s/%s" $.RepoInfo.FullName "tree" (pathEscape $.Ref) $.TreePath .Name }}
59
{{ $icon := "folder" }}
60
{{ $iconStyle := "size-4 fill-current" }}
61
···
88
</div>
89
</main>
90
{{end}}
91
+
92
+
{{ define "repoAfter" }}
93
+
{{- if or .HTMLReadme .Readme -}}
94
+
{{ template "repo/fragments/readme" . }}
95
+
{{- end -}}
96
+
{{ end }}
+3
-7
appview/pages/templates/spindles/index.html
+3
-7
appview/pages/templates/spindles/index.html
···
1
{{ define "title" }}spindles{{ end }}
2
3
{{ define "content" }}
4
-
<div class="px-6 py-4 flex items-end justify-start gap-4 align-bottom">
5
<h1 class="text-xl font-bold dark:text-white">Spindles</h1>
6
-
7
-
8
-
<span class="flex items-center gap-1 text-sm">
9
{{ i "book" "w-3 h-3" }}
10
-
<a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/spindle/hosting.md">
11
-
docs
12
-
</a>
13
</span>
14
</div>
15
···
1
{{ define "title" }}spindles{{ end }}
2
3
{{ define "content" }}
4
+
<div class="px-6 py-4 flex items-center justify-between gap-4 align-bottom">
5
<h1 class="text-xl font-bold dark:text-white">Spindles</h1>
6
+
<span class="flex items-center gap-1">
7
{{ i "book" "w-3 h-3" }}
8
+
<a href="https://tangled.org/@tangled.org/core/blob/master/docs/spindle/hosting.md">docs</a>
9
</span>
10
</div>
11
+1
-1
appview/pages/templates/strings/dashboard.html
+1
-1
appview/pages/templates/strings/dashboard.html
···
3
{{ define "extrameta" }}
4
<meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" />
5
<meta property="og:type" content="profile" />
6
-
<meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}" />
7
<meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" />
8
{{ end }}
9
···
3
{{ define "extrameta" }}
4
<meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" />
5
<meta property="og:type" content="profile" />
6
+
<meta property="og:url" content="https://tangled.org/{{ or .Card.UserHandle .Card.UserDid }}" />
7
<meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" />
8
{{ end }}
9
+2
-2
appview/pages/templates/strings/put.html
+2
-2
appview/pages/templates/strings/put.html
···
3
{{ define "content" }}
4
<div class="px-6 py-2 mb-4">
5
{{ if eq .Action "new" }}
6
-
<p class="text-xl font-bold dark:text-white">Create a new string</p>
7
-
<p class="">Store and share code snippets with ease.</p>
8
{{ else }}
9
<p class="text-xl font-bold dark:text-white">Edit string</p>
10
{{ end }}
···
3
{{ define "content" }}
4
<div class="px-6 py-2 mb-4">
5
{{ if eq .Action "new" }}
6
+
<p class="text-xl font-bold dark:text-white mb-1">Create a new string</p>
7
+
<p class="text-gray-600 dark:text-gray-400 mb-1">Store and share code snippets with ease.</p>
8
{{ else }}
9
<p class="text-xl font-bold dark:text-white">Edit string</p>
10
{{ end }}
+4
-3
appview/pages/templates/strings/string.html
+4
-3
appview/pages/templates/strings/string.html
···
4
{{ $ownerId := didOrHandle .Owner.DID.String .Owner.Handle.String }}
5
<meta property="og:title" content="{{ .String.Filename }} · by {{ $ownerId }}" />
6
<meta property="og:type" content="object" />
7
-
<meta property="og:url" content="https://tangled.sh/strings/{{ $ownerId }}/{{ .String.Rkey }}" />
8
<meta property="og:description" content="{{ .String.Description }}" />
9
{{ end }}
10
···
23
hx-boost="true"
24
href="/strings/{{ .String.Did }}/{{ .String.Rkey }}/edit">
25
{{ i "pencil" "size-4" }}
26
-
<span class="hidden md:inline">edit</span>
27
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
28
</a>
29
<button
···
34
hx-confirm="Are you sure you want to delete the string `{{ .String.Filename }}`?"
35
>
36
{{ i "trash-2" "size-4" }}
37
-
<span class="hidden md:inline">delete</span>
38
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
39
</button>
40
</div>
···
80
<div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ .String.Contents | escapeHtml }}</div>
81
{{ end }}
82
</div>
83
</section>
84
{{ end }}
···
4
{{ $ownerId := didOrHandle .Owner.DID.String .Owner.Handle.String }}
5
<meta property="og:title" content="{{ .String.Filename }} · by {{ $ownerId }}" />
6
<meta property="og:type" content="object" />
7
+
<meta property="og:url" content="https://tangled.org/strings/{{ $ownerId }}/{{ .String.Rkey }}" />
8
<meta property="og:description" content="{{ .String.Description }}" />
9
{{ end }}
10
···
23
hx-boost="true"
24
href="/strings/{{ .String.Did }}/{{ .String.Rkey }}/edit">
25
{{ i "pencil" "size-4" }}
26
+
<span class="hidden md:inline">edit</span>
27
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
28
</a>
29
<button
···
34
hx-confirm="Are you sure you want to delete the string `{{ .String.Filename }}`?"
35
>
36
{{ i "trash-2" "size-4" }}
37
+
<span class="hidden md:inline">delete</span>
38
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
39
</button>
40
</div>
···
80
<div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ .String.Contents | escapeHtml }}</div>
81
{{ end }}
82
</div>
83
+
{{ template "fragments/multiline-select" }}
84
</section>
85
{{ end }}
+5
-7
appview/pages/templates/strings/timeline.html
+5
-7
appview/pages/templates/strings/timeline.html
···
26
{{ end }}
27
28
{{ define "stringCard" }}
29
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800">
30
-
<div class="font-medium dark:text-white flex gap-2 items-center">
31
-
<a href="/strings/{{ resolve .Did.String }}/{{ .Rkey }}">{{ .Filename }}</a>
32
</div>
33
{{ with .Description }}
34
<div class="text-gray-600 dark:text-gray-300 text-sm">
···
42
43
{{ define "stringCardInfo" }}
44
{{ $stat := .Stats }}
45
-
{{ $resolved := resolve .Did.String }}
46
<div class="text-gray-400 pt-4 text-sm font-mono inline-flex items-center gap-2 mt-auto">
47
-
<a href="/strings/{{ $resolved }}" class="flex items-center">
48
-
{{ template "user/fragments/picHandle" $resolved }}
49
-
</a>
50
-
<span class="select-none [&:before]:content-['·']"></span>
51
<span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span>
52
<span class="select-none [&:before]:content-['·']"></span>
53
{{ with .Edited }}
···
26
{{ end }}
27
28
{{ define "stringCard" }}
29
+
{{ $resolved := resolve .Did.String }}
30
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800">
31
+
<div class="font-medium dark:text-white flex flex-wrap gap-1 items-center">
32
+
<a href="/strings/{{ $resolved }}" class="flex gap-1 items-center">{{ template "user/fragments/picHandle" $resolved }}</a>
33
+
<span class="select-none">/</span>
34
+
<a href="/strings/{{ $resolved }}/{{ .Rkey }}">{{ .Filename }}</a>
35
</div>
36
{{ with .Description }}
37
<div class="text-gray-600 dark:text-gray-300 text-sm">
···
45
46
{{ define "stringCardInfo" }}
47
{{ $stat := .Stats }}
48
<div class="text-gray-400 pt-4 text-sm font-mono inline-flex items-center gap-2 mt-auto">
49
<span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span>
50
<span class="select-none [&:before]:content-['·']"></span>
51
{{ with .Edited }}
+30
appview/pages/templates/timeline/fragments/goodfirstissues.html
+30
appview/pages/templates/timeline/fragments/goodfirstissues.html
···
···
1
+
{{ define "timeline/fragments/goodfirstissues" }}
2
+
{{ if .GfiLabel }}
3
+
<a href="/goodfirstissues" class="no-underline hover:no-underline">
4
+
<div class="flex items-center justify-between gap-2 bg-purple-200 dark:bg-purple-900 border border-purple-400 dark:border-purple-500 rounded mb-4 py-4 px-6 ">
5
+
<div class="flex-1 flex flex-col gap-2">
6
+
<div class="text-purple-500 dark:text-purple-400">Oct 2025</div>
7
+
<p>
8
+
Make your first contribution to an open-source project this October.
9
+
<em>good-first-issue</em> helps new contributors find easy ways to
10
+
start contributing to open-source projects.
11
+
</p>
12
+
<span class="flex items-center gap-2 text-purple-500 dark:text-purple-400">
13
+
Browse issues {{ i "arrow-right" "size-4" }}
14
+
</span>
15
+
</div>
16
+
<div class="hidden md:block relative px-16 scale-150">
17
+
<div class="relative opacity-60">
18
+
{{ template "labels/fragments/label" (dict "def" .GfiLabel "val" "" "withPrefix" true) }}
19
+
</div>
20
+
<div class="relative -mt-4 ml-2 opacity-80">
21
+
{{ template "labels/fragments/label" (dict "def" .GfiLabel "val" "" "withPrefix" true) }}
22
+
</div>
23
+
<div class="relative -mt-4 ml-4">
24
+
{{ template "labels/fragments/label" (dict "def" .GfiLabel "val" "" "withPrefix" true) }}
25
+
</div>
26
+
</div>
27
+
</div>
28
+
</a>
29
+
{{ end }}
30
+
{{ end }}
+2
-3
appview/pages/templates/timeline/fragments/hero.html
+2
-3
appview/pages/templates/timeline/fragments/hero.html
···
22
</div>
23
24
<figure class="w-full hidden md:block md:w-auto">
25
-
<a href="https://tangled.sh/@tangled.sh/core" class="block">
26
-
<img src="https://assets.tangled.network/hero-repo.png" alt="Screenshot of the Tangled monorepo." class="max-w-md mx-auto md:max-w-none w-full md:w-[30vw] h-auto shadow-sm rounded hover:shadow-md transition-shadow" />
27
</a>
28
<figcaption class="text-sm text-gray-600 dark:text-gray-400 mt-2 text-center">
29
Monorepo for Tangled, built in the open with the community.
···
31
</figure>
32
</div>
33
{{ end }}
34
-
···
22
</div>
23
24
<figure class="w-full hidden md:block md:w-auto">
25
+
<a href="https://tangled.org/@tangled.org/core" class="block">
26
+
<img src="https://assets.tangled.network/hero-repo.png" alt="Screenshot of the Tangled monorepo." class="max-w-md mx-auto md:max-w-none w-full md:w-[30vw] h-auto shadow-sm rounded" />
27
</a>
28
<figcaption class="text-sm text-gray-600 dark:text-gray-400 mt-2 text-center">
29
Monorepo for Tangled, built in the open with the community.
···
31
</figure>
32
</div>
33
{{ end }}
+23
-35
appview/pages/templates/timeline/fragments/timeline.html
+23
-35
appview/pages/templates/timeline/fragments/timeline.html
···
13
{{ with $e }}
14
<div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm">
15
{{ if .Repo }}
16
-
{{ template "timeline/fragments/repoEvent" (list $ .Repo .Source) }}
17
{{ else if .Star }}
18
-
{{ template "timeline/fragments/starEvent" (list $ .Star) }}
19
{{ else if .Follow }}
20
-
{{ template "timeline/fragments/followEvent" (list $ .Follow .Profile .FollowStats) }}
21
{{ end }}
22
</div>
23
{{ end }}
···
29
30
{{ define "timeline/fragments/repoEvent" }}
31
{{ $root := index . 0 }}
32
-
{{ $repo := index . 1 }}
33
-
{{ $source := index . 2 }}
34
{{ $userHandle := resolve $repo.Did }}
35
<div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm">
36
{{ template "user/fragments/picHandleLink" $repo.Did }}
···
51
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $repo.Created }}</span>
52
</div>
53
{{ with $repo }}
54
-
{{ template "user/fragments/repoCard" (list $root . true) }}
55
{{ end }}
56
{{ end }}
57
58
{{ define "timeline/fragments/starEvent" }}
59
{{ $root := index . 0 }}
60
-
{{ $star := index . 1 }}
61
{{ with $star }}
62
{{ $starrerHandle := resolve .StarredByDid }}
63
{{ $repoOwnerHandle := resolve .Repo.Did }}
···
70
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" .Created }}</span>
71
</div>
72
{{ with .Repo }}
73
-
{{ template "user/fragments/repoCard" (list $root . true) }}
74
{{ end }}
75
{{ end }}
76
{{ end }}
77
78
{{ define "timeline/fragments/followEvent" }}
79
{{ $root := index . 0 }}
80
-
{{ $follow := index . 1 }}
81
-
{{ $profile := index . 2 }}
82
-
{{ $stat := index . 3 }}
83
84
{{ $userHandle := resolve $follow.UserDid }}
85
{{ $subjectHandle := resolve $follow.SubjectDid }}
···
89
{{ template "user/fragments/picHandleLink" $subjectHandle }}
90
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $follow.FollowedAt }}</span>
91
</div>
92
-
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4">
93
-
<div class="flex-shrink-0 max-h-full w-24 h-24">
94
-
<img alt="" class="object-cover rounded-full p-2" src="{{ fullAvatar $subjectHandle }}" />
95
-
</div>
96
-
97
-
<div class="flex-1 min-h-0 justify-around flex flex-col">
98
-
<a href="/{{ $subjectHandle }}">
99
-
<span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $subjectHandle | truncateAt30 }}</span>
100
-
</a>
101
-
{{ with $profile }}
102
-
{{ with .Description }}
103
-
<p class="text-sm pb-2 md:pb-2">{{.}}</p>
104
-
{{ end }}
105
-
{{ end }}
106
-
{{ with $stat }}
107
-
<div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
108
-
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
109
-
<span id="followers"><a href="/{{ $subjectHandle }}?tab=followers">{{ .Followers }} followers</a></span>
110
-
<span class="select-none after:content-['·']"></span>
111
-
<span id="following"><a href="/{{ $subjectHandle }}?tab=following">{{ .Following }} following</a></span>
112
-
</div>
113
-
{{ end }}
114
-
</div>
115
-
</div>
116
{{ end }}
···
13
{{ with $e }}
14
<div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm">
15
{{ if .Repo }}
16
+
{{ template "timeline/fragments/repoEvent" (list $ .) }}
17
{{ else if .Star }}
18
+
{{ template "timeline/fragments/starEvent" (list $ .) }}
19
{{ else if .Follow }}
20
+
{{ template "timeline/fragments/followEvent" (list $ .) }}
21
{{ end }}
22
</div>
23
{{ end }}
···
29
30
{{ define "timeline/fragments/repoEvent" }}
31
{{ $root := index . 0 }}
32
+
{{ $event := index . 1 }}
33
+
{{ $repo := $event.Repo }}
34
+
{{ $source := $event.Source }}
35
{{ $userHandle := resolve $repo.Did }}
36
<div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm">
37
{{ template "user/fragments/picHandleLink" $repo.Did }}
···
52
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $repo.Created }}</span>
53
</div>
54
{{ with $repo }}
55
+
{{ template "user/fragments/repoCard" (list $root . true true (dict "IsStarred" $event.IsStarred "RepoAt" .RepoAt "Stats" (dict "StarCount" $event.StarCount))) }}
56
{{ end }}
57
{{ end }}
58
59
{{ define "timeline/fragments/starEvent" }}
60
{{ $root := index . 0 }}
61
+
{{ $event := index . 1 }}
62
+
{{ $star := $event.Star }}
63
{{ with $star }}
64
{{ $starrerHandle := resolve .StarredByDid }}
65
{{ $repoOwnerHandle := resolve .Repo.Did }}
···
72
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" .Created }}</span>
73
</div>
74
{{ with .Repo }}
75
+
{{ template "user/fragments/repoCard" (list $root . true true (dict "IsStarred" $event.IsStarred "RepoAt" .RepoAt "Stats" (dict "StarCount" $event.StarCount))) }}
76
{{ end }}
77
{{ end }}
78
{{ end }}
79
80
{{ define "timeline/fragments/followEvent" }}
81
{{ $root := index . 0 }}
82
+
{{ $event := index . 1 }}
83
+
{{ $follow := $event.Follow }}
84
+
{{ $profile := $event.Profile }}
85
+
{{ $followStats := $event.FollowStats }}
86
+
{{ $followStatus := $event.FollowStatus }}
87
88
{{ $userHandle := resolve $follow.UserDid }}
89
{{ $subjectHandle := resolve $follow.SubjectDid }}
···
93
{{ template "user/fragments/picHandleLink" $subjectHandle }}
94
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $follow.FollowedAt }}</span>
95
</div>
96
+
{{ template "user/fragments/followCard"
97
+
(dict
98
+
"LoggedInUser" $root.LoggedInUser
99
+
"UserDid" $follow.SubjectDid
100
+
"Profile" $profile
101
+
"FollowStatus" $followStatus
102
+
"FollowersCount" $followStats.Followers
103
+
"FollowingCount" $followStats.Following) }}
104
{{ end }}
+5
-5
appview/pages/templates/timeline/home.html
+5
-5
appview/pages/templates/timeline/home.html
···
3
{{ define "extrameta" }}
4
<meta property="og:title" content="timeline · tangled" />
5
<meta property="og:type" content="object" />
6
-
<meta property="og:url" content="https://tangled.sh" />
7
<meta property="og:description" content="tightly-knit social coding" />
8
{{ end }}
9
···
12
<div class="flex flex-col gap-4">
13
{{ template "timeline/fragments/hero" . }}
14
{{ template "features" . }}
15
{{ template "timeline/fragments/trending" . }}
16
{{ template "timeline/fragments/timeline" . }}
17
<div class="flex justify-end">
···
27
{{ define "feature" }}
28
{{ $info := index . 0 }}
29
{{ $bullets := index . 1 }}
30
-
<div class="flex flex-col items-top gap-6 md:flex-row md:gap-12">
31
<div class="flex-1">
32
<h2 class="text-2xl font-bold text-black dark:text-white mb-6">{{ $info.title }}</h2>
33
<ul class="leading-normal">
···
38
</div>
39
<div class="flex-shrink-0 w-96 md:w-1/3">
40
<a href="{{ $info.image }}">
41
-
<img src="{{ $info.image }}" alt="{{ $info.alt }}" class="w-full h-auto rounded" />
42
</a>
43
</div>
44
</div>
45
{{ end }}
46
47
{{ define "features" }}
48
-
<div class="prose dark:text-gray-200 space-y-12 px-6 py-4">
49
{{ template "feature" (list
50
(dict
51
"title" "lightweight git repo hosting"
···
87
) }}
88
</div>
89
{{ end }}
90
-
···
3
{{ define "extrameta" }}
4
<meta property="og:title" content="timeline · tangled" />
5
<meta property="og:type" content="object" />
6
+
<meta property="og:url" content="https://tangled.org" />
7
<meta property="og:description" content="tightly-knit social coding" />
8
{{ end }}
9
···
12
<div class="flex flex-col gap-4">
13
{{ template "timeline/fragments/hero" . }}
14
{{ template "features" . }}
15
+
{{ template "timeline/fragments/goodfirstissues" . }}
16
{{ template "timeline/fragments/trending" . }}
17
{{ template "timeline/fragments/timeline" . }}
18
<div class="flex justify-end">
···
28
{{ define "feature" }}
29
{{ $info := index . 0 }}
30
{{ $bullets := index . 1 }}
31
+
<div class="flex flex-col items-center gap-6 md:flex-row md:items-top">
32
<div class="flex-1">
33
<h2 class="text-2xl font-bold text-black dark:text-white mb-6">{{ $info.title }}</h2>
34
<ul class="leading-normal">
···
39
</div>
40
<div class="flex-shrink-0 w-96 md:w-1/3">
41
<a href="{{ $info.image }}">
42
+
<img src="{{ $info.image }}" alt="{{ $info.alt }}" class="w-full h-auto rounded shadow-sm" />
43
</a>
44
</div>
45
</div>
46
{{ end }}
47
48
{{ define "features" }}
49
+
<div class="prose dark:text-gray-200 space-y-12 px-6 py-4 bg-white dark:bg-gray-800 rounded drop-shadow-sm">
50
{{ template "feature" (list
51
(dict
52
"title" "lightweight git repo hosting"
···
88
) }}
89
</div>
90
{{ end }}
+2
-1
appview/pages/templates/timeline/timeline.html
+2
-1
appview/pages/templates/timeline/timeline.html
···
3
{{ define "extrameta" }}
4
<meta property="og:title" content="timeline · tangled" />
5
<meta property="og:type" content="object" />
6
-
<meta property="og:url" content="https://tangled.sh" />
7
<meta property="og:description" content="tightly-knit social coding" />
8
{{ end }}
9
···
13
{{ template "timeline/fragments/hero" . }}
14
{{ end }}
15
16
{{ template "timeline/fragments/trending" . }}
17
{{ template "timeline/fragments/timeline" . }}
18
{{ end }}
···
3
{{ define "extrameta" }}
4
<meta property="og:title" content="timeline · tangled" />
5
<meta property="og:type" content="object" />
6
+
<meta property="og:url" content="https://tangled.org" />
7
<meta property="og:description" content="tightly-knit social coding" />
8
{{ end }}
9
···
13
{{ template "timeline/fragments/hero" . }}
14
{{ end }}
15
16
+
{{ template "timeline/fragments/goodfirstissues" . }}
17
{{ template "timeline/fragments/trending" . }}
18
{{ template "timeline/fragments/timeline" . }}
19
{{ end }}
+4
-5
appview/pages/templates/user/completeSignup.html
+4
-5
appview/pages/templates/user/completeSignup.html
···
13
/>
14
<meta
15
property="og:url"
16
-
content="https://tangled.sh/complete-signup"
17
/>
18
<meta
19
property="og:description"
20
content="complete your signup for tangled"
21
/>
22
<script src="/static/htmx.min.js"></script>
23
<link
24
rel="stylesheet"
25
href="/static/tw.css?{{ cssContentHash }}"
···
29
</head>
30
<body class="flex items-center justify-center min-h-screen">
31
<main class="max-w-md px-6 -mt-4">
32
-
<h1
33
-
class="text-center text-2xl font-semibold italic dark:text-white"
34
-
>
35
-
tangled
36
</h1>
37
<h2 class="text-center text-xl italic dark:text-white">
38
tightly-knit social coding.
···
13
/>
14
<meta
15
property="og:url"
16
+
content="https://tangled.org/complete-signup"
17
/>
18
<meta
19
property="og:description"
20
content="complete your signup for tangled"
21
/>
22
<script src="/static/htmx.min.js"></script>
23
+
<link rel="manifest" href="/pwa-manifest.json" />
24
<link
25
rel="stylesheet"
26
href="/static/tw.css?{{ cssContentHash }}"
···
30
</head>
31
<body class="flex items-center justify-center min-h-screen">
32
<main class="max-w-md px-6 -mt-4">
33
+
<h1 class="flex place-content-center text-2xl font-semibold italic dark:text-white" >
34
+
{{ template "fragments/logotype" }}
35
</h1>
36
<h2 class="text-center text-xl italic dark:text-white">
37
tightly-knit social coding.
+8
-1
appview/pages/templates/user/followers.html
+8
-1
appview/pages/templates/user/followers.html
···
10
<p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWERS</p>
11
<div id="followers" class="grid grid-cols-1 gap-4 mb-6">
12
{{ range .Followers }}
13
-
{{ template "user/fragments/followCard" . }}
14
{{ else }}
15
<p class="px-6 dark:text-white">This user does not have any followers yet.</p>
16
{{ end }}
···
10
<p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWERS</p>
11
<div id="followers" class="grid grid-cols-1 gap-4 mb-6">
12
{{ range .Followers }}
13
+
{{ template "user/fragments/followCard"
14
+
(dict
15
+
"LoggedInUser" $.LoggedInUser
16
+
"UserDid" .UserDid
17
+
"Profile" .Profile
18
+
"FollowStatus" .FollowStatus
19
+
"FollowersCount" .FollowersCount
20
+
"FollowingCount" .FollowingCount) }}
21
{{ else }}
22
<p class="px-6 dark:text-white">This user does not have any followers yet.</p>
23
{{ end }}
+8
-1
appview/pages/templates/user/following.html
+8
-1
appview/pages/templates/user/following.html
···
10
<p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWING</p>
11
<div id="following" class="grid grid-cols-1 gap-4 mb-6">
12
{{ range .Following }}
13
-
{{ template "user/fragments/followCard" . }}
14
{{ else }}
15
<p class="px-6 dark:text-white">This user does not follow anyone yet.</p>
16
{{ end }}
···
10
<p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWING</p>
11
<div id="following" class="grid grid-cols-1 gap-4 mb-6">
12
{{ range .Following }}
13
+
{{ template "user/fragments/followCard"
14
+
(dict
15
+
"LoggedInUser" $.LoggedInUser
16
+
"UserDid" .UserDid
17
+
"Profile" .Profile
18
+
"FollowStatus" .FollowStatus
19
+
"FollowersCount" .FollowersCount
20
+
"FollowingCount" .FollowingCount) }}
21
{{ else }}
22
<p class="px-6 dark:text-white">This user does not follow anyone yet.</p>
23
{{ end }}
+6
-2
appview/pages/templates/user/fragments/follow.html
+6
-2
appview/pages/templates/user/fragments/follow.html
···
1
{{ define "user/fragments/follow" }}
2
<button id="{{ normalizeForHtmlId .UserDid }}"
3
-
class="btn mt-2 w-full flex gap-2 items-center group"
4
5
{{ if eq .FollowStatus.String "IsNotFollowing" }}
6
hx-post="/follow?subject={{.UserDid}}"
···
12
hx-target="#{{ normalizeForHtmlId .UserDid }}"
13
hx-swap="outerHTML"
14
>
15
-
{{ if eq .FollowStatus.String "IsNotFollowing" }}Follow{{ else }}Unfollow{{ end }}
16
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
17
</button>
18
{{ end }}
···
1
{{ define "user/fragments/follow" }}
2
<button id="{{ normalizeForHtmlId .UserDid }}"
3
+
class="btn w-full flex gap-2 items-center group"
4
5
{{ if eq .FollowStatus.String "IsNotFollowing" }}
6
hx-post="/follow?subject={{.UserDid}}"
···
12
hx-target="#{{ normalizeForHtmlId .UserDid }}"
13
hx-swap="outerHTML"
14
>
15
+
{{ if eq .FollowStatus.String "IsNotFollowing" }}
16
+
{{ i "user-round-plus" "w-4 h-4" }} follow
17
+
{{ else }}
18
+
{{ i "user-round-minus" "w-4 h-4" }} unfollow
19
+
{{ end }}
20
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
21
</button>
22
{{ end }}
+20
-17
appview/pages/templates/user/fragments/followCard.html
+20
-17
appview/pages/templates/user/fragments/followCard.html
···
1
{{ define "user/fragments/followCard" }}
2
{{ $userIdent := resolve .UserDid }}
3
-
<div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm">
4
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4">
5
<div class="flex-shrink-0 max-h-full w-24 h-24">
6
<img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" />
7
</div>
8
9
-
<div class="flex-1 min-h-0 justify-around flex flex-col">
10
-
<a href="/{{ $userIdent }}">
11
-
<span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $userIdent | truncateAt30 }}</span>
12
-
</a>
13
-
<p class="text-sm pb-2 md:pb-2">{{.Profile.Description}}</p>
14
-
<div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
15
-
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
16
-
<span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .FollowersCount }} followers</a></span>
17
-
<span class="select-none after:content-['·']"></span>
18
-
<span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .FollowingCount }} following</a></span>
19
</div>
20
-
</div>
21
-
22
-
{{ if ne .FollowStatus.String "IsSelf" }}
23
-
<div class="max-w-24">
24
{{ template "user/fragments/follow" . }}
25
</div>
26
-
{{ end }}
27
</div>
28
</div>
29
-
{{ end }}
···
1
{{ define "user/fragments/followCard" }}
2
{{ $userIdent := resolve .UserDid }}
3
+
<div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 rounded-sm">
4
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4">
5
<div class="flex-shrink-0 max-h-full w-24 h-24">
6
<img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" />
7
</div>
8
9
+
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2 w-full">
10
+
<div class="flex-1 min-h-0 justify-around flex flex-col">
11
+
<a href="/{{ $userIdent }}">
12
+
<span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $userIdent | truncateAt30 }}</span>
13
+
</a>
14
+
{{ with .Profile }}
15
+
<p class="text-sm pb-2 md:pb-2">{{.Description}}</p>
16
+
{{ end }}
17
+
<div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
18
+
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
19
+
<span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .FollowersCount }} followers</a></span>
20
+
<span class="select-none after:content-['·']"></span>
21
+
<span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .FollowingCount }} following</a></span>
22
+
</div>
23
</div>
24
+
{{ if and .LoggedInUser (ne .FollowStatus.String "IsSelf") }}
25
+
<div class="w-full md:w-auto md:max-w-24 order-last md:order-none">
26
{{ template "user/fragments/follow" . }}
27
</div>
28
+
{{ end }}
29
+
</div>
30
</div>
31
</div>
32
+
{{ end }}
+2
-2
appview/pages/templates/user/fragments/picHandle.html
+2
-2
appview/pages/templates/user/fragments/picHandle.html
+2
-3
appview/pages/templates/user/fragments/picHandleLink.html
+2
-3
appview/pages/templates/user/fragments/picHandleLink.html
+27
-13
appview/pages/templates/user/fragments/repoCard.html
+27
-13
appview/pages/templates/user/fragments/repoCard.html
···
2
{{ $root := index . 0 }}
3
{{ $repo := index . 1 }}
4
{{ $fullName := index . 2 }}
5
6
{{ with $repo }}
7
<div class="py-4 px-6 gap-1 flex flex-col drop-shadow-sm rounded bg-white dark:bg-gray-800 min-h-32">
8
-
<div class="font-medium dark:text-white flex items-center">
9
-
{{ if .Source }}
10
-
{{ i "git-fork" "w-4 h-4 mr-1.5 shrink-0" }}
11
-
{{ else }}
12
-
{{ i "book-marked" "w-4 h-4 mr-1.5 shrink-0" }}
13
{{ end }}
14
-
15
-
{{ $repoOwner := resolve .Did }}
16
-
{{- if $fullName -}}
17
-
<a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate">{{ $repoOwner }}/{{ .Name }}</a>
18
-
{{- else -}}
19
-
<a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate">{{ .Name }}</a>
20
-
{{- end -}}
21
</div>
22
{{ with .Description }}
23
<div class="text-gray-600 dark:text-gray-300 text-sm line-clamp-2">
···
36
<div class="text-gray-400 text-sm font-mono inline-flex gap-4 mt-auto">
37
{{ with .Language }}
38
<div class="flex gap-2 items-center text-sm">
39
-
{{ template "repo/fragments/languageBall" . }}
40
<span>{{ . }}</span>
41
</div>
42
{{ end }}
···
2
{{ $root := index . 0 }}
3
{{ $repo := index . 1 }}
4
{{ $fullName := index . 2 }}
5
+
{{ $starButton := false }}
6
+
{{ $starData := dict }}
7
+
{{ if gt (len .) 3 }}
8
+
{{ $starButton = index . 3 }}
9
+
{{ if gt (len .) 4 }}
10
+
{{ $starData = index . 4 }}
11
+
{{ end }}
12
+
{{ end }}
13
14
{{ with $repo }}
15
<div class="py-4 px-6 gap-1 flex flex-col drop-shadow-sm rounded bg-white dark:bg-gray-800 min-h-32">
16
+
<div class="font-medium dark:text-white flex items-center justify-between">
17
+
<div class="flex items-center min-w-0 flex-1 mr-2">
18
+
{{ if .Source }}
19
+
{{ i "git-fork" "w-4 h-4 mr-1.5 shrink-0" }}
20
+
{{ else }}
21
+
{{ i "book-marked" "w-4 h-4 mr-1.5 shrink-0" }}
22
+
{{ end }}
23
+
{{ $repoOwner := resolve .Did }}
24
+
{{- if $fullName -}}
25
+
<a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate min-w-0">{{ $repoOwner }}/{{ .Name }}</a>
26
+
{{- else -}}
27
+
<a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate min-w-0">{{ .Name }}</a>
28
+
{{- end -}}
29
+
</div>
30
+
{{ if and $starButton $root.LoggedInUser }}
31
+
<div class="shrink-0">
32
+
{{ template "repo/fragments/repoStar" $starData }}
33
+
</div>
34
{{ end }}
35
</div>
36
{{ with .Description }}
37
<div class="text-gray-600 dark:text-gray-300 text-sm line-clamp-2">
···
50
<div class="text-gray-400 text-sm font-mono inline-flex gap-4 mt-auto">
51
{{ with .Language }}
52
<div class="flex gap-2 items-center text-sm">
53
+
{{ template "repo/fragments/colorBall" (dict "color" (langColor .)) }}
54
<span>{{ . }}</span>
55
</div>
56
{{ end }}
+5
-4
appview/pages/templates/user/login.html
+5
-4
appview/pages/templates/user/login.html
···
5
<meta charset="UTF-8" />
6
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
<meta property="og:title" content="login · tangled" />
8
-
<meta property="og:url" content="https://tangled.sh/login" />
9
<meta property="og:description" content="login to for tangled" />
10
<script src="/static/htmx.min.js"></script>
11
<link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" />
12
<title>login · tangled</title>
13
</head>
14
<body class="flex items-center justify-center min-h-screen">
15
<main class="max-w-md px-6 -mt-4">
16
-
<h1 class="text-center text-2xl font-semibold italic dark:text-white" >
17
-
tangled
18
</h1>
19
<h2 class="text-center text-xl italic dark:text-white">
20
tightly-knit social coding.
···
36
placeholder="akshay.tngl.sh"
37
/>
38
<span class="text-sm text-gray-500 mt-1">
39
-
Use your <a href="https://atproto.com">ATProto</a>
40
handle to log in. If you're unsure, this is likely
41
your Tangled (<code>.tngl.sh</code>) or <a href="https://bsky.app">Bluesky</a> (<code>.bsky.social</code>) account.
42
</span>
···
5
<meta charset="UTF-8" />
6
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
<meta property="og:title" content="login · tangled" />
8
+
<meta property="og:url" content="https://tangled.org/login" />
9
<meta property="og:description" content="login to for tangled" />
10
<script src="/static/htmx.min.js"></script>
11
+
<link rel="manifest" href="/pwa-manifest.json" />
12
<link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" />
13
<title>login · tangled</title>
14
</head>
15
<body class="flex items-center justify-center min-h-screen">
16
<main class="max-w-md px-6 -mt-4">
17
+
<h1 class="flex place-content-center text-3xl font-semibold italic dark:text-white" >
18
+
{{ template "fragments/logotype" }}
19
</h1>
20
<h2 class="text-center text-xl italic dark:text-white">
21
tightly-knit social coding.
···
37
placeholder="akshay.tngl.sh"
38
/>
39
<span class="text-sm text-gray-500 mt-1">
40
+
Use your <a href="https://atproto.com">AT Protocol</a>
41
handle to log in. If you're unsure, this is likely
42
your Tangled (<code>.tngl.sh</code>) or <a href="https://bsky.app">Bluesky</a> (<code>.bsky.social</code>) account.
43
</span>
+1
-1
appview/pages/templates/user/overview.html
+1
-1
appview/pages/templates/user/overview.html
+173
appview/pages/templates/user/settings/notifications.html
+173
appview/pages/templates/user/settings/notifications.html
···
···
1
+
{{ define "title" }}{{ .Tab }} settings{{ end }}
2
+
3
+
{{ define "content" }}
4
+
<div class="p-6">
5
+
<p class="text-xl font-bold dark:text-white">Settings</p>
6
+
</div>
7
+
<div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
8
+
<section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6">
9
+
<div class="col-span-1">
10
+
{{ template "user/settings/fragments/sidebar" . }}
11
+
</div>
12
+
<div class="col-span-1 md:col-span-3 flex flex-col gap-6">
13
+
{{ template "notificationSettings" . }}
14
+
</div>
15
+
</section>
16
+
</div>
17
+
{{ end }}
18
+
19
+
{{ define "notificationSettings" }}
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">Notification Preferences</h2>
23
+
<p class="text-gray-500 dark:text-gray-400">
24
+
Choose which notifications you want to receive when activity happens on your repositories and profile.
25
+
</p>
26
+
</div>
27
+
</div>
28
+
29
+
<form hx-put="/settings/notifications" hx-swap="none" class="flex flex-col gap-6">
30
+
31
+
<div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full">
32
+
<div class="flex items-center justify-between p-2">
33
+
<div class="flex items-center gap-2">
34
+
<div class="flex flex-col gap-1">
35
+
<span class="font-bold">Repository starred</span>
36
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
37
+
<span>When someone stars your repository.</span>
38
+
</div>
39
+
</div>
40
+
</div>
41
+
<label class="flex items-center gap-2">
42
+
<input type="checkbox" name="repo_starred" {{if .Preferences.RepoStarred}}checked{{end}}>
43
+
</label>
44
+
</div>
45
+
46
+
<div class="flex items-center justify-between p-2">
47
+
<div class="flex items-center gap-2">
48
+
<div class="flex flex-col gap-1">
49
+
<span class="font-bold">New issues</span>
50
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
51
+
<span>When someone creates an issue on your repository.</span>
52
+
</div>
53
+
</div>
54
+
</div>
55
+
<label class="flex items-center gap-2">
56
+
<input type="checkbox" name="issue_created" {{if .Preferences.IssueCreated}}checked{{end}}>
57
+
</label>
58
+
</div>
59
+
60
+
<div class="flex items-center justify-between p-2">
61
+
<div class="flex items-center gap-2">
62
+
<div class="flex flex-col gap-1">
63
+
<span class="font-bold">Issue comments</span>
64
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
65
+
<span>When someone comments on an issue you're involved with.</span>
66
+
</div>
67
+
</div>
68
+
</div>
69
+
<label class="flex items-center gap-2">
70
+
<input type="checkbox" name="issue_commented" {{if .Preferences.IssueCommented}}checked{{end}}>
71
+
</label>
72
+
</div>
73
+
74
+
<div class="flex items-center justify-between p-2">
75
+
<div class="flex items-center gap-2">
76
+
<div class="flex flex-col gap-1">
77
+
<span class="font-bold">Issue closed</span>
78
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
79
+
<span>When an issue on your repository is closed.</span>
80
+
</div>
81
+
</div>
82
+
</div>
83
+
<label class="flex items-center gap-2">
84
+
<input type="checkbox" name="issue_closed" {{if .Preferences.IssueClosed}}checked{{end}}>
85
+
</label>
86
+
</div>
87
+
88
+
<div class="flex items-center justify-between p-2">
89
+
<div class="flex items-center gap-2">
90
+
<div class="flex flex-col gap-1">
91
+
<span class="font-bold">New pull requests</span>
92
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
93
+
<span>When someone creates a pull request on your repository.</span>
94
+
</div>
95
+
</div>
96
+
</div>
97
+
<label class="flex items-center gap-2">
98
+
<input type="checkbox" name="pull_created" {{if .Preferences.PullCreated}}checked{{end}}>
99
+
</label>
100
+
</div>
101
+
102
+
<div class="flex items-center justify-between p-2">
103
+
<div class="flex items-center gap-2">
104
+
<div class="flex flex-col gap-1">
105
+
<span class="font-bold">Pull request comments</span>
106
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
107
+
<span>When someone comments on a pull request you're involved with.</span>
108
+
</div>
109
+
</div>
110
+
</div>
111
+
<label class="flex items-center gap-2">
112
+
<input type="checkbox" name="pull_commented" {{if .Preferences.PullCommented}}checked{{end}}>
113
+
</label>
114
+
</div>
115
+
116
+
<div class="flex items-center justify-between p-2">
117
+
<div class="flex items-center gap-2">
118
+
<div class="flex flex-col gap-1">
119
+
<span class="font-bold">Pull request merged</span>
120
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
121
+
<span>When your pull request is merged.</span>
122
+
</div>
123
+
</div>
124
+
</div>
125
+
<label class="flex items-center gap-2">
126
+
<input type="checkbox" name="pull_merged" {{if .Preferences.PullMerged}}checked{{end}}>
127
+
</label>
128
+
</div>
129
+
130
+
<div class="flex items-center justify-between p-2">
131
+
<div class="flex items-center gap-2">
132
+
<div class="flex flex-col gap-1">
133
+
<span class="font-bold">New followers</span>
134
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
135
+
<span>When someone follows you.</span>
136
+
</div>
137
+
</div>
138
+
</div>
139
+
<label class="flex items-center gap-2">
140
+
<input type="checkbox" name="followed" {{if .Preferences.Followed}}checked{{end}}>
141
+
</label>
142
+
</div>
143
+
144
+
<div class="flex items-center justify-between p-2">
145
+
<div class="flex items-center gap-2">
146
+
<div class="flex flex-col gap-1">
147
+
<span class="font-bold">Email notifications</span>
148
+
<div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
149
+
<span>Receive notifications via email in addition to in-app notifications.</span>
150
+
</div>
151
+
</div>
152
+
</div>
153
+
<label class="flex items-center gap-2">
154
+
<input type="checkbox" name="email_notifications" {{if .Preferences.EmailNotifications}}checked{{end}}>
155
+
</label>
156
+
</div>
157
+
</div>
158
+
159
+
<div class="flex justify-end pt-2">
160
+
<button
161
+
type="submit"
162
+
class="btn-create flex items-center gap-2 group"
163
+
>
164
+
{{ i "save" "w-4 h-4" }}
165
+
save
166
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
167
+
</button>
168
+
</div>
169
+
<div id="settings-notifications-success"></div>
170
+
171
+
<div id="settings-notifications-error" class="error"></div>
172
+
</form>
173
+
{{ end }}
+11
-4
appview/pages/templates/user/signup.html
+11
-4
appview/pages/templates/user/signup.html
···
5
<meta charset="UTF-8" />
6
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
<meta property="og:title" content="signup · tangled" />
8
-
<meta property="og:url" content="https://tangled.sh/signup" />
9
<meta property="og:description" content="sign up for tangled" />
10
<script src="/static/htmx.min.js"></script>
11
<link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" />
12
<title>sign up · tangled</title>
13
</head>
14
<body class="flex items-center justify-center min-h-screen">
15
<main class="max-w-md px-6 -mt-4">
16
-
<h1 class="text-center text-2xl font-semibold italic dark:text-white" >tangled</h1>
17
<h2 class="text-center text-xl italic dark:text-white">tightly-knit social coding.</h2>
18
<form
19
class="mt-4 max-w-sm mx-auto"
···
37
invite code, desired username, and password in the next
38
page to complete your registration.
39
</span>
40
<button class="btn text-base w-full my-2 mt-6" type="submit" id="signup-button" tabindex="7" >
41
<span>join now</span>
42
</button>
43
</form>
44
<p class="text-sm text-gray-500">
45
-
Already have an ATProto account? <a href="/login" class="underline">Login to Tangled</a>.
46
</p>
47
48
<p id="signup-msg" class="error w-full"></p>
···
50
</body>
51
</html>
52
{{ end }}
53
-
···
5
<meta charset="UTF-8" />
6
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
<meta property="og:title" content="signup · tangled" />
8
+
<meta property="og:url" content="https://tangled.org/signup" />
9
<meta property="og:description" content="sign up for tangled" />
10
<script src="/static/htmx.min.js"></script>
11
+
<link rel="manifest" href="/pwa-manifest.json" />
12
<link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" />
13
<title>sign up · tangled</title>
14
+
15
+
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
16
</head>
17
<body class="flex items-center justify-center min-h-screen">
18
<main class="max-w-md px-6 -mt-4">
19
+
<h1 class="flex place-content-center text-2xl font-semibold italic dark:text-white" >
20
+
{{ template "fragments/logotype" }}
21
+
</h1>
22
<h2 class="text-center text-xl italic dark:text-white">tightly-knit social coding.</h2>
23
<form
24
class="mt-4 max-w-sm mx-auto"
···
42
invite code, desired username, and password in the next
43
page to complete your registration.
44
</span>
45
+
<div class="w-full mt-4 text-center">
46
+
<div class="cf-turnstile" data-sitekey="{{ .CloudflareSiteKey }}"></div>
47
+
</div>
48
<button class="btn text-base w-full my-2 mt-6" type="submit" id="signup-button" tabindex="7" >
49
<span>join now</span>
50
</button>
51
</form>
52
<p class="text-sm text-gray-500">
53
+
Already have an AT Protocol account? <a href="/login" class="underline">Login to Tangled</a>.
54
</p>
55
56
<p id="signup-msg" class="error w-full"></p>
···
58
</body>
59
</html>
60
{{ end }}
+1
-1
appview/pagination/page.go
+1
-1
appview/pagination/page.go
+10
-10
appview/pipelines/pipelines.go
+10
-10
appview/pipelines/pipelines.go
···
9
"strings"
10
"time"
11
12
-
"tangled.sh/tangled.sh/core/appview/config"
13
-
"tangled.sh/tangled.sh/core/appview/db"
14
-
"tangled.sh/tangled.sh/core/appview/oauth"
15
-
"tangled.sh/tangled.sh/core/appview/pages"
16
-
"tangled.sh/tangled.sh/core/appview/reporesolver"
17
-
"tangled.sh/tangled.sh/core/eventconsumer"
18
-
"tangled.sh/tangled.sh/core/idresolver"
19
-
"tangled.sh/tangled.sh/core/log"
20
-
"tangled.sh/tangled.sh/core/rbac"
21
-
spindlemodel "tangled.sh/tangled.sh/core/spindle/models"
22
23
"github.com/go-chi/chi/v5"
24
"github.com/gorilla/websocket"
···
9
"strings"
10
"time"
11
12
+
"tangled.org/core/appview/config"
13
+
"tangled.org/core/appview/db"
14
+
"tangled.org/core/appview/oauth"
15
+
"tangled.org/core/appview/pages"
16
+
"tangled.org/core/appview/reporesolver"
17
+
"tangled.org/core/eventconsumer"
18
+
"tangled.org/core/idresolver"
19
+
"tangled.org/core/log"
20
+
"tangled.org/core/rbac"
21
+
spindlemodel "tangled.org/core/spindle/models"
22
23
"github.com/go-chi/chi/v5"
24
"github.com/gorilla/websocket"
+1
-1
appview/pipelines/router.go
+1
-1
appview/pipelines/router.go
-131
appview/posthog/notifier.go
-131
appview/posthog/notifier.go
···
1
-
package posthog_service
2
-
3
-
import (
4
-
"context"
5
-
"log"
6
-
7
-
"github.com/posthog/posthog-go"
8
-
"tangled.sh/tangled.sh/core/appview/db"
9
-
"tangled.sh/tangled.sh/core/appview/notify"
10
-
)
11
-
12
-
type posthogNotifier struct {
13
-
client posthog.Client
14
-
notify.BaseNotifier
15
-
}
16
-
17
-
func NewPosthogNotifier(client posthog.Client) notify.Notifier {
18
-
return &posthogNotifier{
19
-
client,
20
-
notify.BaseNotifier{},
21
-
}
22
-
}
23
-
24
-
var _ notify.Notifier = &posthogNotifier{}
25
-
26
-
func (n *posthogNotifier) NewRepo(ctx context.Context, repo *db.Repo) {
27
-
err := n.client.Enqueue(posthog.Capture{
28
-
DistinctId: repo.Did,
29
-
Event: "new_repo",
30
-
Properties: posthog.Properties{"repo": repo.Name, "repo_at": repo.RepoAt()},
31
-
})
32
-
if err != nil {
33
-
log.Println("failed to enqueue posthog event:", err)
34
-
}
35
-
}
36
-
37
-
func (n *posthogNotifier) NewStar(ctx context.Context, star *db.Star) {
38
-
err := n.client.Enqueue(posthog.Capture{
39
-
DistinctId: star.StarredByDid,
40
-
Event: "star",
41
-
Properties: posthog.Properties{"repo_at": star.RepoAt.String()},
42
-
})
43
-
if err != nil {
44
-
log.Println("failed to enqueue posthog event:", err)
45
-
}
46
-
}
47
-
48
-
func (n *posthogNotifier) DeleteStar(ctx context.Context, star *db.Star) {
49
-
err := n.client.Enqueue(posthog.Capture{
50
-
DistinctId: star.StarredByDid,
51
-
Event: "unstar",
52
-
Properties: posthog.Properties{"repo_at": star.RepoAt.String()},
53
-
})
54
-
if err != nil {
55
-
log.Println("failed to enqueue posthog event:", err)
56
-
}
57
-
}
58
-
59
-
func (n *posthogNotifier) NewIssue(ctx context.Context, issue *db.Issue) {
60
-
err := n.client.Enqueue(posthog.Capture{
61
-
DistinctId: issue.Did,
62
-
Event: "new_issue",
63
-
Properties: posthog.Properties{
64
-
"repo_at": issue.RepoAt.String(),
65
-
"issue_id": issue.IssueId,
66
-
},
67
-
})
68
-
if err != nil {
69
-
log.Println("failed to enqueue posthog event:", err)
70
-
}
71
-
}
72
-
73
-
func (n *posthogNotifier) NewPull(ctx context.Context, pull *db.Pull) {
74
-
err := n.client.Enqueue(posthog.Capture{
75
-
DistinctId: pull.OwnerDid,
76
-
Event: "new_pull",
77
-
Properties: posthog.Properties{
78
-
"repo_at": pull.RepoAt,
79
-
"pull_id": pull.PullId,
80
-
},
81
-
})
82
-
if err != nil {
83
-
log.Println("failed to enqueue posthog event:", err)
84
-
}
85
-
}
86
-
87
-
func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *db.PullComment) {
88
-
err := n.client.Enqueue(posthog.Capture{
89
-
DistinctId: comment.OwnerDid,
90
-
Event: "new_pull_comment",
91
-
Properties: posthog.Properties{
92
-
"repo_at": comment.RepoAt,
93
-
"pull_id": comment.PullId,
94
-
},
95
-
})
96
-
if err != nil {
97
-
log.Println("failed to enqueue posthog event:", err)
98
-
}
99
-
}
100
-
101
-
func (n *posthogNotifier) NewFollow(ctx context.Context, follow *db.Follow) {
102
-
err := n.client.Enqueue(posthog.Capture{
103
-
DistinctId: follow.UserDid,
104
-
Event: "follow",
105
-
Properties: posthog.Properties{"subject": follow.SubjectDid},
106
-
})
107
-
if err != nil {
108
-
log.Println("failed to enqueue posthog event:", err)
109
-
}
110
-
}
111
-
112
-
func (n *posthogNotifier) DeleteFollow(ctx context.Context, follow *db.Follow) {
113
-
err := n.client.Enqueue(posthog.Capture{
114
-
DistinctId: follow.UserDid,
115
-
Event: "unfollow",
116
-
Properties: posthog.Properties{"subject": follow.SubjectDid},
117
-
})
118
-
if err != nil {
119
-
log.Println("failed to enqueue posthog event:", err)
120
-
}
121
-
}
122
-
123
-
func (n *posthogNotifier) UpdateProfile(ctx context.Context, profile *db.Profile) {
124
-
err := n.client.Enqueue(posthog.Capture{
125
-
DistinctId: profile.Did,
126
-
Event: "edit_profile",
127
-
})
128
-
if err != nil {
129
-
log.Println("failed to enqueue posthog event:", err)
130
-
}
131
-
}
···
+130
-77
appview/pulls/pulls.go
+130
-77
appview/pulls/pulls.go
···
12
"strings"
13
"time"
14
15
-
"tangled.sh/tangled.sh/core/api/tangled"
16
-
"tangled.sh/tangled.sh/core/appview/config"
17
-
"tangled.sh/tangled.sh/core/appview/db"
18
-
"tangled.sh/tangled.sh/core/appview/notify"
19
-
"tangled.sh/tangled.sh/core/appview/oauth"
20
-
"tangled.sh/tangled.sh/core/appview/pages"
21
-
"tangled.sh/tangled.sh/core/appview/pages/markup"
22
-
"tangled.sh/tangled.sh/core/appview/reporesolver"
23
-
"tangled.sh/tangled.sh/core/appview/xrpcclient"
24
-
"tangled.sh/tangled.sh/core/idresolver"
25
-
"tangled.sh/tangled.sh/core/patchutil"
26
-
"tangled.sh/tangled.sh/core/tid"
27
-
"tangled.sh/tangled.sh/core/types"
28
29
"github.com/bluekeyes/go-gitdiff/gitdiff"
30
comatproto "github.com/bluesky-social/indigo/api/atproto"
···
75
return
76
}
77
78
-
pull, ok := r.Context().Value("pull").(*db.Pull)
79
if !ok {
80
log.Println("failed to get pull")
81
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
···
83
}
84
85
// can be nil if this pull is not stacked
86
-
stack, _ := r.Context().Value("stack").(db.Stack)
87
88
roundNumberStr := chi.URLParam(r, "round")
89
roundNumber, err := strconv.Atoi(roundNumberStr)
···
123
return
124
}
125
126
-
pull, ok := r.Context().Value("pull").(*db.Pull)
127
if !ok {
128
log.Println("failed to get pull")
129
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
···
131
}
132
133
// can be nil if this pull is not stacked
134
-
stack, _ := r.Context().Value("stack").(db.Stack)
135
-
abandonedPulls, _ := r.Context().Value("abandonedPulls").([]*db.Pull)
136
137
totalIdents := 1
138
for _, submission := range pull.Submissions {
···
159
160
repoInfo := f.RepoInfo(user)
161
162
-
m := make(map[string]db.Pipeline)
163
164
var shas []string
165
for _, s := range pull.Submissions {
···
194
s.pages.Notice(w, "pulls", "Failed to load pull. Try again later.")
195
}
196
197
-
userReactions := map[db.ReactionKind]bool{}
198
if user != nil {
199
userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.PullAt())
200
}
201
202
s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{
203
LoggedInUser: user,
204
RepoInfo: repoInfo,
···
209
ResubmitCheck: resubmitResult,
210
Pipelines: m,
211
212
-
OrderedReactionKinds: db.OrderedReactionKinds,
213
Reactions: reactionCountMap,
214
UserReacted: userReactions,
215
})
216
}
217
218
-
func (s *Pulls) mergeCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) types.MergeCheckResponse {
219
-
if pull.State == db.PullMerged {
220
return types.MergeCheckResponse{}
221
}
222
···
282
return result
283
}
284
285
-
func (s *Pulls) resubmitCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) pages.ResubmitResult {
286
-
if pull.State == db.PullMerged || pull.State == db.PullDeleted || pull.PullSource == nil {
287
return pages.Unknown
288
}
289
···
356
diffOpts.Split = true
357
}
358
359
-
pull, ok := r.Context().Value("pull").(*db.Pull)
360
if !ok {
361
log.Println("failed to get pull")
362
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
363
return
364
}
365
366
-
stack, _ := r.Context().Value("stack").(db.Stack)
367
368
roundId := chi.URLParam(r, "round")
369
roundIdInt, err := strconv.Atoi(roundId)
···
403
diffOpts.Split = true
404
}
405
406
-
pull, ok := r.Context().Value("pull").(*db.Pull)
407
if !ok {
408
log.Println("failed to get pull")
409
s.pages.Notice(w, "pull-error", "Failed to get pull.")
···
451
}
452
453
func (s *Pulls) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) {
454
-
pull, ok := r.Context().Value("pull").(*db.Pull)
455
if !ok {
456
log.Println("failed to get pull")
457
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
···
474
user := s.oauth.GetUser(r)
475
params := r.URL.Query()
476
477
-
state := db.PullOpen
478
switch params.Get("state") {
479
case "closed":
480
-
state = db.PullClosed
481
case "merged":
482
-
state = db.PullMerged
483
}
484
485
f, err := s.repoResolver.Resolve(r)
···
500
}
501
502
for _, p := range pulls {
503
-
var pullSourceRepo *db.Repo
504
if p.PullSource != nil {
505
if p.PullSource.RepoAt != nil {
506
pullSourceRepo, err = db.GetRepoByAtUri(s.db, p.PullSource.RepoAt.String())
···
515
}
516
517
// we want to group all stacked PRs into just one list
518
-
stacks := make(map[string]db.Stack)
519
var shas []string
520
n := 0
521
for _, p := range pulls {
···
551
log.Printf("failed to fetch pipeline statuses: %s", err)
552
// non-fatal
553
}
554
-
m := make(map[string]db.Pipeline)
555
for _, p := range ps {
556
m[p.Sha] = p
557
}
558
559
s.pages.RepoPulls(w, pages.RepoPullsParams{
560
LoggedInUser: s.oauth.GetUser(r),
561
RepoInfo: f.RepoInfo(user),
562
Pulls: pulls,
563
FilteringBy: state,
564
Stacks: stacks,
565
Pipelines: m,
···
574
return
575
}
576
577
-
pull, ok := r.Context().Value("pull").(*db.Pull)
578
if !ok {
579
log.Println("failed to get pull")
580
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
···
647
return
648
}
649
650
-
comment := &db.PullComment{
651
OwnerDid: user.Did,
652
RepoAt: f.RepoAt().String(),
653
PullId: pull.PullId,
···
890
return
891
}
892
893
-
pullSource := &db.PullSource{
894
Branch: sourceBranch,
895
}
896
recordPullSource := &tangled.RepoPull_Source{
···
1000
forkAtUri := fork.RepoAt()
1001
forkAtUriStr := forkAtUri.String()
1002
1003
-
pullSource := &db.PullSource{
1004
Branch: sourceBranch,
1005
RepoAt: &forkAtUri,
1006
}
···
1021
title, body, targetBranch string,
1022
patch string,
1023
sourceRev string,
1024
-
pullSource *db.PullSource,
1025
recordPullSource *tangled.RepoPull_Source,
1026
isStacked bool,
1027
) {
···
1057
1058
// We've already checked earlier if it's diff-based and title is empty,
1059
// so if it's still empty now, it's intentionally skipped owing to format-patch.
1060
-
if title == "" {
1061
formatPatches, err := patchutil.ExtractPatches(patch)
1062
if err != nil {
1063
s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err))
···
1068
return
1069
}
1070
1071
-
title = formatPatches[0].Title
1072
-
body = formatPatches[0].Body
1073
}
1074
1075
rkey := tid.TID()
1076
-
initialSubmission := db.PullSubmission{
1077
Patch: patch,
1078
SourceRev: sourceRev,
1079
}
1080
-
pull := &db.Pull{
1081
Title: title,
1082
Body: body,
1083
TargetBranch: targetBranch,
1084
OwnerDid: user.Did,
1085
RepoAt: f.RepoAt(),
1086
Rkey: rkey,
1087
-
Submissions: []*db.PullSubmission{
1088
&initialSubmission,
1089
},
1090
PullSource: pullSource,
···
1143
targetBranch string,
1144
patch string,
1145
sourceRev string,
1146
-
pullSource *db.PullSource,
1147
) {
1148
// run some necessary checks for stacked-prs first
1149
···
1364
forkOwnerDid := repoString[0]
1365
forkName := repoString[1]
1366
// fork repo
1367
-
repo, err := db.GetRepo(s.db, forkOwnerDid, forkName)
1368
if err != nil {
1369
-
log.Println("failed to get repo", user.Did, forkVal)
1370
return
1371
}
1372
···
1447
return
1448
}
1449
1450
-
pull, ok := r.Context().Value("pull").(*db.Pull)
1451
if !ok {
1452
log.Println("failed to get pull")
1453
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
···
1478
func (s *Pulls) resubmitPatch(w http.ResponseWriter, r *http.Request) {
1479
user := s.oauth.GetUser(r)
1480
1481
-
pull, ok := r.Context().Value("pull").(*db.Pull)
1482
if !ok {
1483
log.Println("failed to get pull")
1484
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
···
1505
func (s *Pulls) resubmitBranch(w http.ResponseWriter, r *http.Request) {
1506
user := s.oauth.GetUser(r)
1507
1508
-
pull, ok := r.Context().Value("pull").(*db.Pull)
1509
if !ok {
1510
log.Println("failed to get pull")
1511
s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.")
···
1568
func (s *Pulls) resubmitFork(w http.ResponseWriter, r *http.Request) {
1569
user := s.oauth.GetUser(r)
1570
1571
-
pull, ok := r.Context().Value("pull").(*db.Pull)
1572
if !ok {
1573
log.Println("failed to get pull")
1574
s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.")
···
1661
}
1662
1663
// validate a resubmission against a pull request
1664
-
func validateResubmittedPatch(pull *db.Pull, patch string) error {
1665
if patch == "" {
1666
return fmt.Errorf("Patch is empty.")
1667
}
···
1682
r *http.Request,
1683
f *reporesolver.ResolvedRepo,
1684
user *oauth.User,
1685
-
pull *db.Pull,
1686
patch string,
1687
sourceRev string,
1688
) {
···
1786
r *http.Request,
1787
f *reporesolver.ResolvedRepo,
1788
user *oauth.User,
1789
-
pull *db.Pull,
1790
patch string,
1791
stackId string,
1792
) {
1793
targetBranch := pull.TargetBranch
1794
1795
-
origStack, _ := r.Context().Value("stack").(db.Stack)
1796
newStack, err := newStack(f, user, targetBranch, patch, pull.PullSource, stackId)
1797
if err != nil {
1798
log.Println("failed to create resubmitted stack", err)
···
1801
}
1802
1803
// find the diff between the stacks, first, map them by changeId
1804
-
origById := make(map[string]*db.Pull)
1805
-
newById := make(map[string]*db.Pull)
1806
for _, p := range origStack {
1807
origById[p.ChangeId] = p
1808
}
···
1815
// commits that got updated: corresponding pull is resubmitted & new round begins
1816
//
1817
// for commits that were unchanged: no changes, parent-change-id is updated as necessary
1818
-
additions := make(map[string]*db.Pull)
1819
-
deletions := make(map[string]*db.Pull)
1820
unchanged := make(map[string]struct{})
1821
updated := make(map[string]struct{})
1822
···
1876
// deleted pulls are marked as deleted in the DB
1877
for _, p := range deletions {
1878
// do not do delete already merged PRs
1879
-
if p.State == db.PullMerged {
1880
continue
1881
}
1882
···
1921
np, _ := newById[id]
1922
1923
// do not update already merged PRs
1924
-
if op.State == db.PullMerged {
1925
continue
1926
}
1927
···
2042
return
2043
}
2044
2045
-
pull, ok := r.Context().Value("pull").(*db.Pull)
2046
if !ok {
2047
log.Println("failed to get pull")
2048
s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.")
2049
return
2050
}
2051
2052
-
var pullsToMerge db.Stack
2053
pullsToMerge = append(pullsToMerge, pull)
2054
if pull.IsStacked() {
2055
-
stack, ok := r.Context().Value("stack").(db.Stack)
2056
if !ok {
2057
log.Println("failed to get stack")
2058
s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.")
···
2142
return
2143
}
2144
2145
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.Name, pull.PullId))
2146
}
2147
···
2154
return
2155
}
2156
2157
-
pull, ok := r.Context().Value("pull").(*db.Pull)
2158
if !ok {
2159
log.Println("failed to get pull")
2160
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
···
2182
}
2183
defer tx.Rollback()
2184
2185
-
var pullsToClose []*db.Pull
2186
pullsToClose = append(pullsToClose, pull)
2187
2188
// if this PR is stacked, then we want to close all PRs below this one on the stack
2189
if pull.IsStacked() {
2190
-
stack := r.Context().Value("stack").(db.Stack)
2191
subStack := stack.StrictlyBelow(pull)
2192
pullsToClose = append(pullsToClose, subStack...)
2193
}
···
2209
return
2210
}
2211
2212
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
2213
}
2214
···
2222
return
2223
}
2224
2225
-
pull, ok := r.Context().Value("pull").(*db.Pull)
2226
if !ok {
2227
log.Println("failed to get pull")
2228
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
···
2250
}
2251
defer tx.Rollback()
2252
2253
-
var pullsToReopen []*db.Pull
2254
pullsToReopen = append(pullsToReopen, pull)
2255
2256
// if this PR is stacked, then we want to reopen all PRs above this one on the stack
2257
if pull.IsStacked() {
2258
-
stack := r.Context().Value("stack").(db.Stack)
2259
subStack := stack.StrictlyAbove(pull)
2260
pullsToReopen = append(pullsToReopen, subStack...)
2261
}
···
2280
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
2281
}
2282
2283
-
func newStack(f *reporesolver.ResolvedRepo, user *oauth.User, targetBranch, patch string, pullSource *db.PullSource, stackId string) (db.Stack, error) {
2284
formatPatches, err := patchutil.ExtractPatches(patch)
2285
if err != nil {
2286
return nil, fmt.Errorf("Failed to extract patches: %v", err)
···
2292
}
2293
2294
// the stack is identified by a UUID
2295
-
var stack db.Stack
2296
parentChangeId := ""
2297
for _, fp := range formatPatches {
2298
// all patches must have a jj change-id
···
2305
body := fp.Body
2306
rkey := tid.TID()
2307
2308
-
initialSubmission := db.PullSubmission{
2309
Patch: fp.Raw,
2310
SourceRev: fp.SHA,
2311
}
2312
-
pull := db.Pull{
2313
Title: title,
2314
Body: body,
2315
TargetBranch: targetBranch,
2316
OwnerDid: user.Did,
2317
RepoAt: f.RepoAt(),
2318
Rkey: rkey,
2319
-
Submissions: []*db.PullSubmission{
2320
&initialSubmission,
2321
},
2322
PullSource: pullSource,
···
12
"strings"
13
"time"
14
15
+
"tangled.org/core/api/tangled"
16
+
"tangled.org/core/appview/config"
17
+
"tangled.org/core/appview/db"
18
+
"tangled.org/core/appview/models"
19
+
"tangled.org/core/appview/notify"
20
+
"tangled.org/core/appview/oauth"
21
+
"tangled.org/core/appview/pages"
22
+
"tangled.org/core/appview/pages/markup"
23
+
"tangled.org/core/appview/reporesolver"
24
+
"tangled.org/core/appview/xrpcclient"
25
+
"tangled.org/core/idresolver"
26
+
"tangled.org/core/patchutil"
27
+
"tangled.org/core/tid"
28
+
"tangled.org/core/types"
29
30
"github.com/bluekeyes/go-gitdiff/gitdiff"
31
comatproto "github.com/bluesky-social/indigo/api/atproto"
···
76
return
77
}
78
79
+
pull, ok := r.Context().Value("pull").(*models.Pull)
80
if !ok {
81
log.Println("failed to get pull")
82
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
···
84
}
85
86
// can be nil if this pull is not stacked
87
+
stack, _ := r.Context().Value("stack").(models.Stack)
88
89
roundNumberStr := chi.URLParam(r, "round")
90
roundNumber, err := strconv.Atoi(roundNumberStr)
···
124
return
125
}
126
127
+
pull, ok := r.Context().Value("pull").(*models.Pull)
128
if !ok {
129
log.Println("failed to get pull")
130
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
···
132
}
133
134
// can be nil if this pull is not stacked
135
+
stack, _ := r.Context().Value("stack").(models.Stack)
136
+
abandonedPulls, _ := r.Context().Value("abandonedPulls").([]*models.Pull)
137
138
totalIdents := 1
139
for _, submission := range pull.Submissions {
···
160
161
repoInfo := f.RepoInfo(user)
162
163
+
m := make(map[string]models.Pipeline)
164
165
var shas []string
166
for _, s := range pull.Submissions {
···
195
s.pages.Notice(w, "pulls", "Failed to load pull. Try again later.")
196
}
197
198
+
userReactions := map[models.ReactionKind]bool{}
199
if user != nil {
200
userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.PullAt())
201
}
202
203
+
labelDefs, err := db.GetLabelDefinitions(
204
+
s.db,
205
+
db.FilterIn("at_uri", f.Repo.Labels),
206
+
db.FilterContains("scope", tangled.RepoPullNSID),
207
+
)
208
+
if err != nil {
209
+
log.Println("failed to fetch labels", err)
210
+
s.pages.Error503(w)
211
+
return
212
+
}
213
+
214
+
defs := make(map[string]*models.LabelDefinition)
215
+
for _, l := range labelDefs {
216
+
defs[l.AtUri().String()] = &l
217
+
}
218
+
219
s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{
220
LoggedInUser: user,
221
RepoInfo: repoInfo,
···
226
ResubmitCheck: resubmitResult,
227
Pipelines: m,
228
229
+
OrderedReactionKinds: models.OrderedReactionKinds,
230
Reactions: reactionCountMap,
231
UserReacted: userReactions,
232
+
233
+
LabelDefs: defs,
234
})
235
}
236
237
+
func (s *Pulls) mergeCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *models.Pull, stack models.Stack) types.MergeCheckResponse {
238
+
if pull.State == models.PullMerged {
239
return types.MergeCheckResponse{}
240
}
241
···
301
return result
302
}
303
304
+
func (s *Pulls) resubmitCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *models.Pull, stack models.Stack) pages.ResubmitResult {
305
+
if pull.State == models.PullMerged || pull.State == models.PullDeleted || pull.PullSource == nil {
306
return pages.Unknown
307
}
308
···
375
diffOpts.Split = true
376
}
377
378
+
pull, ok := r.Context().Value("pull").(*models.Pull)
379
if !ok {
380
log.Println("failed to get pull")
381
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
382
return
383
}
384
385
+
stack, _ := r.Context().Value("stack").(models.Stack)
386
387
roundId := chi.URLParam(r, "round")
388
roundIdInt, err := strconv.Atoi(roundId)
···
422
diffOpts.Split = true
423
}
424
425
+
pull, ok := r.Context().Value("pull").(*models.Pull)
426
if !ok {
427
log.Println("failed to get pull")
428
s.pages.Notice(w, "pull-error", "Failed to get pull.")
···
470
}
471
472
func (s *Pulls) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) {
473
+
pull, ok := r.Context().Value("pull").(*models.Pull)
474
if !ok {
475
log.Println("failed to get pull")
476
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
···
493
user := s.oauth.GetUser(r)
494
params := r.URL.Query()
495
496
+
state := models.PullOpen
497
switch params.Get("state") {
498
case "closed":
499
+
state = models.PullClosed
500
case "merged":
501
+
state = models.PullMerged
502
}
503
504
f, err := s.repoResolver.Resolve(r)
···
519
}
520
521
for _, p := range pulls {
522
+
var pullSourceRepo *models.Repo
523
if p.PullSource != nil {
524
if p.PullSource.RepoAt != nil {
525
pullSourceRepo, err = db.GetRepoByAtUri(s.db, p.PullSource.RepoAt.String())
···
534
}
535
536
// we want to group all stacked PRs into just one list
537
+
stacks := make(map[string]models.Stack)
538
var shas []string
539
n := 0
540
for _, p := range pulls {
···
570
log.Printf("failed to fetch pipeline statuses: %s", err)
571
// non-fatal
572
}
573
+
m := make(map[string]models.Pipeline)
574
for _, p := range ps {
575
m[p.Sha] = p
576
}
577
578
+
labelDefs, err := db.GetLabelDefinitions(
579
+
s.db,
580
+
db.FilterIn("at_uri", f.Repo.Labels),
581
+
db.FilterContains("scope", tangled.RepoPullNSID),
582
+
)
583
+
if err != nil {
584
+
log.Println("failed to fetch labels", err)
585
+
s.pages.Error503(w)
586
+
return
587
+
}
588
+
589
+
defs := make(map[string]*models.LabelDefinition)
590
+
for _, l := range labelDefs {
591
+
defs[l.AtUri().String()] = &l
592
+
}
593
+
594
s.pages.RepoPulls(w, pages.RepoPullsParams{
595
LoggedInUser: s.oauth.GetUser(r),
596
RepoInfo: f.RepoInfo(user),
597
Pulls: pulls,
598
+
LabelDefs: defs,
599
FilteringBy: state,
600
Stacks: stacks,
601
Pipelines: m,
···
610
return
611
}
612
613
+
pull, ok := r.Context().Value("pull").(*models.Pull)
614
if !ok {
615
log.Println("failed to get pull")
616
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
···
683
return
684
}
685
686
+
comment := &models.PullComment{
687
OwnerDid: user.Did,
688
RepoAt: f.RepoAt().String(),
689
PullId: pull.PullId,
···
926
return
927
}
928
929
+
pullSource := &models.PullSource{
930
Branch: sourceBranch,
931
}
932
recordPullSource := &tangled.RepoPull_Source{
···
1036
forkAtUri := fork.RepoAt()
1037
forkAtUriStr := forkAtUri.String()
1038
1039
+
pullSource := &models.PullSource{
1040
Branch: sourceBranch,
1041
RepoAt: &forkAtUri,
1042
}
···
1057
title, body, targetBranch string,
1058
patch string,
1059
sourceRev string,
1060
+
pullSource *models.PullSource,
1061
recordPullSource *tangled.RepoPull_Source,
1062
isStacked bool,
1063
) {
···
1093
1094
// We've already checked earlier if it's diff-based and title is empty,
1095
// so if it's still empty now, it's intentionally skipped owing to format-patch.
1096
+
if title == "" || body == "" {
1097
formatPatches, err := patchutil.ExtractPatches(patch)
1098
if err != nil {
1099
s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err))
···
1104
return
1105
}
1106
1107
+
if title == "" {
1108
+
title = formatPatches[0].Title
1109
+
}
1110
+
if body == "" {
1111
+
body = formatPatches[0].Body
1112
+
}
1113
}
1114
1115
rkey := tid.TID()
1116
+
initialSubmission := models.PullSubmission{
1117
Patch: patch,
1118
SourceRev: sourceRev,
1119
}
1120
+
pull := &models.Pull{
1121
Title: title,
1122
Body: body,
1123
TargetBranch: targetBranch,
1124
OwnerDid: user.Did,
1125
RepoAt: f.RepoAt(),
1126
Rkey: rkey,
1127
+
Submissions: []*models.PullSubmission{
1128
&initialSubmission,
1129
},
1130
PullSource: pullSource,
···
1183
targetBranch string,
1184
patch string,
1185
sourceRev string,
1186
+
pullSource *models.PullSource,
1187
) {
1188
// run some necessary checks for stacked-prs first
1189
···
1404
forkOwnerDid := repoString[0]
1405
forkName := repoString[1]
1406
// fork repo
1407
+
repo, err := db.GetRepo(
1408
+
s.db,
1409
+
db.FilterEq("did", forkOwnerDid),
1410
+
db.FilterEq("name", forkName),
1411
+
)
1412
if err != nil {
1413
+
log.Println("failed to get repo", "did", forkOwnerDid, "name", forkName, "err", err)
1414
return
1415
}
1416
···
1491
return
1492
}
1493
1494
+
pull, ok := r.Context().Value("pull").(*models.Pull)
1495
if !ok {
1496
log.Println("failed to get pull")
1497
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
···
1522
func (s *Pulls) resubmitPatch(w http.ResponseWriter, r *http.Request) {
1523
user := s.oauth.GetUser(r)
1524
1525
+
pull, ok := r.Context().Value("pull").(*models.Pull)
1526
if !ok {
1527
log.Println("failed to get pull")
1528
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
···
1549
func (s *Pulls) resubmitBranch(w http.ResponseWriter, r *http.Request) {
1550
user := s.oauth.GetUser(r)
1551
1552
+
pull, ok := r.Context().Value("pull").(*models.Pull)
1553
if !ok {
1554
log.Println("failed to get pull")
1555
s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.")
···
1612
func (s *Pulls) resubmitFork(w http.ResponseWriter, r *http.Request) {
1613
user := s.oauth.GetUser(r)
1614
1615
+
pull, ok := r.Context().Value("pull").(*models.Pull)
1616
if !ok {
1617
log.Println("failed to get pull")
1618
s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.")
···
1705
}
1706
1707
// validate a resubmission against a pull request
1708
+
func validateResubmittedPatch(pull *models.Pull, patch string) error {
1709
if patch == "" {
1710
return fmt.Errorf("Patch is empty.")
1711
}
···
1726
r *http.Request,
1727
f *reporesolver.ResolvedRepo,
1728
user *oauth.User,
1729
+
pull *models.Pull,
1730
patch string,
1731
sourceRev string,
1732
) {
···
1830
r *http.Request,
1831
f *reporesolver.ResolvedRepo,
1832
user *oauth.User,
1833
+
pull *models.Pull,
1834
patch string,
1835
stackId string,
1836
) {
1837
targetBranch := pull.TargetBranch
1838
1839
+
origStack, _ := r.Context().Value("stack").(models.Stack)
1840
newStack, err := newStack(f, user, targetBranch, patch, pull.PullSource, stackId)
1841
if err != nil {
1842
log.Println("failed to create resubmitted stack", err)
···
1845
}
1846
1847
// find the diff between the stacks, first, map them by changeId
1848
+
origById := make(map[string]*models.Pull)
1849
+
newById := make(map[string]*models.Pull)
1850
for _, p := range origStack {
1851
origById[p.ChangeId] = p
1852
}
···
1859
// commits that got updated: corresponding pull is resubmitted & new round begins
1860
//
1861
// for commits that were unchanged: no changes, parent-change-id is updated as necessary
1862
+
additions := make(map[string]*models.Pull)
1863
+
deletions := make(map[string]*models.Pull)
1864
unchanged := make(map[string]struct{})
1865
updated := make(map[string]struct{})
1866
···
1920
// deleted pulls are marked as deleted in the DB
1921
for _, p := range deletions {
1922
// do not do delete already merged PRs
1923
+
if p.State == models.PullMerged {
1924
continue
1925
}
1926
···
1965
np, _ := newById[id]
1966
1967
// do not update already merged PRs
1968
+
if op.State == models.PullMerged {
1969
continue
1970
}
1971
···
2086
return
2087
}
2088
2089
+
pull, ok := r.Context().Value("pull").(*models.Pull)
2090
if !ok {
2091
log.Println("failed to get pull")
2092
s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.")
2093
return
2094
}
2095
2096
+
var pullsToMerge models.Stack
2097
pullsToMerge = append(pullsToMerge, pull)
2098
if pull.IsStacked() {
2099
+
stack, ok := r.Context().Value("stack").(models.Stack)
2100
if !ok {
2101
log.Println("failed to get stack")
2102
s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.")
···
2186
return
2187
}
2188
2189
+
// notify about the pull merge
2190
+
for _, p := range pullsToMerge {
2191
+
s.notifier.NewPullMerged(r.Context(), p)
2192
+
}
2193
+
2194
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.Name, pull.PullId))
2195
}
2196
···
2203
return
2204
}
2205
2206
+
pull, ok := r.Context().Value("pull").(*models.Pull)
2207
if !ok {
2208
log.Println("failed to get pull")
2209
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
···
2231
}
2232
defer tx.Rollback()
2233
2234
+
var pullsToClose []*models.Pull
2235
pullsToClose = append(pullsToClose, pull)
2236
2237
// if this PR is stacked, then we want to close all PRs below this one on the stack
2238
if pull.IsStacked() {
2239
+
stack := r.Context().Value("stack").(models.Stack)
2240
subStack := stack.StrictlyBelow(pull)
2241
pullsToClose = append(pullsToClose, subStack...)
2242
}
···
2258
return
2259
}
2260
2261
+
for _, p := range pullsToClose {
2262
+
s.notifier.NewPullClosed(r.Context(), p)
2263
+
}
2264
+
2265
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
2266
}
2267
···
2275
return
2276
}
2277
2278
+
pull, ok := r.Context().Value("pull").(*models.Pull)
2279
if !ok {
2280
log.Println("failed to get pull")
2281
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
···
2303
}
2304
defer tx.Rollback()
2305
2306
+
var pullsToReopen []*models.Pull
2307
pullsToReopen = append(pullsToReopen, pull)
2308
2309
// if this PR is stacked, then we want to reopen all PRs above this one on the stack
2310
if pull.IsStacked() {
2311
+
stack := r.Context().Value("stack").(models.Stack)
2312
subStack := stack.StrictlyAbove(pull)
2313
pullsToReopen = append(pullsToReopen, subStack...)
2314
}
···
2333
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
2334
}
2335
2336
+
func newStack(f *reporesolver.ResolvedRepo, user *oauth.User, targetBranch, patch string, pullSource *models.PullSource, stackId string) (models.Stack, error) {
2337
formatPatches, err := patchutil.ExtractPatches(patch)
2338
if err != nil {
2339
return nil, fmt.Errorf("Failed to extract patches: %v", err)
···
2345
}
2346
2347
// the stack is identified by a UUID
2348
+
var stack models.Stack
2349
parentChangeId := ""
2350
for _, fp := range formatPatches {
2351
// all patches must have a jj change-id
···
2358
body := fp.Body
2359
rkey := tid.TID()
2360
2361
+
initialSubmission := models.PullSubmission{
2362
Patch: fp.Raw,
2363
SourceRev: fp.SHA,
2364
}
2365
+
pull := models.Pull{
2366
Title: title,
2367
Body: body,
2368
TargetBranch: targetBranch,
2369
OwnerDid: user.Did,
2370
RepoAt: f.RepoAt(),
2371
Rkey: rkey,
2372
+
Submissions: []*models.PullSubmission{
2373
&initialSubmission,
2374
},
2375
PullSource: pullSource,
+1
-1
appview/pulls/router.go
+1
-1
appview/pulls/router.go
+49
-22
appview/repo/artifact.go
+49
-22
appview/repo/artifact.go
···
4
"context"
5
"encoding/json"
6
"fmt"
7
"log"
8
"net/http"
9
"net/url"
···
16
"github.com/go-chi/chi/v5"
17
"github.com/go-git/go-git/v5/plumbing"
18
"github.com/ipfs/go-cid"
19
-
"tangled.sh/tangled.sh/core/api/tangled"
20
-
"tangled.sh/tangled.sh/core/appview/db"
21
-
"tangled.sh/tangled.sh/core/appview/pages"
22
-
"tangled.sh/tangled.sh/core/appview/reporesolver"
23
-
"tangled.sh/tangled.sh/core/appview/xrpcclient"
24
-
"tangled.sh/tangled.sh/core/tid"
25
-
"tangled.sh/tangled.sh/core/types"
26
)
27
28
// TODO: proper statuses here on early exit
···
100
}
101
defer tx.Rollback()
102
103
-
artifact := db.Artifact{
104
Did: user.Did,
105
Rkey: rkey,
106
RepoAt: f.RepoAt(),
···
133
})
134
}
135
136
-
// TODO: proper statuses here on early exit
137
func (rp *Repo) DownloadArtifact(w http.ResponseWriter, r *http.Request) {
138
-
tagParam := chi.URLParam(r, "tag")
139
-
filename := chi.URLParam(r, "file")
140
f, err := rp.repoResolver.Resolve(r)
141
if err != nil {
142
log.Println("failed to get repo and knot", err)
143
return
144
}
145
146
tag, err := rp.resolveTag(r.Context(), f, tagParam)
147
if err != nil {
148
log.Println("failed to resolve tag", err)
···
150
return
151
}
152
153
-
client, err := rp.oauth.AuthorizedClient(r)
154
-
if err != nil {
155
-
log.Println("failed to get authorized client", err)
156
-
return
157
-
}
158
-
159
artifacts, err := db.GetArtifact(
160
rp.db,
161
db.FilterEq("repo_at", f.RepoAt()),
···
164
)
165
if err != nil {
166
log.Println("failed to get artifacts", err)
167
return
168
}
169
if len(artifacts) != 1 {
170
-
log.Printf("too many or too little artifacts found")
171
return
172
}
173
174
artifact := artifacts[0]
175
176
-
getBlobResp, err := client.SyncGetBlob(r.Context(), artifact.BlobCid.String(), artifact.Did)
177
if err != nil {
178
-
log.Println("failed to get blob from pds", err)
179
return
180
}
181
182
-
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
183
-
w.Write(getBlobResp)
184
}
185
186
// TODO: proper statuses here on early exit
···
4
"context"
5
"encoding/json"
6
"fmt"
7
+
"io"
8
"log"
9
"net/http"
10
"net/url"
···
17
"github.com/go-chi/chi/v5"
18
"github.com/go-git/go-git/v5/plumbing"
19
"github.com/ipfs/go-cid"
20
+
"tangled.org/core/api/tangled"
21
+
"tangled.org/core/appview/db"
22
+
"tangled.org/core/appview/models"
23
+
"tangled.org/core/appview/pages"
24
+
"tangled.org/core/appview/reporesolver"
25
+
"tangled.org/core/appview/xrpcclient"
26
+
"tangled.org/core/tid"
27
+
"tangled.org/core/types"
28
)
29
30
// TODO: proper statuses here on early exit
···
102
}
103
defer tx.Rollback()
104
105
+
artifact := models.Artifact{
106
Did: user.Did,
107
Rkey: rkey,
108
RepoAt: f.RepoAt(),
···
135
})
136
}
137
138
func (rp *Repo) DownloadArtifact(w http.ResponseWriter, r *http.Request) {
139
f, err := rp.repoResolver.Resolve(r)
140
if err != nil {
141
log.Println("failed to get repo and knot", err)
142
+
http.Error(w, "failed to resolve repo", http.StatusInternalServerError)
143
return
144
}
145
146
+
tagParam := chi.URLParam(r, "tag")
147
+
filename := chi.URLParam(r, "file")
148
+
149
tag, err := rp.resolveTag(r.Context(), f, tagParam)
150
if err != nil {
151
log.Println("failed to resolve tag", err)
···
153
return
154
}
155
156
artifacts, err := db.GetArtifact(
157
rp.db,
158
db.FilterEq("repo_at", f.RepoAt()),
···
161
)
162
if err != nil {
163
log.Println("failed to get artifacts", err)
164
+
http.Error(w, "failed to get artifact", http.StatusInternalServerError)
165
return
166
}
167
+
168
if len(artifacts) != 1 {
169
+
log.Printf("too many or too few artifacts found")
170
+
http.Error(w, "artifact not found", http.StatusNotFound)
171
return
172
}
173
174
artifact := artifacts[0]
175
176
+
ownerPds := f.OwnerId.PDSEndpoint()
177
+
url, _ := url.Parse(fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob", ownerPds))
178
+
q := url.Query()
179
+
q.Set("cid", artifact.BlobCid.String())
180
+
q.Set("did", artifact.Did)
181
+
url.RawQuery = q.Encode()
182
+
183
+
req, err := http.NewRequest(http.MethodGet, url.String(), nil)
184
if err != nil {
185
+
log.Println("failed to create request", err)
186
+
http.Error(w, "failed to create request", http.StatusInternalServerError)
187
+
return
188
+
}
189
+
req.Header.Set("Content-Type", "application/json")
190
+
191
+
resp, err := http.DefaultClient.Do(req)
192
+
if err != nil {
193
+
log.Println("failed to make request", err)
194
+
http.Error(w, "failed to make request to PDS", http.StatusInternalServerError)
195
return
196
}
197
+
defer resp.Body.Close()
198
199
+
// copy status code and relevant headers from upstream response
200
+
w.WriteHeader(resp.StatusCode)
201
+
for key, values := range resp.Header {
202
+
for _, v := range values {
203
+
w.Header().Add(key, v)
204
+
}
205
+
}
206
+
207
+
// stream the body directly to the client
208
+
if _, err := io.Copy(w, resp.Body); err != nil {
209
+
log.Println("error streaming response to client:", err)
210
+
}
211
}
212
213
// TODO: proper statuses here on early exit
+10
-9
appview/repo/feed.go
+10
-9
appview/repo/feed.go
···
8
"slices"
9
"time"
10
11
-
"tangled.sh/tangled.sh/core/appview/db"
12
-
"tangled.sh/tangled.sh/core/appview/pagination"
13
-
"tangled.sh/tangled.sh/core/appview/reporesolver"
14
15
"github.com/bluesky-social/indigo/atproto/syntax"
16
"github.com/gorilla/feeds"
···
70
return feed, nil
71
}
72
73
-
func (rp *Repo) createPullItems(ctx context.Context, pull *db.Pull, f *reporesolver.ResolvedRepo) ([]*feeds.Item, error) {
74
owner, err := rp.idResolver.ResolveIdent(ctx, pull.OwnerDid)
75
if err != nil {
76
return nil, err
···
108
return items, nil
109
}
110
111
-
func (rp *Repo) createIssueItem(ctx context.Context, issue db.Issue, f *reporesolver.ResolvedRepo) (*feeds.Item, error) {
112
owner, err := rp.idResolver.ResolveIdent(ctx, issue.Did)
113
if err != nil {
114
return nil, err
···
128
}, nil
129
}
130
131
-
func (rp *Repo) getPullState(pull *db.Pull) string {
132
-
if pull.State == db.PullOpen {
133
return "opened"
134
}
135
return pull.State.String()
136
}
137
138
-
func (rp *Repo) buildPullDescription(handle syntax.Handle, state string, pull *db.Pull, repoName string) string {
139
base := fmt.Sprintf("@%s %s pull request #%d", handle, state, pull.PullId)
140
141
-
if pull.State == db.PullMerged {
142
return fmt.Sprintf("%s (on round #%d) in %s", base, pull.LastRoundNumber(), repoName)
143
}
144
···
8
"slices"
9
"time"
10
11
+
"tangled.org/core/appview/db"
12
+
"tangled.org/core/appview/models"
13
+
"tangled.org/core/appview/pagination"
14
+
"tangled.org/core/appview/reporesolver"
15
16
"github.com/bluesky-social/indigo/atproto/syntax"
17
"github.com/gorilla/feeds"
···
71
return feed, nil
72
}
73
74
+
func (rp *Repo) createPullItems(ctx context.Context, pull *models.Pull, f *reporesolver.ResolvedRepo) ([]*feeds.Item, error) {
75
owner, err := rp.idResolver.ResolveIdent(ctx, pull.OwnerDid)
76
if err != nil {
77
return nil, err
···
109
return items, nil
110
}
111
112
+
func (rp *Repo) createIssueItem(ctx context.Context, issue models.Issue, f *reporesolver.ResolvedRepo) (*feeds.Item, error) {
113
owner, err := rp.idResolver.ResolveIdent(ctx, issue.Did)
114
if err != nil {
115
return nil, err
···
129
}, nil
130
}
131
132
+
func (rp *Repo) getPullState(pull *models.Pull) string {
133
+
if pull.State == models.PullOpen {
134
return "opened"
135
}
136
return pull.State.String()
137
}
138
139
+
func (rp *Repo) buildPullDescription(handle syntax.Handle, state string, pull *models.Pull, repoName string) string {
140
base := fmt.Sprintf("@%s %s pull request #%d", handle, state, pull.PullId)
141
142
+
if pull.State == models.PullMerged {
143
return fmt.Sprintf("%s (on round #%d) in %s", base, pull.LastRoundNumber(), repoName)
144
}
145
+42
-47
appview/repo/index.go
+42
-47
appview/repo/index.go
···
5
"fmt"
6
"log"
7
"net/http"
8
"slices"
9
"sort"
10
"strings"
···
16
17
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
18
"github.com/go-git/go-git/v5/plumbing"
19
-
"tangled.sh/tangled.sh/core/api/tangled"
20
-
"tangled.sh/tangled.sh/core/appview/commitverify"
21
-
"tangled.sh/tangled.sh/core/appview/db"
22
-
"tangled.sh/tangled.sh/core/appview/pages"
23
-
"tangled.sh/tangled.sh/core/appview/pages/markup"
24
-
"tangled.sh/tangled.sh/core/appview/reporesolver"
25
-
"tangled.sh/tangled.sh/core/appview/xrpcclient"
26
-
"tangled.sh/tangled.sh/core/types"
27
28
"github.com/go-chi/chi/v5"
29
"github.com/go-enry/go-enry/v2"
···
31
32
func (rp *Repo) RepoIndex(w http.ResponseWriter, r *http.Request) {
33
ref := chi.URLParam(r, "ref")
34
35
f, err := rp.repoResolver.Resolve(r)
36
if err != nil {
···
61
RepoInfo: repoInfo,
62
})
63
return
64
-
} else {
65
-
rp.pages.Error503(w)
66
-
log.Println("failed to build index response", err)
67
-
return
68
}
69
}
70
71
tagMap := make(map[string][]string)
···
189
}
190
191
for _, lang := range ls.Languages {
192
-
langs = append(langs, db.RepoLanguage{
193
RepoAt: f.RepoAt(),
194
Ref: currentRef,
195
IsDefaultRef: isDefaultRef,
···
197
Bytes: lang.Size,
198
})
199
}
200
201
// update appview's cache
202
-
err = db.InsertRepoLanguages(rp.db, langs)
203
if err != nil {
204
// non-fatal
205
log.Println("failed to cache lang results", err)
206
}
207
}
208
···
245
// first get branches to determine the ref if not specified
246
branchesBytes, err := tangled.RepoBranches(ctx, xrpcc, "", 0, repo)
247
if err != nil {
248
-
return nil, err
249
}
250
251
var branchesResp types.RepoBranchesResponse
252
if err := json.Unmarshal(branchesBytes, &branchesResp); err != nil {
253
-
return nil, err
254
}
255
256
// if no ref specified, use default branch or first available
257
-
if ref == "" && len(branchesResp.Branches) > 0 {
258
for _, branch := range branchesResp.Branches {
259
if branch.IsDefault {
260
ref = branch.Name
261
break
262
}
263
-
}
264
-
if ref == "" {
265
-
ref = branchesResp.Branches[0].Name
266
}
267
}
268
269
-
// check if repo is empty
270
-
if len(branchesResp.Branches) == 0 {
271
return &types.RepoIndexResponse{
272
IsEmpty: true,
273
Branches: branchesResp.Branches,
···
292
defer wg.Done()
293
tagsBytes, err := tangled.RepoTags(ctx, xrpcc, "", 0, repo)
294
if err != nil {
295
-
errs = errors.Join(errs, err)
296
return
297
}
298
299
if err := json.Unmarshal(tagsBytes, &tagsResp); err != nil {
300
-
errs = errors.Join(errs, err)
301
}
302
}()
303
···
307
defer wg.Done()
308
resp, err := tangled.RepoTree(ctx, xrpcc, "", ref, repo)
309
if err != nil {
310
-
errs = errors.Join(errs, err)
311
return
312
}
313
treeResp = resp
···
319
defer wg.Done()
320
logBytes, err := tangled.RepoLog(ctx, xrpcc, "", 50, "", ref, repo)
321
if err != nil {
322
-
errs = errors.Join(errs, err)
323
return
324
}
325
326
if err := json.Unmarshal(logBytes, &logResp); err != nil {
327
-
errs = errors.Join(errs, err)
328
-
}
329
-
}()
330
-
331
-
// readme content
332
-
wg.Add(1)
333
-
go func() {
334
-
defer wg.Done()
335
-
for _, filename := range markup.ReadmeFilenames {
336
-
blobResp, err := tangled.RepoBlob(ctx, xrpcc, filename, false, ref, repo)
337
-
if err != nil {
338
-
continue
339
-
}
340
-
341
-
if blobResp == nil {
342
-
continue
343
-
}
344
-
345
-
readmeContent = blobResp.Content
346
-
readmeFileName = filename
347
-
break
348
}
349
}()
350
···
374
}
375
files = append(files, niceFile)
376
}
377
}
378
379
result := &types.RepoIndexResponse{
···
5
"fmt"
6
"log"
7
"net/http"
8
+
"net/url"
9
"slices"
10
"sort"
11
"strings"
···
17
18
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
19
"github.com/go-git/go-git/v5/plumbing"
20
+
"tangled.org/core/api/tangled"
21
+
"tangled.org/core/appview/commitverify"
22
+
"tangled.org/core/appview/db"
23
+
"tangled.org/core/appview/models"
24
+
"tangled.org/core/appview/pages"
25
+
"tangled.org/core/appview/reporesolver"
26
+
"tangled.org/core/appview/xrpcclient"
27
+
"tangled.org/core/types"
28
29
"github.com/go-chi/chi/v5"
30
"github.com/go-enry/go-enry/v2"
···
32
33
func (rp *Repo) RepoIndex(w http.ResponseWriter, r *http.Request) {
34
ref := chi.URLParam(r, "ref")
35
+
ref, _ = url.PathUnescape(ref)
36
37
f, err := rp.repoResolver.Resolve(r)
38
if err != nil {
···
63
RepoInfo: repoInfo,
64
})
65
return
66
}
67
+
68
+
rp.pages.Error503(w)
69
+
log.Println("failed to build index response", err)
70
+
return
71
}
72
73
tagMap := make(map[string][]string)
···
191
}
192
193
for _, lang := range ls.Languages {
194
+
langs = append(langs, models.RepoLanguage{
195
RepoAt: f.RepoAt(),
196
Ref: currentRef,
197
IsDefaultRef: isDefaultRef,
···
199
Bytes: lang.Size,
200
})
201
}
202
+
203
+
tx, err := rp.db.Begin()
204
+
if err != nil {
205
+
return nil, err
206
+
}
207
+
defer tx.Rollback()
208
209
// update appview's cache
210
+
err = db.UpdateRepoLanguages(tx, f.RepoAt(), currentRef, langs)
211
if err != nil {
212
// non-fatal
213
log.Println("failed to cache lang results", err)
214
+
}
215
+
216
+
err = tx.Commit()
217
+
if err != nil {
218
+
return nil, err
219
}
220
}
221
···
258
// first get branches to determine the ref if not specified
259
branchesBytes, err := tangled.RepoBranches(ctx, xrpcc, "", 0, repo)
260
if err != nil {
261
+
return nil, fmt.Errorf("failed to call repoBranches: %w", err)
262
}
263
264
var branchesResp types.RepoBranchesResponse
265
if err := json.Unmarshal(branchesBytes, &branchesResp); err != nil {
266
+
return nil, fmt.Errorf("failed to unmarshal branches response: %w", err)
267
}
268
269
// if no ref specified, use default branch or first available
270
+
if ref == "" {
271
for _, branch := range branchesResp.Branches {
272
if branch.IsDefault {
273
ref = branch.Name
274
break
275
}
276
}
277
}
278
279
+
// if ref is still empty, this means the default branch is not set
280
+
if ref == "" {
281
return &types.RepoIndexResponse{
282
IsEmpty: true,
283
Branches: branchesResp.Branches,
···
302
defer wg.Done()
303
tagsBytes, err := tangled.RepoTags(ctx, xrpcc, "", 0, repo)
304
if err != nil {
305
+
errs = errors.Join(errs, fmt.Errorf("failed to call repoTags: %w", err))
306
return
307
}
308
309
if err := json.Unmarshal(tagsBytes, &tagsResp); err != nil {
310
+
errs = errors.Join(errs, fmt.Errorf("failed to unmarshal repoTags: %w", err))
311
}
312
}()
313
···
317
defer wg.Done()
318
resp, err := tangled.RepoTree(ctx, xrpcc, "", ref, repo)
319
if err != nil {
320
+
errs = errors.Join(errs, fmt.Errorf("failed to call repoTree: %w", err))
321
return
322
}
323
treeResp = resp
···
329
defer wg.Done()
330
logBytes, err := tangled.RepoLog(ctx, xrpcc, "", 50, "", ref, repo)
331
if err != nil {
332
+
errs = errors.Join(errs, fmt.Errorf("failed to call repoLog: %w", err))
333
return
334
}
335
336
if err := json.Unmarshal(logBytes, &logResp); err != nil {
337
+
errs = errors.Join(errs, fmt.Errorf("failed to unmarshal repoLog: %w", err))
338
}
339
}()
340
···
364
}
365
files = append(files, niceFile)
366
}
367
+
}
368
+
369
+
if treeResp != nil && treeResp.Readme != nil {
370
+
readmeFileName = treeResp.Readme.Filename
371
+
readmeContent = treeResp.Readme.Contents
372
}
373
374
result := &types.RepoIndexResponse{
+754
-198
appview/repo/repo.go
+754
-198
appview/repo/repo.go
···
11
"log/slog"
12
"net/http"
13
"net/url"
14
-
"path"
15
"path/filepath"
16
"slices"
17
"strconv"
···
21
comatproto "github.com/bluesky-social/indigo/api/atproto"
22
lexutil "github.com/bluesky-social/indigo/lex/util"
23
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
24
-
"tangled.sh/tangled.sh/core/api/tangled"
25
-
"tangled.sh/tangled.sh/core/appview/commitverify"
26
-
"tangled.sh/tangled.sh/core/appview/config"
27
-
"tangled.sh/tangled.sh/core/appview/db"
28
-
"tangled.sh/tangled.sh/core/appview/notify"
29
-
"tangled.sh/tangled.sh/core/appview/oauth"
30
-
"tangled.sh/tangled.sh/core/appview/pages"
31
-
"tangled.sh/tangled.sh/core/appview/pages/markup"
32
-
"tangled.sh/tangled.sh/core/appview/reporesolver"
33
-
xrpcclient "tangled.sh/tangled.sh/core/appview/xrpcclient"
34
-
"tangled.sh/tangled.sh/core/eventconsumer"
35
-
"tangled.sh/tangled.sh/core/idresolver"
36
-
"tangled.sh/tangled.sh/core/patchutil"
37
-
"tangled.sh/tangled.sh/core/rbac"
38
-
"tangled.sh/tangled.sh/core/tid"
39
-
"tangled.sh/tangled.sh/core/types"
40
-
"tangled.sh/tangled.sh/core/xrpc/serviceauth"
41
42
securejoin "github.com/cyphar/filepath-securejoin"
43
"github.com/go-chi/chi/v5"
···
58
notifier notify.Notifier
59
logger *slog.Logger
60
serviceAuth *serviceauth.ServiceAuth
61
}
62
63
func New(
···
71
notifier notify.Notifier,
72
enforcer *rbac.Enforcer,
73
logger *slog.Logger,
74
) *Repo {
75
return &Repo{oauth: oauth,
76
repoResolver: repoResolver,
···
82
notifier: notifier,
83
enforcer: enforcer,
84
logger: logger,
85
}
86
}
87
88
func (rp *Repo) DownloadArchive(w http.ResponseWriter, r *http.Request) {
89
-
refParam := chi.URLParam(r, "ref")
90
f, err := rp.repoResolver.Resolve(r)
91
if err != nil {
92
log.Println("failed to get repo and knot", err)
···
103
}
104
105
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
106
-
archiveBytes, err := tangled.RepoArchive(r.Context(), xrpcc, "tar.gz", "", refParam, repo)
107
-
if err != nil {
108
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
109
-
log.Println("failed to call XRPC repo.archive", xrpcerr)
110
-
rp.pages.Error503(w)
111
-
return
112
-
}
113
-
rp.pages.Error404(w)
114
return
115
}
116
117
-
// Set headers for file download
118
-
filename := fmt.Sprintf("%s-%s.tar.gz", f.Name, refParam)
119
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
120
w.Header().Set("Content-Type", "application/gzip")
121
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(archiveBytes)))
···
140
}
141
142
ref := chi.URLParam(r, "ref")
143
144
scheme := "http"
145
if !rp.config.Core.Dev {
···
160
161
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
162
xrpcBytes, err := tangled.RepoLog(r.Context(), xrpcc, cursor, limit, "", ref, repo)
163
-
if err != nil {
164
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
165
-
log.Println("failed to call XRPC repo.log", xrpcerr)
166
-
rp.pages.Error503(w)
167
-
return
168
-
}
169
-
rp.pages.Error404(w)
170
return
171
}
172
···
178
}
179
180
tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
181
-
if err != nil {
182
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
183
-
log.Println("failed to call XRPC repo.tags", xrpcerr)
184
-
rp.pages.Error503(w)
185
-
return
186
-
}
187
}
188
189
tagMap := make(map[string][]string)
···
197
}
198
199
branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
200
-
if err != nil {
201
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
202
-
log.Println("failed to call XRPC repo.branches", xrpcerr)
203
-
rp.pages.Error503(w)
204
-
return
205
-
}
206
}
207
208
if branchBytes != nil {
···
304
return
305
}
306
307
// this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field
308
//
309
// SwapRecord is optional and should happen automagically, but given that it does not, we have to perform two requests
310
-
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, user.Did, rkey)
311
if err != nil {
312
// failed to get record
313
rp.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.")
···
315
}
316
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
317
Collection: tangled.RepoNSID,
318
-
Repo: user.Did,
319
-
Rkey: rkey,
320
SwapRecord: ex.Cid,
321
Record: &lexutil.LexiconTypeDecoder{
322
-
Val: &tangled.Repo{
323
-
Knot: f.Knot,
324
-
Name: f.Name,
325
-
Owner: user.Did,
326
-
CreatedAt: f.Created.Format(time.RFC3339),
327
-
Description: &newDescription,
328
-
Spindle: &f.Spindle,
329
-
},
330
},
331
})
332
···
354
return
355
}
356
ref := chi.URLParam(r, "ref")
357
358
var diffOpts types.DiffOpts
359
if d := r.URL.Query().Get("diff"); d == "split" {
···
376
377
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
378
xrpcBytes, err := tangled.RepoDiff(r.Context(), xrpcc, ref, repo)
379
-
if err != nil {
380
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
381
-
log.Println("failed to call XRPC repo.diff", xrpcerr)
382
-
rp.pages.Error503(w)
383
-
return
384
-
}
385
-
rp.pages.Error404(w)
386
return
387
}
388
···
410
log.Println(err)
411
// non-fatal
412
}
413
-
var pipeline *db.Pipeline
414
if p, ok := pipelines[result.Diff.Commit.This]; ok {
415
pipeline = &p
416
}
···
434
}
435
436
ref := chi.URLParam(r, "ref")
437
-
treePath := chi.URLParam(r, "*")
438
439
// if the tree path has a trailing slash, let's strip it
440
// so we don't 404
441
treePath = strings.TrimSuffix(treePath, "/")
442
443
scheme := "http"
···
451
452
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
453
xrpcResp, err := tangled.RepoTree(r.Context(), xrpcc, treePath, ref, repo)
454
-
if err != nil {
455
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
456
-
log.Println("failed to call XRPC repo.tree", xrpcerr)
457
-
rp.pages.Error503(w)
458
-
return
459
-
}
460
-
rp.pages.Error404(w)
461
return
462
}
463
···
496
if xrpcResp.Dotdot != nil {
497
result.DotDot = *xrpcResp.Dotdot
498
}
499
500
// redirects tree paths trying to access a blob; in this case the result.Files is unpopulated,
501
// so we can safely redirect to the "parent" (which is the same file).
502
-
unescapedTreePath, _ := url.PathUnescape(treePath)
503
-
if len(result.Files) == 0 && result.Parent == unescapedTreePath {
504
-
http.Redirect(w, r, fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), ref, result.Parent), http.StatusFound)
505
return
506
}
507
508
user := rp.oauth.GetUser(r)
509
510
var breadcrumbs [][]string
511
-
breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)})
512
if treePath != "" {
513
for idx, elem := range strings.Split(treePath, "/") {
514
-
breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)})
515
}
516
}
517
···
544
545
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
546
xrpcBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
547
-
if err != nil {
548
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
549
-
log.Println("failed to call XRPC repo.tags", xrpcerr)
550
-
rp.pages.Error503(w)
551
-
return
552
-
}
553
-
rp.pages.Error404(w)
554
return
555
}
556
···
568
}
569
570
// convert artifacts to map for easy UI building
571
-
artifactMap := make(map[plumbing.Hash][]db.Artifact)
572
for _, a := range artifacts {
573
artifactMap[a.Tag] = append(artifactMap[a.Tag], a)
574
}
575
576
-
var danglingArtifacts []db.Artifact
577
for _, a := range artifacts {
578
found := false
579
for _, t := range result.Tags {
···
617
618
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
619
xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
620
-
if err != nil {
621
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
622
-
log.Println("failed to call XRPC repo.branches", xrpcerr)
623
-
rp.pages.Error503(w)
624
-
return
625
-
}
626
-
rp.pages.Error404(w)
627
return
628
}
629
···
652
}
653
654
ref := chi.URLParam(r, "ref")
655
filePath := chi.URLParam(r, "*")
656
657
scheme := "http"
658
if !rp.config.Core.Dev {
···
665
666
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name)
667
resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, repo)
668
-
if err != nil {
669
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
670
-
log.Println("failed to call XRPC repo.blob", xrpcerr)
671
-
rp.pages.Error503(w)
672
-
return
673
-
}
674
-
rp.pages.Error404(w)
675
return
676
}
677
678
// Use XRPC response directly instead of converting to internal types
679
680
var breadcrumbs [][]string
681
-
breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)})
682
if filePath != "" {
683
for idx, elem := range strings.Split(filePath, "/") {
684
-
breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)})
685
}
686
}
687
···
710
}
711
712
// fetch the raw binary content using sh.tangled.repo.blob xrpc
713
-
repoName := path.Join("%s/%s", f.OwnerDid(), f.Name)
714
-
blobURL := fmt.Sprintf("%s://%s/xrpc/sh.tangled.repo.blob?repo=%s&ref=%s&path=%s&raw=true",
715
-
scheme, f.Knot, url.QueryEscape(repoName), url.QueryEscape(ref), url.QueryEscape(filePath))
716
717
contentSrc = blobURL
718
if !rp.config.Core.Dev {
···
767
}
768
769
ref := chi.URLParam(r, "ref")
770
filePath := chi.URLParam(r, "*")
771
772
scheme := "http"
773
if !rp.config.Core.Dev {
···
775
}
776
777
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name)
778
-
blobURL := fmt.Sprintf("%s://%s/xrpc/sh.tangled.repo.blob?repo=%s&ref=%s&path=%s&raw=true",
779
-
scheme, f.Knot, url.QueryEscape(repo), url.QueryEscape(ref), url.QueryEscape(filePath))
780
781
req, err := http.NewRequest("GET", blobURL, nil)
782
if err != nil {
···
870
return
871
}
872
873
-
repoAt := f.RepoAt()
874
-
rkey := repoAt.RecordKey().String()
875
-
if rkey == "" {
876
-
fail("Failed to resolve repo. Try again later", err)
877
-
return
878
-
}
879
-
880
newSpindle := r.FormValue("spindle")
881
removingSpindle := newSpindle == "[[none]]" // see pages/templates/repo/settings/pipelines.html for more info on why we use this value
882
client, err := rp.oauth.AuthorizedClient(r)
···
898
return
899
}
900
}
901
902
spindlePtr := &newSpindle
903
if removingSpindle {
904
spindlePtr = nil
905
}
906
907
// optimistic update
908
-
err = db.UpdateSpindle(rp.db, string(repoAt), spindlePtr)
909
if err != nil {
910
fail("Failed to update spindle. Try again later.", err)
911
return
912
}
913
914
-
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, user.Did, rkey)
915
if err != nil {
916
fail("Failed to update spindle, no record found on PDS.", err)
917
return
918
}
919
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
920
Collection: tangled.RepoNSID,
921
-
Repo: user.Did,
922
-
Rkey: rkey,
923
SwapRecord: ex.Cid,
924
Record: &lexutil.LexiconTypeDecoder{
925
-
Val: &tangled.Repo{
926
-
Knot: f.Knot,
927
-
Name: f.Name,
928
-
Owner: user.Did,
929
-
CreatedAt: f.Created.Format(time.RFC3339),
930
-
Description: &f.Description,
931
-
Spindle: spindlePtr,
932
-
},
933
},
934
})
935
···
949
rp.pages.HxRefresh(w)
950
}
951
952
func (rp *Repo) AddCollaborator(w http.ResponseWriter, r *http.Request) {
953
user := rp.oauth.GetUser(r)
954
l := rp.logger.With("handler", "AddCollaborator")
···
1050
return
1051
}
1052
1053
-
err = db.AddCollaborator(rp.db, db.Collaborator{
1054
Did: syntax.DID(currentUser.Did),
1055
Rkey: rkey,
1056
SubjectDid: collaboratorIdent.DID,
···
1365
1366
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
1367
xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
1368
-
if err != nil {
1369
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1370
-
log.Println("failed to call XRPC repo.branches", xrpcerr)
1371
-
rp.pages.Error503(w)
1372
-
return
1373
-
}
1374
rp.pages.Error503(w)
1375
return
1376
}
···
1382
return
1383
}
1384
1385
rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{
1386
-
LoggedInUser: user,
1387
-
RepoInfo: f.RepoInfo(user),
1388
-
Branches: result.Branches,
1389
-
Tabs: settingsTabs,
1390
-
Tab: "general",
1391
})
1392
}
1393
···
1472
1473
func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) {
1474
ref := chi.URLParam(r, "ref")
1475
1476
user := rp.oauth.GetUser(r)
1477
f, err := rp.repoResolver.Resolve(r)
···
1559
}
1560
1561
// choose a name for a fork
1562
-
forkName := f.Name
1563
// this check is *only* to see if the forked repo name already exists
1564
// in the user's account.
1565
-
existingRepo, err := db.GetRepo(rp.db, user.Did, f.Name)
1566
if err != nil {
1567
-
if errors.Is(err, sql.ErrNoRows) {
1568
-
// no existing repo with this name found, we can use the name as is
1569
-
} else {
1570
-
log.Println("error fetching existing repo from db", err)
1571
rp.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.")
1572
return
1573
}
1574
} else if existingRepo != nil {
1575
-
// repo with this name already exists, append random string
1576
-
forkName = fmt.Sprintf("%s-%s", forkName, randomString(3))
1577
}
1578
l = l.With("forkName", forkName)
1579
···
1589
1590
// create an atproto record for this fork
1591
rkey := tid.TID()
1592
-
repo := &db.Repo{
1593
-
Did: user.Did,
1594
-
Name: forkName,
1595
-
Knot: targetKnot,
1596
-
Rkey: rkey,
1597
-
Source: sourceAt,
1598
}
1599
1600
xrpcClient, err := rp.oauth.AuthorizedClient(r)
1601
if err != nil {
···
1604
return
1605
}
1606
1607
-
createdAt := time.Now().Format(time.RFC3339)
1608
atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1609
Collection: tangled.RepoNSID,
1610
Repo: user.Did,
1611
Rkey: rkey,
1612
Record: &lexutil.LexiconTypeDecoder{
1613
-
Val: &tangled.Repo{
1614
-
Knot: repo.Knot,
1615
-
Name: repo.Name,
1616
-
CreatedAt: createdAt,
1617
-
Owner: user.Did,
1618
-
Source: &sourceAt,
1619
-
}},
1620
})
1621
if err != nil {
1622
l.Error("failed to write to PDS", "err", err)
···
1760
1761
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
1762
branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
1763
-
if err != nil {
1764
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1765
-
log.Println("failed to call XRPC repo.branches", xrpcerr)
1766
-
rp.pages.Error503(w)
1767
-
return
1768
-
}
1769
-
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1770
return
1771
}
1772
···
1801
}
1802
1803
tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
1804
-
if err != nil {
1805
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1806
-
log.Println("failed to call XRPC repo.tags", xrpcerr)
1807
-
rp.pages.Error503(w)
1808
-
return
1809
-
}
1810
-
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1811
return
1812
}
1813
···
1878
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
1879
1880
branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
1881
-
if err != nil {
1882
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1883
-
log.Println("failed to call XRPC repo.branches", xrpcerr)
1884
-
rp.pages.Error503(w)
1885
-
return
1886
-
}
1887
-
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1888
return
1889
}
1890
···
1896
}
1897
1898
tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
1899
-
if err != nil {
1900
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1901
-
log.Println("failed to call XRPC repo.tags", xrpcerr)
1902
-
rp.pages.Error503(w)
1903
-
return
1904
-
}
1905
-
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1906
return
1907
}
1908
···
1914
}
1915
1916
compareBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, base, head)
1917
-
if err != nil {
1918
-
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1919
-
log.Println("failed to call XRPC repo.compare", xrpcerr)
1920
-
rp.pages.Error503(w)
1921
-
return
1922
-
}
1923
-
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1924
return
1925
}
1926
···
11
"log/slog"
12
"net/http"
13
"net/url"
14
"path/filepath"
15
"slices"
16
"strconv"
···
20
comatproto "github.com/bluesky-social/indigo/api/atproto"
21
lexutil "github.com/bluesky-social/indigo/lex/util"
22
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
23
+
"tangled.org/core/api/tangled"
24
+
"tangled.org/core/appview/commitverify"
25
+
"tangled.org/core/appview/config"
26
+
"tangled.org/core/appview/db"
27
+
"tangled.org/core/appview/models"
28
+
"tangled.org/core/appview/notify"
29
+
"tangled.org/core/appview/oauth"
30
+
"tangled.org/core/appview/pages"
31
+
"tangled.org/core/appview/pages/markup"
32
+
"tangled.org/core/appview/reporesolver"
33
+
"tangled.org/core/appview/validator"
34
+
xrpcclient "tangled.org/core/appview/xrpcclient"
35
+
"tangled.org/core/eventconsumer"
36
+
"tangled.org/core/idresolver"
37
+
"tangled.org/core/patchutil"
38
+
"tangled.org/core/rbac"
39
+
"tangled.org/core/tid"
40
+
"tangled.org/core/types"
41
+
"tangled.org/core/xrpc/serviceauth"
42
43
securejoin "github.com/cyphar/filepath-securejoin"
44
"github.com/go-chi/chi/v5"
···
59
notifier notify.Notifier
60
logger *slog.Logger
61
serviceAuth *serviceauth.ServiceAuth
62
+
validator *validator.Validator
63
}
64
65
func New(
···
73
notifier notify.Notifier,
74
enforcer *rbac.Enforcer,
75
logger *slog.Logger,
76
+
validator *validator.Validator,
77
) *Repo {
78
return &Repo{oauth: oauth,
79
repoResolver: repoResolver,
···
85
notifier: notifier,
86
enforcer: enforcer,
87
logger: logger,
88
+
validator: validator,
89
}
90
}
91
92
func (rp *Repo) DownloadArchive(w http.ResponseWriter, r *http.Request) {
93
+
ref := chi.URLParam(r, "ref")
94
+
ref, _ = url.PathUnescape(ref)
95
+
96
f, err := rp.repoResolver.Resolve(r)
97
if err != nil {
98
log.Println("failed to get repo and knot", err)
···
109
}
110
111
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
112
+
archiveBytes, err := tangled.RepoArchive(r.Context(), xrpcc, "tar.gz", "", ref, repo)
113
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
114
+
log.Println("failed to call XRPC repo.archive", xrpcerr)
115
+
rp.pages.Error503(w)
116
return
117
}
118
119
+
// Set headers for file download, just pass along whatever the knot specifies
120
+
safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(ref).Short(), "/", "-")
121
+
filename := fmt.Sprintf("%s-%s.tar.gz", f.Name, safeRefFilename)
122
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
123
w.Header().Set("Content-Type", "application/gzip")
124
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(archiveBytes)))
···
143
}
144
145
ref := chi.URLParam(r, "ref")
146
+
ref, _ = url.PathUnescape(ref)
147
148
scheme := "http"
149
if !rp.config.Core.Dev {
···
164
165
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
166
xrpcBytes, err := tangled.RepoLog(r.Context(), xrpcc, cursor, limit, "", ref, repo)
167
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
168
+
log.Println("failed to call XRPC repo.log", xrpcerr)
169
+
rp.pages.Error503(w)
170
return
171
}
172
···
178
}
179
180
tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
181
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
182
+
log.Println("failed to call XRPC repo.tags", xrpcerr)
183
+
rp.pages.Error503(w)
184
+
return
185
}
186
187
tagMap := make(map[string][]string)
···
195
}
196
197
branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
198
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
199
+
log.Println("failed to call XRPC repo.branches", xrpcerr)
200
+
rp.pages.Error503(w)
201
+
return
202
}
203
204
if branchBytes != nil {
···
300
return
301
}
302
303
+
newRepo := f.Repo
304
+
newRepo.Description = newDescription
305
+
record := newRepo.AsRecord()
306
+
307
// this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field
308
//
309
// SwapRecord is optional and should happen automagically, but given that it does not, we have to perform two requests
310
+
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
311
if err != nil {
312
// failed to get record
313
rp.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.")
···
315
}
316
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
317
Collection: tangled.RepoNSID,
318
+
Repo: newRepo.Did,
319
+
Rkey: newRepo.Rkey,
320
SwapRecord: ex.Cid,
321
Record: &lexutil.LexiconTypeDecoder{
322
+
Val: &record,
323
},
324
})
325
···
347
return
348
}
349
ref := chi.URLParam(r, "ref")
350
+
ref, _ = url.PathUnescape(ref)
351
352
var diffOpts types.DiffOpts
353
if d := r.URL.Query().Get("diff"); d == "split" {
···
370
371
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
372
xrpcBytes, err := tangled.RepoDiff(r.Context(), xrpcc, ref, repo)
373
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
374
+
log.Println("failed to call XRPC repo.diff", xrpcerr)
375
+
rp.pages.Error503(w)
376
return
377
}
378
···
400
log.Println(err)
401
// non-fatal
402
}
403
+
var pipeline *models.Pipeline
404
if p, ok := pipelines[result.Diff.Commit.This]; ok {
405
pipeline = &p
406
}
···
424
}
425
426
ref := chi.URLParam(r, "ref")
427
+
ref, _ = url.PathUnescape(ref)
428
429
// if the tree path has a trailing slash, let's strip it
430
// so we don't 404
431
+
treePath := chi.URLParam(r, "*")
432
+
treePath, _ = url.PathUnescape(treePath)
433
treePath = strings.TrimSuffix(treePath, "/")
434
435
scheme := "http"
···
443
444
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
445
xrpcResp, err := tangled.RepoTree(r.Context(), xrpcc, treePath, ref, repo)
446
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
447
+
log.Println("failed to call XRPC repo.tree", xrpcerr)
448
+
rp.pages.Error503(w)
449
return
450
}
451
···
484
if xrpcResp.Dotdot != nil {
485
result.DotDot = *xrpcResp.Dotdot
486
}
487
+
if xrpcResp.Readme != nil {
488
+
result.ReadmeFileName = xrpcResp.Readme.Filename
489
+
result.Readme = xrpcResp.Readme.Contents
490
+
}
491
492
// redirects tree paths trying to access a blob; in this case the result.Files is unpopulated,
493
// so we can safely redirect to the "parent" (which is the same file).
494
+
if len(result.Files) == 0 && result.Parent == treePath {
495
+
redirectTo := fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), url.PathEscape(ref), result.Parent)
496
+
http.Redirect(w, r, redirectTo, http.StatusFound)
497
return
498
}
499
500
user := rp.oauth.GetUser(r)
501
502
var breadcrumbs [][]string
503
+
breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))})
504
if treePath != "" {
505
for idx, elem := range strings.Split(treePath, "/") {
506
+
breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))})
507
}
508
}
509
···
536
537
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
538
xrpcBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
539
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
540
+
log.Println("failed to call XRPC repo.tags", xrpcerr)
541
+
rp.pages.Error503(w)
542
return
543
}
544
···
556
}
557
558
// convert artifacts to map for easy UI building
559
+
artifactMap := make(map[plumbing.Hash][]models.Artifact)
560
for _, a := range artifacts {
561
artifactMap[a.Tag] = append(artifactMap[a.Tag], a)
562
}
563
564
+
var danglingArtifacts []models.Artifact
565
for _, a := range artifacts {
566
found := false
567
for _, t := range result.Tags {
···
605
606
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
607
xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
608
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
609
+
log.Println("failed to call XRPC repo.branches", xrpcerr)
610
+
rp.pages.Error503(w)
611
return
612
}
613
···
636
}
637
638
ref := chi.URLParam(r, "ref")
639
+
ref, _ = url.PathUnescape(ref)
640
+
641
filePath := chi.URLParam(r, "*")
642
+
filePath, _ = url.PathUnescape(filePath)
643
644
scheme := "http"
645
if !rp.config.Core.Dev {
···
652
653
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name)
654
resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, repo)
655
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
656
+
log.Println("failed to call XRPC repo.blob", xrpcerr)
657
+
rp.pages.Error503(w)
658
return
659
}
660
661
// Use XRPC response directly instead of converting to internal types
662
663
var breadcrumbs [][]string
664
+
breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))})
665
if filePath != "" {
666
for idx, elem := range strings.Split(filePath, "/") {
667
+
breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))})
668
}
669
}
670
···
693
}
694
695
// fetch the raw binary content using sh.tangled.repo.blob xrpc
696
+
repoName := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
697
+
698
+
baseURL := &url.URL{
699
+
Scheme: scheme,
700
+
Host: f.Knot,
701
+
Path: "/xrpc/sh.tangled.repo.blob",
702
+
}
703
+
query := baseURL.Query()
704
+
query.Set("repo", repoName)
705
+
query.Set("ref", ref)
706
+
query.Set("path", filePath)
707
+
query.Set("raw", "true")
708
+
baseURL.RawQuery = query.Encode()
709
+
blobURL := baseURL.String()
710
711
contentSrc = blobURL
712
if !rp.config.Core.Dev {
···
761
}
762
763
ref := chi.URLParam(r, "ref")
764
+
ref, _ = url.PathUnescape(ref)
765
+
766
filePath := chi.URLParam(r, "*")
767
+
filePath, _ = url.PathUnescape(filePath)
768
769
scheme := "http"
770
if !rp.config.Core.Dev {
···
772
}
773
774
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name)
775
+
baseURL := &url.URL{
776
+
Scheme: scheme,
777
+
Host: f.Knot,
778
+
Path: "/xrpc/sh.tangled.repo.blob",
779
+
}
780
+
query := baseURL.Query()
781
+
query.Set("repo", repo)
782
+
query.Set("ref", ref)
783
+
query.Set("path", filePath)
784
+
query.Set("raw", "true")
785
+
baseURL.RawQuery = query.Encode()
786
+
blobURL := baseURL.String()
787
788
req, err := http.NewRequest("GET", blobURL, nil)
789
if err != nil {
···
877
return
878
}
879
880
newSpindle := r.FormValue("spindle")
881
removingSpindle := newSpindle == "[[none]]" // see pages/templates/repo/settings/pipelines.html for more info on why we use this value
882
client, err := rp.oauth.AuthorizedClient(r)
···
898
return
899
}
900
}
901
+
902
+
newRepo := f.Repo
903
+
newRepo.Spindle = newSpindle
904
+
record := newRepo.AsRecord()
905
906
spindlePtr := &newSpindle
907
if removingSpindle {
908
spindlePtr = nil
909
+
newRepo.Spindle = ""
910
}
911
912
// optimistic update
913
+
err = db.UpdateSpindle(rp.db, newRepo.RepoAt().String(), spindlePtr)
914
if err != nil {
915
fail("Failed to update spindle. Try again later.", err)
916
return
917
}
918
919
+
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
920
if err != nil {
921
fail("Failed to update spindle, no record found on PDS.", err)
922
return
923
}
924
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
925
Collection: tangled.RepoNSID,
926
+
Repo: newRepo.Did,
927
+
Rkey: newRepo.Rkey,
928
SwapRecord: ex.Cid,
929
Record: &lexutil.LexiconTypeDecoder{
930
+
Val: &record,
931
},
932
})
933
···
947
rp.pages.HxRefresh(w)
948
}
949
950
+
func (rp *Repo) AddLabelDef(w http.ResponseWriter, r *http.Request) {
951
+
user := rp.oauth.GetUser(r)
952
+
l := rp.logger.With("handler", "AddLabel")
953
+
l = l.With("did", user.Did)
954
+
l = l.With("handle", user.Handle)
955
+
956
+
f, err := rp.repoResolver.Resolve(r)
957
+
if err != nil {
958
+
l.Error("failed to get repo and knot", "err", err)
959
+
return
960
+
}
961
+
962
+
errorId := "add-label-error"
963
+
fail := func(msg string, err error) {
964
+
l.Error(msg, "err", err)
965
+
rp.pages.Notice(w, errorId, msg)
966
+
}
967
+
968
+
// get form values for label definition
969
+
name := r.FormValue("name")
970
+
concreteType := r.FormValue("valueType")
971
+
valueFormat := r.FormValue("valueFormat")
972
+
enumValues := r.FormValue("enumValues")
973
+
scope := r.Form["scope"]
974
+
color := r.FormValue("color")
975
+
multiple := r.FormValue("multiple") == "true"
976
+
977
+
var variants []string
978
+
for part := range strings.SplitSeq(enumValues, ",") {
979
+
if part = strings.TrimSpace(part); part != "" {
980
+
variants = append(variants, part)
981
+
}
982
+
}
983
+
984
+
if concreteType == "" {
985
+
concreteType = "null"
986
+
}
987
+
988
+
format := models.ValueTypeFormatAny
989
+
if valueFormat == "did" {
990
+
format = models.ValueTypeFormatDid
991
+
}
992
+
993
+
valueType := models.ValueType{
994
+
Type: models.ConcreteType(concreteType),
995
+
Format: format,
996
+
Enum: variants,
997
+
}
998
+
999
+
label := models.LabelDefinition{
1000
+
Did: user.Did,
1001
+
Rkey: tid.TID(),
1002
+
Name: name,
1003
+
ValueType: valueType,
1004
+
Scope: scope,
1005
+
Color: &color,
1006
+
Multiple: multiple,
1007
+
Created: time.Now(),
1008
+
}
1009
+
if err := rp.validator.ValidateLabelDefinition(&label); err != nil {
1010
+
fail(err.Error(), err)
1011
+
return
1012
+
}
1013
+
1014
+
// announce this relation into the firehose, store into owners' pds
1015
+
client, err := rp.oauth.AuthorizedClient(r)
1016
+
if err != nil {
1017
+
fail(err.Error(), err)
1018
+
return
1019
+
}
1020
+
1021
+
// emit a labelRecord
1022
+
labelRecord := label.AsRecord()
1023
+
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1024
+
Collection: tangled.LabelDefinitionNSID,
1025
+
Repo: label.Did,
1026
+
Rkey: label.Rkey,
1027
+
Record: &lexutil.LexiconTypeDecoder{
1028
+
Val: &labelRecord,
1029
+
},
1030
+
})
1031
+
// invalid record
1032
+
if err != nil {
1033
+
fail("Failed to write record to PDS.", err)
1034
+
return
1035
+
}
1036
+
1037
+
aturi := resp.Uri
1038
+
l = l.With("at-uri", aturi)
1039
+
l.Info("wrote label record to PDS")
1040
+
1041
+
// update the repo to subscribe to this label
1042
+
newRepo := f.Repo
1043
+
newRepo.Labels = append(newRepo.Labels, aturi)
1044
+
repoRecord := newRepo.AsRecord()
1045
+
1046
+
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
1047
+
if err != nil {
1048
+
fail("Failed to update labels, no record found on PDS.", err)
1049
+
return
1050
+
}
1051
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1052
+
Collection: tangled.RepoNSID,
1053
+
Repo: newRepo.Did,
1054
+
Rkey: newRepo.Rkey,
1055
+
SwapRecord: ex.Cid,
1056
+
Record: &lexutil.LexiconTypeDecoder{
1057
+
Val: &repoRecord,
1058
+
},
1059
+
})
1060
+
if err != nil {
1061
+
fail("Failed to update labels for repo.", err)
1062
+
return
1063
+
}
1064
+
1065
+
tx, err := rp.db.BeginTx(r.Context(), nil)
1066
+
if err != nil {
1067
+
fail("Failed to add label.", err)
1068
+
return
1069
+
}
1070
+
1071
+
rollback := func() {
1072
+
err1 := tx.Rollback()
1073
+
err2 := rollbackRecord(context.Background(), aturi, client)
1074
+
1075
+
// ignore txn complete errors, this is okay
1076
+
if errors.Is(err1, sql.ErrTxDone) {
1077
+
err1 = nil
1078
+
}
1079
+
1080
+
if errs := errors.Join(err1, err2); errs != nil {
1081
+
l.Error("failed to rollback changes", "errs", errs)
1082
+
return
1083
+
}
1084
+
}
1085
+
defer rollback()
1086
+
1087
+
_, err = db.AddLabelDefinition(tx, &label)
1088
+
if err != nil {
1089
+
fail("Failed to add label.", err)
1090
+
return
1091
+
}
1092
+
1093
+
err = db.SubscribeLabel(tx, &models.RepoLabel{
1094
+
RepoAt: f.RepoAt(),
1095
+
LabelAt: label.AtUri(),
1096
+
})
1097
+
1098
+
err = tx.Commit()
1099
+
if err != nil {
1100
+
fail("Failed to add label.", err)
1101
+
return
1102
+
}
1103
+
1104
+
// clear aturi when everything is successful
1105
+
aturi = ""
1106
+
1107
+
rp.pages.HxRefresh(w)
1108
+
}
1109
+
1110
+
func (rp *Repo) DeleteLabelDef(w http.ResponseWriter, r *http.Request) {
1111
+
user := rp.oauth.GetUser(r)
1112
+
l := rp.logger.With("handler", "DeleteLabel")
1113
+
l = l.With("did", user.Did)
1114
+
l = l.With("handle", user.Handle)
1115
+
1116
+
f, err := rp.repoResolver.Resolve(r)
1117
+
if err != nil {
1118
+
l.Error("failed to get repo and knot", "err", err)
1119
+
return
1120
+
}
1121
+
1122
+
errorId := "label-operation"
1123
+
fail := func(msg string, err error) {
1124
+
l.Error(msg, "err", err)
1125
+
rp.pages.Notice(w, errorId, msg)
1126
+
}
1127
+
1128
+
// get form values
1129
+
labelId := r.FormValue("label-id")
1130
+
1131
+
label, err := db.GetLabelDefinition(rp.db, db.FilterEq("id", labelId))
1132
+
if err != nil {
1133
+
fail("Failed to find label definition.", err)
1134
+
return
1135
+
}
1136
+
1137
+
client, err := rp.oauth.AuthorizedClient(r)
1138
+
if err != nil {
1139
+
fail(err.Error(), err)
1140
+
return
1141
+
}
1142
+
1143
+
// delete label record from PDS
1144
+
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
1145
+
Collection: tangled.LabelDefinitionNSID,
1146
+
Repo: label.Did,
1147
+
Rkey: label.Rkey,
1148
+
})
1149
+
if err != nil {
1150
+
fail("Failed to delete label record from PDS.", err)
1151
+
return
1152
+
}
1153
+
1154
+
// update repo record to remove the label reference
1155
+
newRepo := f.Repo
1156
+
var updated []string
1157
+
removedAt := label.AtUri().String()
1158
+
for _, l := range newRepo.Labels {
1159
+
if l != removedAt {
1160
+
updated = append(updated, l)
1161
+
}
1162
+
}
1163
+
newRepo.Labels = updated
1164
+
repoRecord := newRepo.AsRecord()
1165
+
1166
+
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
1167
+
if err != nil {
1168
+
fail("Failed to update labels, no record found on PDS.", err)
1169
+
return
1170
+
}
1171
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1172
+
Collection: tangled.RepoNSID,
1173
+
Repo: newRepo.Did,
1174
+
Rkey: newRepo.Rkey,
1175
+
SwapRecord: ex.Cid,
1176
+
Record: &lexutil.LexiconTypeDecoder{
1177
+
Val: &repoRecord,
1178
+
},
1179
+
})
1180
+
if err != nil {
1181
+
fail("Failed to update repo record.", err)
1182
+
return
1183
+
}
1184
+
1185
+
// transaction for DB changes
1186
+
tx, err := rp.db.BeginTx(r.Context(), nil)
1187
+
if err != nil {
1188
+
fail("Failed to delete label.", err)
1189
+
return
1190
+
}
1191
+
defer tx.Rollback()
1192
+
1193
+
err = db.UnsubscribeLabel(
1194
+
tx,
1195
+
db.FilterEq("repo_at", f.RepoAt()),
1196
+
db.FilterEq("label_at", removedAt),
1197
+
)
1198
+
if err != nil {
1199
+
fail("Failed to unsubscribe label.", err)
1200
+
return
1201
+
}
1202
+
1203
+
err = db.DeleteLabelDefinition(tx, db.FilterEq("id", label.Id))
1204
+
if err != nil {
1205
+
fail("Failed to delete label definition.", err)
1206
+
return
1207
+
}
1208
+
1209
+
err = tx.Commit()
1210
+
if err != nil {
1211
+
fail("Failed to delete label.", err)
1212
+
return
1213
+
}
1214
+
1215
+
// everything succeeded
1216
+
rp.pages.HxRefresh(w)
1217
+
}
1218
+
1219
+
func (rp *Repo) SubscribeLabel(w http.ResponseWriter, r *http.Request) {
1220
+
user := rp.oauth.GetUser(r)
1221
+
l := rp.logger.With("handler", "SubscribeLabel")
1222
+
l = l.With("did", user.Did)
1223
+
l = l.With("handle", user.Handle)
1224
+
1225
+
f, err := rp.repoResolver.Resolve(r)
1226
+
if err != nil {
1227
+
l.Error("failed to get repo and knot", "err", err)
1228
+
return
1229
+
}
1230
+
1231
+
if err := r.ParseForm(); err != nil {
1232
+
l.Error("invalid form", "err", err)
1233
+
return
1234
+
}
1235
+
1236
+
errorId := "default-label-operation"
1237
+
fail := func(msg string, err error) {
1238
+
l.Error(msg, "err", err)
1239
+
rp.pages.Notice(w, errorId, msg)
1240
+
}
1241
+
1242
+
labelAts := r.Form["label"]
1243
+
_, err = db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", labelAts))
1244
+
if err != nil {
1245
+
fail("Failed to subscribe to label.", err)
1246
+
return
1247
+
}
1248
+
1249
+
newRepo := f.Repo
1250
+
newRepo.Labels = append(newRepo.Labels, labelAts...)
1251
+
1252
+
// dedup
1253
+
slices.Sort(newRepo.Labels)
1254
+
newRepo.Labels = slices.Compact(newRepo.Labels)
1255
+
1256
+
repoRecord := newRepo.AsRecord()
1257
+
1258
+
client, err := rp.oauth.AuthorizedClient(r)
1259
+
if err != nil {
1260
+
fail(err.Error(), err)
1261
+
return
1262
+
}
1263
+
1264
+
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey)
1265
+
if err != nil {
1266
+
fail("Failed to update labels, no record found on PDS.", err)
1267
+
return
1268
+
}
1269
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1270
+
Collection: tangled.RepoNSID,
1271
+
Repo: newRepo.Did,
1272
+
Rkey: newRepo.Rkey,
1273
+
SwapRecord: ex.Cid,
1274
+
Record: &lexutil.LexiconTypeDecoder{
1275
+
Val: &repoRecord,
1276
+
},
1277
+
})
1278
+
1279
+
tx, err := rp.db.Begin()
1280
+
if err != nil {
1281
+
fail("Failed to subscribe to label.", err)
1282
+
return
1283
+
}
1284
+
defer tx.Rollback()
1285
+
1286
+
for _, l := range labelAts {
1287
+
err = db.SubscribeLabel(tx, &models.RepoLabel{
1288
+
RepoAt: f.RepoAt(),
1289
+
LabelAt: syntax.ATURI(l),
1290
+
})
1291
+
if err != nil {
1292
+
fail("Failed to subscribe to label.", err)
1293
+
return
1294
+
}
1295
+
}
1296
+
1297
+
if err := tx.Commit(); err != nil {
1298
+
fail("Failed to subscribe to label.", err)
1299
+
return
1300
+
}
1301
+
1302
+
// everything succeeded
1303
+
rp.pages.HxRefresh(w)
1304
+
}
1305
+
1306
+
func (rp *Repo) UnsubscribeLabel(w http.ResponseWriter, r *http.Request) {
1307
+
user := rp.oauth.GetUser(r)
1308
+
l := rp.logger.With("handler", "UnsubscribeLabel")
1309
+
l = l.With("did", user.Did)
1310
+
l = l.With("handle", user.Handle)
1311
+
1312
+
f, err := rp.repoResolver.Resolve(r)
1313
+
if err != nil {
1314
+
l.Error("failed to get repo and knot", "err", err)
1315
+
return
1316
+
}
1317
+
1318
+
if err := r.ParseForm(); err != nil {
1319
+
l.Error("invalid form", "err", err)
1320
+
return
1321
+
}
1322
+
1323
+
errorId := "default-label-operation"
1324
+
fail := func(msg string, err error) {
1325
+
l.Error(msg, "err", err)
1326
+
rp.pages.Notice(w, errorId, msg)
1327
+
}
1328
+
1329
+
labelAts := r.Form["label"]
1330
+
_, err = db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", labelAts))
1331
+
if err != nil {
1332
+
fail("Failed to unsubscribe to label.", err)
1333
+
return
1334
+
}
1335
+
1336
+
// update repo record to remove the label reference
1337
+
newRepo := f.Repo
1338
+
var updated []string
1339
+
for _, l := range newRepo.Labels {
1340
+
if !slices.Contains(labelAts, l) {
1341
+
updated = append(updated, l)
1342
+
}
1343
+
}
1344
+
newRepo.Labels = updated
1345
+
repoRecord := newRepo.AsRecord()
1346
+
1347
+
client, err := rp.oauth.AuthorizedClient(r)
1348
+
if err != nil {
1349
+
fail(err.Error(), err)
1350
+
return
1351
+
}
1352
+
1353
+
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey)
1354
+
if err != nil {
1355
+
fail("Failed to update labels, no record found on PDS.", err)
1356
+
return
1357
+
}
1358
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1359
+
Collection: tangled.RepoNSID,
1360
+
Repo: newRepo.Did,
1361
+
Rkey: newRepo.Rkey,
1362
+
SwapRecord: ex.Cid,
1363
+
Record: &lexutil.LexiconTypeDecoder{
1364
+
Val: &repoRecord,
1365
+
},
1366
+
})
1367
+
1368
+
err = db.UnsubscribeLabel(
1369
+
rp.db,
1370
+
db.FilterEq("repo_at", f.RepoAt()),
1371
+
db.FilterIn("label_at", labelAts),
1372
+
)
1373
+
if err != nil {
1374
+
fail("Failed to unsubscribe label.", err)
1375
+
return
1376
+
}
1377
+
1378
+
// everything succeeded
1379
+
rp.pages.HxRefresh(w)
1380
+
}
1381
+
1382
+
func (rp *Repo) LabelPanel(w http.ResponseWriter, r *http.Request) {
1383
+
l := rp.logger.With("handler", "LabelPanel")
1384
+
1385
+
f, err := rp.repoResolver.Resolve(r)
1386
+
if err != nil {
1387
+
l.Error("failed to get repo and knot", "err", err)
1388
+
return
1389
+
}
1390
+
1391
+
subjectStr := r.FormValue("subject")
1392
+
subject, err := syntax.ParseATURI(subjectStr)
1393
+
if err != nil {
1394
+
l.Error("failed to get repo and knot", "err", err)
1395
+
return
1396
+
}
1397
+
1398
+
labelDefs, err := db.GetLabelDefinitions(
1399
+
rp.db,
1400
+
db.FilterIn("at_uri", f.Repo.Labels),
1401
+
db.FilterContains("scope", subject.Collection().String()),
1402
+
)
1403
+
if err != nil {
1404
+
log.Println("failed to fetch label defs", err)
1405
+
return
1406
+
}
1407
+
1408
+
defs := make(map[string]*models.LabelDefinition)
1409
+
for _, l := range labelDefs {
1410
+
defs[l.AtUri().String()] = &l
1411
+
}
1412
+
1413
+
states, err := db.GetLabels(rp.db, db.FilterEq("subject", subject))
1414
+
if err != nil {
1415
+
log.Println("failed to build label state", err)
1416
+
return
1417
+
}
1418
+
state := states[subject]
1419
+
1420
+
user := rp.oauth.GetUser(r)
1421
+
rp.pages.LabelPanel(w, pages.LabelPanelParams{
1422
+
LoggedInUser: user,
1423
+
RepoInfo: f.RepoInfo(user),
1424
+
Defs: defs,
1425
+
Subject: subject.String(),
1426
+
State: state,
1427
+
})
1428
+
}
1429
+
1430
+
func (rp *Repo) EditLabelPanel(w http.ResponseWriter, r *http.Request) {
1431
+
l := rp.logger.With("handler", "EditLabelPanel")
1432
+
1433
+
f, err := rp.repoResolver.Resolve(r)
1434
+
if err != nil {
1435
+
l.Error("failed to get repo and knot", "err", err)
1436
+
return
1437
+
}
1438
+
1439
+
subjectStr := r.FormValue("subject")
1440
+
subject, err := syntax.ParseATURI(subjectStr)
1441
+
if err != nil {
1442
+
l.Error("failed to get repo and knot", "err", err)
1443
+
return
1444
+
}
1445
+
1446
+
labelDefs, err := db.GetLabelDefinitions(
1447
+
rp.db,
1448
+
db.FilterIn("at_uri", f.Repo.Labels),
1449
+
db.FilterContains("scope", subject.Collection().String()),
1450
+
)
1451
+
if err != nil {
1452
+
log.Println("failed to fetch labels", err)
1453
+
return
1454
+
}
1455
+
1456
+
defs := make(map[string]*models.LabelDefinition)
1457
+
for _, l := range labelDefs {
1458
+
defs[l.AtUri().String()] = &l
1459
+
}
1460
+
1461
+
states, err := db.GetLabels(rp.db, db.FilterEq("subject", subject))
1462
+
if err != nil {
1463
+
log.Println("failed to build label state", err)
1464
+
return
1465
+
}
1466
+
state := states[subject]
1467
+
1468
+
user := rp.oauth.GetUser(r)
1469
+
rp.pages.EditLabelPanel(w, pages.EditLabelPanelParams{
1470
+
LoggedInUser: user,
1471
+
RepoInfo: f.RepoInfo(user),
1472
+
Defs: defs,
1473
+
Subject: subject.String(),
1474
+
State: state,
1475
+
})
1476
+
}
1477
+
1478
func (rp *Repo) AddCollaborator(w http.ResponseWriter, r *http.Request) {
1479
user := rp.oauth.GetUser(r)
1480
l := rp.logger.With("handler", "AddCollaborator")
···
1576
return
1577
}
1578
1579
+
err = db.AddCollaborator(tx, models.Collaborator{
1580
Did: syntax.DID(currentUser.Did),
1581
Rkey: rkey,
1582
SubjectDid: collaboratorIdent.DID,
···
1891
1892
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
1893
xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
1894
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1895
+
log.Println("failed to call XRPC repo.branches", xrpcerr)
1896
rp.pages.Error503(w)
1897
return
1898
}
···
1904
return
1905
}
1906
1907
+
defaultLabels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", models.DefaultLabelDefs()))
1908
+
if err != nil {
1909
+
log.Println("failed to fetch labels", err)
1910
+
rp.pages.Error503(w)
1911
+
return
1912
+
}
1913
+
1914
+
labels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Repo.Labels))
1915
+
if err != nil {
1916
+
log.Println("failed to fetch labels", err)
1917
+
rp.pages.Error503(w)
1918
+
return
1919
+
}
1920
+
// remove default labels from the labels list, if present
1921
+
defaultLabelMap := make(map[string]bool)
1922
+
for _, dl := range defaultLabels {
1923
+
defaultLabelMap[dl.AtUri().String()] = true
1924
+
}
1925
+
n := 0
1926
+
for _, l := range labels {
1927
+
if !defaultLabelMap[l.AtUri().String()] {
1928
+
labels[n] = l
1929
+
n++
1930
+
}
1931
+
}
1932
+
labels = labels[:n]
1933
+
1934
+
subscribedLabels := make(map[string]struct{})
1935
+
for _, l := range f.Repo.Labels {
1936
+
subscribedLabels[l] = struct{}{}
1937
+
}
1938
+
1939
+
// if there is atleast 1 unsubbed default label, show the "subscribe all" button,
1940
+
// if all default labels are subbed, show the "unsubscribe all" button
1941
+
shouldSubscribeAll := false
1942
+
for _, dl := range defaultLabels {
1943
+
if _, ok := subscribedLabels[dl.AtUri().String()]; !ok {
1944
+
// one of the default labels is not subscribed to
1945
+
shouldSubscribeAll = true
1946
+
break
1947
+
}
1948
+
}
1949
+
1950
rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{
1951
+
LoggedInUser: user,
1952
+
RepoInfo: f.RepoInfo(user),
1953
+
Branches: result.Branches,
1954
+
Labels: labels,
1955
+
DefaultLabels: defaultLabels,
1956
+
SubscribedLabels: subscribedLabels,
1957
+
ShouldSubscribeAll: shouldSubscribeAll,
1958
+
Tabs: settingsTabs,
1959
+
Tab: "general",
1960
})
1961
}
1962
···
2041
2042
func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) {
2043
ref := chi.URLParam(r, "ref")
2044
+
ref, _ = url.PathUnescape(ref)
2045
2046
user := rp.oauth.GetUser(r)
2047
f, err := rp.repoResolver.Resolve(r)
···
2129
}
2130
2131
// choose a name for a fork
2132
+
forkName := r.FormValue("repo_name")
2133
+
if forkName == "" {
2134
+
rp.pages.Notice(w, "repo", "Repository name cannot be empty.")
2135
+
return
2136
+
}
2137
+
2138
// this check is *only* to see if the forked repo name already exists
2139
// in the user's account.
2140
+
existingRepo, err := db.GetRepo(
2141
+
rp.db,
2142
+
db.FilterEq("did", user.Did),
2143
+
db.FilterEq("name", forkName),
2144
+
)
2145
if err != nil {
2146
+
if !errors.Is(err, sql.ErrNoRows) {
2147
+
log.Println("error fetching existing repo from db", "err", err)
2148
rp.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.")
2149
return
2150
}
2151
} else if existingRepo != nil {
2152
+
// repo with this name already exists
2153
+
rp.pages.Notice(w, "repo", "A repository with this name already exists.")
2154
+
return
2155
}
2156
l = l.With("forkName", forkName)
2157
···
2167
2168
// create an atproto record for this fork
2169
rkey := tid.TID()
2170
+
repo := &models.Repo{
2171
+
Did: user.Did,
2172
+
Name: forkName,
2173
+
Knot: targetKnot,
2174
+
Rkey: rkey,
2175
+
Source: sourceAt,
2176
+
Description: f.Repo.Description,
2177
+
Created: time.Now(),
2178
+
Labels: models.DefaultLabelDefs(),
2179
}
2180
+
record := repo.AsRecord()
2181
2182
xrpcClient, err := rp.oauth.AuthorizedClient(r)
2183
if err != nil {
···
2186
return
2187
}
2188
2189
atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
2190
Collection: tangled.RepoNSID,
2191
Repo: user.Did,
2192
Rkey: rkey,
2193
Record: &lexutil.LexiconTypeDecoder{
2194
+
Val: &record,
2195
+
},
2196
})
2197
if err != nil {
2198
l.Error("failed to write to PDS", "err", err)
···
2336
2337
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
2338
branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
2339
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
2340
+
log.Println("failed to call XRPC repo.branches", xrpcerr)
2341
+
rp.pages.Error503(w)
2342
return
2343
}
2344
···
2373
}
2374
2375
tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
2376
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
2377
+
log.Println("failed to call XRPC repo.tags", xrpcerr)
2378
+
rp.pages.Error503(w)
2379
return
2380
}
2381
···
2446
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
2447
2448
branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
2449
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
2450
+
log.Println("failed to call XRPC repo.branches", xrpcerr)
2451
+
rp.pages.Error503(w)
2452
return
2453
}
2454
···
2460
}
2461
2462
tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
2463
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
2464
+
log.Println("failed to call XRPC repo.tags", xrpcerr)
2465
+
rp.pages.Error503(w)
2466
return
2467
}
2468
···
2474
}
2475
2476
compareBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, base, head)
2477
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
2478
+
log.Println("failed to call XRPC repo.compare", xrpcerr)
2479
+
rp.pages.Error503(w)
2480
return
2481
}
2482
+6
-5
appview/repo/repo_util.go
+6
-5
appview/repo/repo_util.go
···
9
"sort"
10
"strings"
11
12
-
"tangled.sh/tangled.sh/core/appview/db"
13
-
"tangled.sh/tangled.sh/core/appview/pages/repoinfo"
14
-
"tangled.sh/tangled.sh/core/types"
15
16
"github.com/go-git/go-git/v5/plumbing/object"
17
)
···
143
d *db.DB,
144
repoInfo repoinfo.RepoInfo,
145
shas []string,
146
-
) (map[string]db.Pipeline, error) {
147
-
m := make(map[string]db.Pipeline)
148
149
if len(shas) == 0 {
150
return m, nil
···
9
"sort"
10
"strings"
11
12
+
"tangled.org/core/appview/db"
13
+
"tangled.org/core/appview/models"
14
+
"tangled.org/core/appview/pages/repoinfo"
15
+
"tangled.org/core/types"
16
17
"github.com/go-git/go-git/v5/plumbing/object"
18
)
···
144
d *db.DB,
145
repoInfo repoinfo.RepoInfo,
146
shas []string,
147
+
) (map[string]models.Pipeline, error) {
148
+
m := make(map[string]models.Pipeline)
149
150
if len(shas) == 0 {
151
return m, nil
+13
-4
appview/repo/router.go
+13
-4
appview/repo/router.go
···
4
"net/http"
5
6
"github.com/go-chi/chi/v5"
7
-
"tangled.sh/tangled.sh/core/appview/middleware"
8
)
9
10
func (rp *Repo) Router(mw *middleware.Middleware) http.Handler {
···
21
r.Route("/tags", func(r chi.Router) {
22
r.Get("/", rp.RepoTags)
23
r.Route("/{tag}", func(r chi.Router) {
24
-
r.Use(middleware.AuthMiddleware(rp.oauth))
25
-
// require auth to download for now
26
r.Get("/download/{file}", rp.DownloadArtifact)
27
28
// require repo:push to upload or delete artifacts
···
30
// additionally: only the uploader can truly delete an artifact
31
// (record+blob will live on their pds)
32
r.Group(func(r chi.Router) {
33
-
r.With(mw.RepoPermissionMiddleware("repo:push"))
34
r.Post("/upload", rp.AttachArtifact)
35
r.Delete("/{file}", rp.DeleteArtifact)
36
})
···
64
r.Get("/*", rp.RepoCompare)
65
})
66
67
// settings routes, needs auth
68
r.Group(func(r chi.Router) {
69
r.Use(middleware.AuthMiddleware(rp.oauth))
···
76
r.With(mw.RepoPermissionMiddleware("repo:settings")).Route("/settings", func(r chi.Router) {
77
r.Get("/", rp.RepoSettings)
78
r.With(mw.RepoPermissionMiddleware("repo:owner")).Post("/spindle", rp.EditSpindle)
79
r.With(mw.RepoPermissionMiddleware("repo:invite")).Put("/collaborator", rp.AddCollaborator)
80
r.With(mw.RepoPermissionMiddleware("repo:delete")).Delete("/delete", rp.DeleteRepo)
81
r.Put("/branches/default", rp.SetDefaultBranch)
···
4
"net/http"
5
6
"github.com/go-chi/chi/v5"
7
+
"tangled.org/core/appview/middleware"
8
)
9
10
func (rp *Repo) Router(mw *middleware.Middleware) http.Handler {
···
21
r.Route("/tags", func(r chi.Router) {
22
r.Get("/", rp.RepoTags)
23
r.Route("/{tag}", func(r chi.Router) {
24
r.Get("/download/{file}", rp.DownloadArtifact)
25
26
// require repo:push to upload or delete artifacts
···
28
// additionally: only the uploader can truly delete an artifact
29
// (record+blob will live on their pds)
30
r.Group(func(r chi.Router) {
31
+
r.Use(middleware.AuthMiddleware(rp.oauth))
32
+
r.Use(mw.RepoPermissionMiddleware("repo:push"))
33
r.Post("/upload", rp.AttachArtifact)
34
r.Delete("/{file}", rp.DeleteArtifact)
35
})
···
63
r.Get("/*", rp.RepoCompare)
64
})
65
66
+
// label panel in issues/pulls/discussions/tasks
67
+
r.Route("/label", func(r chi.Router) {
68
+
r.Get("/", rp.LabelPanel)
69
+
r.Get("/edit", rp.EditLabelPanel)
70
+
})
71
+
72
// settings routes, needs auth
73
r.Group(func(r chi.Router) {
74
r.Use(middleware.AuthMiddleware(rp.oauth))
···
81
r.With(mw.RepoPermissionMiddleware("repo:settings")).Route("/settings", func(r chi.Router) {
82
r.Get("/", rp.RepoSettings)
83
r.With(mw.RepoPermissionMiddleware("repo:owner")).Post("/spindle", rp.EditSpindle)
84
+
r.With(mw.RepoPermissionMiddleware("repo:owner")).Put("/label", rp.AddLabelDef)
85
+
r.With(mw.RepoPermissionMiddleware("repo:owner")).Delete("/label", rp.DeleteLabelDef)
86
+
r.With(mw.RepoPermissionMiddleware("repo:owner")).Post("/label/subscribe", rp.SubscribeLabel)
87
+
r.With(mw.RepoPermissionMiddleware("repo:owner")).Post("/label/unsubscribe", rp.UnsubscribeLabel)
88
r.With(mw.RepoPermissionMiddleware("repo:invite")).Put("/collaborator", rp.AddCollaborator)
89
r.With(mw.RepoPermissionMiddleware("repo:delete")).Delete("/delete", rp.DeleteRepo)
90
r.Put("/branches/default", rp.SetDefaultBranch)
+14
-12
appview/reporesolver/resolver.go
+14
-12
appview/reporesolver/resolver.go
···
14
"github.com/bluesky-social/indigo/atproto/identity"
15
securejoin "github.com/cyphar/filepath-securejoin"
16
"github.com/go-chi/chi/v5"
17
-
"tangled.sh/tangled.sh/core/appview/config"
18
-
"tangled.sh/tangled.sh/core/appview/db"
19
-
"tangled.sh/tangled.sh/core/appview/oauth"
20
-
"tangled.sh/tangled.sh/core/appview/pages"
21
-
"tangled.sh/tangled.sh/core/appview/pages/repoinfo"
22
-
"tangled.sh/tangled.sh/core/idresolver"
23
-
"tangled.sh/tangled.sh/core/rbac"
24
)
25
26
type ResolvedRepo struct {
27
-
db.Repo
28
OwnerId identity.Identity
29
CurrentDir string
30
Ref string
···
44
}
45
46
func (rr *RepoResolver) Resolve(r *http.Request) (*ResolvedRepo, error) {
47
-
repo, ok := r.Context().Value("repo").(*db.Repo)
48
if !ok {
49
log.Println("malformed middleware: `repo` not exist in context")
50
return nil, fmt.Errorf("malformed middleware")
···
162
log.Println("failed to get repo source for ", repoAt, err)
163
}
164
165
-
var sourceRepo *db.Repo
166
if source != "" {
167
sourceRepo, err = db.GetRepoByAtUri(f.rr.execer, source)
168
if err != nil {
···
184
OwnerDid: f.OwnerDid(),
185
OwnerHandle: f.OwnerHandle(),
186
Name: f.Name,
187
RepoAt: repoAt,
188
Description: f.Description,
189
IsStarred: isStarred,
190
Knot: knot,
191
Spindle: f.Spindle,
192
Roles: f.RolesInRepo(user),
193
-
Stats: db.RepoStats{
194
StarCount: starCount,
195
IssueCount: issueCount,
196
PullCount: pullCount,
···
210
func (f *ResolvedRepo) RolesInRepo(u *oauth.User) repoinfo.RolesInRepo {
211
if u != nil {
212
r := f.rr.enforcer.GetPermissionsInRepo(u.Did, f.Knot, f.DidSlashRepo())
213
-
return repoinfo.RolesInRepo{r}
214
} else {
215
return repoinfo.RolesInRepo{}
216
}
···
14
"github.com/bluesky-social/indigo/atproto/identity"
15
securejoin "github.com/cyphar/filepath-securejoin"
16
"github.com/go-chi/chi/v5"
17
+
"tangled.org/core/appview/config"
18
+
"tangled.org/core/appview/db"
19
+
"tangled.org/core/appview/models"
20
+
"tangled.org/core/appview/oauth"
21
+
"tangled.org/core/appview/pages"
22
+
"tangled.org/core/appview/pages/repoinfo"
23
+
"tangled.org/core/idresolver"
24
+
"tangled.org/core/rbac"
25
)
26
27
type ResolvedRepo struct {
28
+
models.Repo
29
OwnerId identity.Identity
30
CurrentDir string
31
Ref string
···
45
}
46
47
func (rr *RepoResolver) Resolve(r *http.Request) (*ResolvedRepo, error) {
48
+
repo, ok := r.Context().Value("repo").(*models.Repo)
49
if !ok {
50
log.Println("malformed middleware: `repo` not exist in context")
51
return nil, fmt.Errorf("malformed middleware")
···
163
log.Println("failed to get repo source for ", repoAt, err)
164
}
165
166
+
var sourceRepo *models.Repo
167
if source != "" {
168
sourceRepo, err = db.GetRepoByAtUri(f.rr.execer, source)
169
if err != nil {
···
185
OwnerDid: f.OwnerDid(),
186
OwnerHandle: f.OwnerHandle(),
187
Name: f.Name,
188
+
Rkey: f.Repo.Rkey,
189
RepoAt: repoAt,
190
Description: f.Description,
191
IsStarred: isStarred,
192
Knot: knot,
193
Spindle: f.Spindle,
194
Roles: f.RolesInRepo(user),
195
+
Stats: models.RepoStats{
196
StarCount: starCount,
197
IssueCount: issueCount,
198
PullCount: pullCount,
···
212
func (f *ResolvedRepo) RolesInRepo(u *oauth.User) repoinfo.RolesInRepo {
213
if u != nil {
214
r := f.rr.enforcer.GetPermissionsInRepo(u.Did, f.Knot, f.DidSlashRepo())
215
+
return repoinfo.RolesInRepo{Roles: r}
216
} else {
217
return repoinfo.RolesInRepo{}
218
}
+4
-4
appview/serververify/verify.go
+4
-4
appview/serververify/verify.go
+62
-10
appview/settings/settings.go
+62
-10
appview/settings/settings.go
···
11
"time"
12
13
"github.com/go-chi/chi/v5"
14
-
"tangled.sh/tangled.sh/core/api/tangled"
15
-
"tangled.sh/tangled.sh/core/appview/config"
16
-
"tangled.sh/tangled.sh/core/appview/db"
17
-
"tangled.sh/tangled.sh/core/appview/email"
18
-
"tangled.sh/tangled.sh/core/appview/middleware"
19
-
"tangled.sh/tangled.sh/core/appview/oauth"
20
-
"tangled.sh/tangled.sh/core/appview/pages"
21
-
"tangled.sh/tangled.sh/core/tid"
22
23
comatproto "github.com/bluesky-social/indigo/api/atproto"
24
lexutil "github.com/bluesky-social/indigo/lex/util"
···
40
{"Name": "profile", "Icon": "user"},
41
{"Name": "keys", "Icon": "key"},
42
{"Name": "emails", "Icon": "mail"},
43
}
44
)
45
···
67
r.Post("/primary", s.emailsPrimary)
68
})
69
70
return r
71
}
72
···
80
})
81
}
82
83
func (s *Settings) keysSettings(w http.ResponseWriter, r *http.Request) {
84
user := s.OAuth.GetUser(r)
85
pubKeys, err := db.GetPublicKeysForDid(s.Db, user.Did)
···
185
}
186
defer tx.Rollback()
187
188
-
if err := db.AddEmail(tx, db.Email{
189
Did: did,
190
Address: emAddr,
191
Verified: false,
···
246
if s.Config.Core.Dev {
247
appUrl = "http://" + s.Config.Core.ListenAddr
248
} else {
249
-
appUrl = "https://tangled.sh"
250
}
251
252
return fmt.Sprintf("%s/settings/emails/verify?did=%s&email=%s&code=%s", appUrl, url.QueryEscape(did), url.QueryEscape(email), url.QueryEscape(code))
···
11
"time"
12
13
"github.com/go-chi/chi/v5"
14
+
"tangled.org/core/api/tangled"
15
+
"tangled.org/core/appview/config"
16
+
"tangled.org/core/appview/db"
17
+
"tangled.org/core/appview/email"
18
+
"tangled.org/core/appview/middleware"
19
+
"tangled.org/core/appview/models"
20
+
"tangled.org/core/appview/oauth"
21
+
"tangled.org/core/appview/pages"
22
+
"tangled.org/core/tid"
23
24
comatproto "github.com/bluesky-social/indigo/api/atproto"
25
lexutil "github.com/bluesky-social/indigo/lex/util"
···
41
{"Name": "profile", "Icon": "user"},
42
{"Name": "keys", "Icon": "key"},
43
{"Name": "emails", "Icon": "mail"},
44
+
{"Name": "notifications", "Icon": "bell"},
45
}
46
)
47
···
69
r.Post("/primary", s.emailsPrimary)
70
})
71
72
+
r.Route("/notifications", func(r chi.Router) {
73
+
r.Get("/", s.notificationsSettings)
74
+
r.Put("/", s.updateNotificationPreferences)
75
+
})
76
+
77
return r
78
}
79
···
87
})
88
}
89
90
+
func (s *Settings) notificationsSettings(w http.ResponseWriter, r *http.Request) {
91
+
user := s.OAuth.GetUser(r)
92
+
did := s.OAuth.GetDid(r)
93
+
94
+
prefs, err := s.Db.GetNotificationPreferences(r.Context(), did)
95
+
if err != nil {
96
+
log.Printf("failed to get notification preferences: %s", err)
97
+
s.Pages.Notice(w, "settings-notifications-error", "Unable to load notification preferences.")
98
+
return
99
+
}
100
+
101
+
s.Pages.UserNotificationSettings(w, pages.UserNotificationSettingsParams{
102
+
LoggedInUser: user,
103
+
Preferences: prefs,
104
+
Tabs: settingsTabs,
105
+
Tab: "notifications",
106
+
})
107
+
}
108
+
109
+
func (s *Settings) updateNotificationPreferences(w http.ResponseWriter, r *http.Request) {
110
+
did := s.OAuth.GetDid(r)
111
+
112
+
prefs := &models.NotificationPreferences{
113
+
UserDid: did,
114
+
RepoStarred: r.FormValue("repo_starred") == "on",
115
+
IssueCreated: r.FormValue("issue_created") == "on",
116
+
IssueCommented: r.FormValue("issue_commented") == "on",
117
+
IssueClosed: r.FormValue("issue_closed") == "on",
118
+
PullCreated: r.FormValue("pull_created") == "on",
119
+
PullCommented: r.FormValue("pull_commented") == "on",
120
+
PullMerged: r.FormValue("pull_merged") == "on",
121
+
Followed: r.FormValue("followed") == "on",
122
+
EmailNotifications: r.FormValue("email_notifications") == "on",
123
+
}
124
+
125
+
err := s.Db.UpdateNotificationPreferences(r.Context(), prefs)
126
+
if err != nil {
127
+
log.Printf("failed to update notification preferences: %s", err)
128
+
s.Pages.Notice(w, "settings-notifications-error", "Unable to save notification preferences.")
129
+
return
130
+
}
131
+
132
+
s.Pages.Notice(w, "settings-notifications-success", "Notification preferences saved successfully.")
133
+
}
134
+
135
func (s *Settings) keysSettings(w http.ResponseWriter, r *http.Request) {
136
user := s.OAuth.GetUser(r)
137
pubKeys, err := db.GetPublicKeysForDid(s.Db, user.Did)
···
237
}
238
defer tx.Rollback()
239
240
+
if err := db.AddEmail(tx, models.Email{
241
Did: did,
242
Address: emAddr,
243
Verified: false,
···
298
if s.Config.Core.Dev {
299
appUrl = "http://" + s.Config.Core.ListenAddr
300
} else {
301
+
appUrl = s.Config.Core.AppviewHost
302
}
303
304
return fmt.Sprintf("%s/settings/emails/verify?did=%s&email=%s&code=%s", appUrl, url.QueryEscape(did), url.QueryEscape(email), url.QueryEscape(code))
+76
-11
appview/signup/signup.go
+76
-11
appview/signup/signup.go
···
2
3
import (
4
"bufio"
5
"fmt"
6
"log/slog"
7
"net/http"
8
"os"
9
"strings"
10
11
"github.com/go-chi/chi/v5"
12
"github.com/posthog/posthog-go"
13
-
"tangled.sh/tangled.sh/core/appview/config"
14
-
"tangled.sh/tangled.sh/core/appview/db"
15
-
"tangled.sh/tangled.sh/core/appview/dns"
16
-
"tangled.sh/tangled.sh/core/appview/email"
17
-
"tangled.sh/tangled.sh/core/appview/pages"
18
-
"tangled.sh/tangled.sh/core/appview/state/userutil"
19
-
"tangled.sh/tangled.sh/core/appview/xrpcclient"
20
-
"tangled.sh/tangled.sh/core/idresolver"
21
)
22
23
type Signup struct {
···
115
func (s *Signup) signup(w http.ResponseWriter, r *http.Request) {
116
switch r.Method {
117
case http.MethodGet:
118
-
s.pages.Signup(w)
119
case http.MethodPost:
120
if s.cf == nil {
121
http.Error(w, "signup is disabled", http.StatusFailedDependency)
122
}
123
emailId := r.FormValue("email")
124
125
noticeId := "signup-msg"
126
if !email.IsValidEmail(emailId) {
127
s.pages.Notice(w, noticeId, "Invalid email address.")
128
return
···
163
s.pages.Notice(w, noticeId, "Failed to send email.")
164
return
165
}
166
-
err = db.AddInflightSignup(s.db, db.InflightSignup{
167
Email: emailId,
168
InviteCode: code,
169
})
···
229
return
230
}
231
232
-
err = db.AddEmail(s.db, db.Email{
233
Did: did,
234
Address: email,
235
Verified: true,
···
254
return
255
}
256
}
···
2
3
import (
4
"bufio"
5
+
"encoding/json"
6
+
"errors"
7
"fmt"
8
"log/slog"
9
"net/http"
10
+
"net/url"
11
"os"
12
"strings"
13
14
"github.com/go-chi/chi/v5"
15
"github.com/posthog/posthog-go"
16
+
"tangled.org/core/appview/config"
17
+
"tangled.org/core/appview/db"
18
+
"tangled.org/core/appview/dns"
19
+
"tangled.org/core/appview/email"
20
+
"tangled.org/core/appview/models"
21
+
"tangled.org/core/appview/pages"
22
+
"tangled.org/core/appview/state/userutil"
23
+
"tangled.org/core/appview/xrpcclient"
24
+
"tangled.org/core/idresolver"
25
)
26
27
type Signup struct {
···
119
func (s *Signup) signup(w http.ResponseWriter, r *http.Request) {
120
switch r.Method {
121
case http.MethodGet:
122
+
s.pages.Signup(w, pages.SignupParams{
123
+
CloudflareSiteKey: s.config.Cloudflare.TurnstileSiteKey,
124
+
})
125
case http.MethodPost:
126
if s.cf == nil {
127
http.Error(w, "signup is disabled", http.StatusFailedDependency)
128
+
return
129
}
130
emailId := r.FormValue("email")
131
+
cfToken := r.FormValue("cf-turnstile-response")
132
133
noticeId := "signup-msg"
134
+
135
+
if err := s.validateCaptcha(cfToken, r); err != nil {
136
+
s.l.Warn("turnstile validation failed", "error", err, "email", emailId)
137
+
s.pages.Notice(w, noticeId, "Captcha validation failed.")
138
+
return
139
+
}
140
+
141
if !email.IsValidEmail(emailId) {
142
s.pages.Notice(w, noticeId, "Invalid email address.")
143
return
···
178
s.pages.Notice(w, noticeId, "Failed to send email.")
179
return
180
}
181
+
err = db.AddInflightSignup(s.db, models.InflightSignup{
182
Email: emailId,
183
InviteCode: code,
184
})
···
244
return
245
}
246
247
+
err = db.AddEmail(s.db, models.Email{
248
Did: did,
249
Address: email,
250
Verified: true,
···
269
return
270
}
271
}
272
+
273
+
type turnstileResponse struct {
274
+
Success bool `json:"success"`
275
+
ErrorCodes []string `json:"error-codes,omitempty"`
276
+
ChallengeTs string `json:"challenge_ts,omitempty"`
277
+
Hostname string `json:"hostname,omitempty"`
278
+
}
279
+
280
+
func (s *Signup) validateCaptcha(cfToken string, r *http.Request) error {
281
+
if cfToken == "" {
282
+
return errors.New("captcha token is empty")
283
+
}
284
+
285
+
if s.config.Cloudflare.TurnstileSecretKey == "" {
286
+
return errors.New("turnstile secret key not configured")
287
+
}
288
+
289
+
data := url.Values{}
290
+
data.Set("secret", s.config.Cloudflare.TurnstileSecretKey)
291
+
data.Set("response", cfToken)
292
+
293
+
// include the client IP if we have it
294
+
if remoteIP := r.Header.Get("CF-Connecting-IP"); remoteIP != "" {
295
+
data.Set("remoteip", remoteIP)
296
+
} else if remoteIP := r.Header.Get("X-Forwarded-For"); remoteIP != "" {
297
+
if ips := strings.Split(remoteIP, ","); len(ips) > 0 {
298
+
data.Set("remoteip", strings.TrimSpace(ips[0]))
299
+
}
300
+
} else {
301
+
data.Set("remoteip", r.RemoteAddr)
302
+
}
303
+
304
+
resp, err := http.PostForm("https://challenges.cloudflare.com/turnstile/v0/siteverify", data)
305
+
if err != nil {
306
+
return fmt.Errorf("failed to verify turnstile token: %w", err)
307
+
}
308
+
defer resp.Body.Close()
309
+
310
+
var turnstileResp turnstileResponse
311
+
if err := json.NewDecoder(resp.Body).Decode(&turnstileResp); err != nil {
312
+
return fmt.Errorf("failed to decode turnstile response: %w", err)
313
+
}
314
+
315
+
if !turnstileResp.Success {
316
+
s.l.Warn("turnstile validation failed", "error_codes", turnstileResp.ErrorCodes)
317
+
return errors.New("turnstile validation failed")
318
+
}
319
+
320
+
return nil
321
+
}
+15
-14
appview/spindles/spindles.go
+15
-14
appview/spindles/spindles.go
···
9
"time"
10
11
"github.com/go-chi/chi/v5"
12
-
"tangled.sh/tangled.sh/core/api/tangled"
13
-
"tangled.sh/tangled.sh/core/appview/config"
14
-
"tangled.sh/tangled.sh/core/appview/db"
15
-
"tangled.sh/tangled.sh/core/appview/middleware"
16
-
"tangled.sh/tangled.sh/core/appview/oauth"
17
-
"tangled.sh/tangled.sh/core/appview/pages"
18
-
"tangled.sh/tangled.sh/core/appview/serververify"
19
-
"tangled.sh/tangled.sh/core/appview/xrpcclient"
20
-
"tangled.sh/tangled.sh/core/idresolver"
21
-
"tangled.sh/tangled.sh/core/rbac"
22
-
"tangled.sh/tangled.sh/core/tid"
23
24
comatproto "github.com/bluesky-social/indigo/api/atproto"
25
"github.com/bluesky-social/indigo/atproto/syntax"
···
115
}
116
117
// organize repos by did
118
-
repoMap := make(map[string][]db.Repo)
119
for _, r := range repos {
120
repoMap[r.Did] = append(repoMap[r.Did], r)
121
}
···
163
s.Enforcer.E.LoadPolicy()
164
}()
165
166
-
err = db.AddSpindle(tx, db.Spindle{
167
Owner: syntax.DID(user.Did),
168
Instance: instance,
169
})
···
524
rkey := tid.TID()
525
526
// add member to db
527
-
if err = db.AddSpindleMember(tx, db.SpindleMember{
528
Did: syntax.DID(user.Did),
529
Rkey: rkey,
530
Instance: instance,
···
9
"time"
10
11
"github.com/go-chi/chi/v5"
12
+
"tangled.org/core/api/tangled"
13
+
"tangled.org/core/appview/config"
14
+
"tangled.org/core/appview/db"
15
+
"tangled.org/core/appview/middleware"
16
+
"tangled.org/core/appview/models"
17
+
"tangled.org/core/appview/oauth"
18
+
"tangled.org/core/appview/pages"
19
+
"tangled.org/core/appview/serververify"
20
+
"tangled.org/core/appview/xrpcclient"
21
+
"tangled.org/core/idresolver"
22
+
"tangled.org/core/rbac"
23
+
"tangled.org/core/tid"
24
25
comatproto "github.com/bluesky-social/indigo/api/atproto"
26
"github.com/bluesky-social/indigo/atproto/syntax"
···
116
}
117
118
// organize repos by did
119
+
repoMap := make(map[string][]models.Repo)
120
for _, r := range repos {
121
repoMap[r.Did] = append(repoMap[r.Did], r)
122
}
···
164
s.Enforcer.E.LoadPolicy()
165
}()
166
167
+
err = db.AddSpindle(tx, models.Spindle{
168
Owner: syntax.DID(user.Did),
169
Instance: instance,
170
})
···
525
rkey := tid.TID()
526
527
// add member to db
528
+
if err = db.AddSpindleMember(tx, models.SpindleMember{
529
Did: syntax.DID(user.Did),
530
Rkey: rkey,
531
Instance: instance,
+8
-7
appview/state/follow.go
+8
-7
appview/state/follow.go
···
7
8
comatproto "github.com/bluesky-social/indigo/api/atproto"
9
lexutil "github.com/bluesky-social/indigo/lex/util"
10
-
"tangled.sh/tangled.sh/core/api/tangled"
11
-
"tangled.sh/tangled.sh/core/appview/db"
12
-
"tangled.sh/tangled.sh/core/appview/pages"
13
-
"tangled.sh/tangled.sh/core/tid"
14
)
15
16
func (s *State) Follow(w http.ResponseWriter, r *http.Request) {
···
59
60
log.Println("created atproto record: ", resp.Uri)
61
62
-
follow := &db.Follow{
63
UserDid: currentUser.Did,
64
SubjectDid: subjectIdent.DID.String(),
65
Rkey: rkey,
···
75
76
s.pages.FollowFragment(w, pages.FollowFragmentParams{
77
UserDid: subjectIdent.DID.String(),
78
-
FollowStatus: db.IsFollowing,
79
})
80
81
return
···
106
107
s.pages.FollowFragment(w, pages.FollowFragmentParams{
108
UserDid: subjectIdent.DID.String(),
109
-
FollowStatus: db.IsNotFollowing,
110
})
111
112
s.notifier.DeleteFollow(r.Context(), follow)
···
7
8
comatproto "github.com/bluesky-social/indigo/api/atproto"
9
lexutil "github.com/bluesky-social/indigo/lex/util"
10
+
"tangled.org/core/api/tangled"
11
+
"tangled.org/core/appview/db"
12
+
"tangled.org/core/appview/models"
13
+
"tangled.org/core/appview/pages"
14
+
"tangled.org/core/tid"
15
)
16
17
func (s *State) Follow(w http.ResponseWriter, r *http.Request) {
···
60
61
log.Println("created atproto record: ", resp.Uri)
62
63
+
follow := &models.Follow{
64
UserDid: currentUser.Did,
65
SubjectDid: subjectIdent.DID.String(),
66
Rkey: rkey,
···
76
77
s.pages.FollowFragment(w, pages.FollowFragmentParams{
78
UserDid: subjectIdent.DID.String(),
79
+
FollowStatus: models.IsFollowing,
80
})
81
82
return
···
107
108
s.pages.FollowFragment(w, pages.FollowFragmentParams{
109
UserDid: subjectIdent.DID.String(),
110
+
FollowStatus: models.IsNotFollowing,
111
})
112
113
s.notifier.DeleteFollow(r.Context(), follow)
+151
appview/state/gfi.go
+151
appview/state/gfi.go
···
···
1
+
package state
2
+
3
+
import (
4
+
"fmt"
5
+
"log"
6
+
"net/http"
7
+
"sort"
8
+
9
+
"github.com/bluesky-social/indigo/atproto/syntax"
10
+
"tangled.org/core/api/tangled"
11
+
"tangled.org/core/appview/db"
12
+
"tangled.org/core/appview/models"
13
+
"tangled.org/core/appview/pages"
14
+
"tangled.org/core/appview/pagination"
15
+
"tangled.org/core/consts"
16
+
)
17
+
18
+
func (s *State) GoodFirstIssues(w http.ResponseWriter, r *http.Request) {
19
+
user := s.oauth.GetUser(r)
20
+
21
+
page, ok := r.Context().Value("page").(pagination.Page)
22
+
if !ok {
23
+
page = pagination.FirstPage()
24
+
}
25
+
26
+
goodFirstIssueLabel := fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "good-first-issue")
27
+
28
+
repoLabels, err := db.GetRepoLabels(s.db, db.FilterEq("label_at", goodFirstIssueLabel))
29
+
if err != nil {
30
+
log.Println("failed to get repo labels", err)
31
+
s.pages.Error503(w)
32
+
return
33
+
}
34
+
35
+
if len(repoLabels) == 0 {
36
+
s.pages.GoodFirstIssues(w, pages.GoodFirstIssuesParams{
37
+
LoggedInUser: user,
38
+
RepoGroups: []*models.RepoGroup{},
39
+
LabelDefs: make(map[string]*models.LabelDefinition),
40
+
Page: page,
41
+
})
42
+
return
43
+
}
44
+
45
+
repoUris := make([]string, 0, len(repoLabels))
46
+
for _, rl := range repoLabels {
47
+
repoUris = append(repoUris, rl.RepoAt.String())
48
+
}
49
+
50
+
allIssues, err := db.GetIssuesPaginated(
51
+
s.db,
52
+
pagination.Page{
53
+
Limit: 500,
54
+
},
55
+
db.FilterIn("repo_at", repoUris),
56
+
db.FilterEq("open", 1),
57
+
)
58
+
if err != nil {
59
+
log.Println("failed to get issues", err)
60
+
s.pages.Error503(w)
61
+
return
62
+
}
63
+
64
+
var goodFirstIssues []models.Issue
65
+
for _, issue := range allIssues {
66
+
if issue.Labels.ContainsLabel(goodFirstIssueLabel) {
67
+
goodFirstIssues = append(goodFirstIssues, issue)
68
+
}
69
+
}
70
+
71
+
repoGroups := make(map[syntax.ATURI]*models.RepoGroup)
72
+
for _, issue := range goodFirstIssues {
73
+
if group, exists := repoGroups[issue.Repo.RepoAt()]; exists {
74
+
group.Issues = append(group.Issues, issue)
75
+
} else {
76
+
repoGroups[issue.Repo.RepoAt()] = &models.RepoGroup{
77
+
Repo: issue.Repo,
78
+
Issues: []models.Issue{issue},
79
+
}
80
+
}
81
+
}
82
+
83
+
var sortedGroups []*models.RepoGroup
84
+
for _, group := range repoGroups {
85
+
sortedGroups = append(sortedGroups, group)
86
+
}
87
+
88
+
sort.Slice(sortedGroups, func(i, j int) bool {
89
+
iIsTangled := sortedGroups[i].Repo.Did == consts.TangledDid
90
+
jIsTangled := sortedGroups[j].Repo.Did == consts.TangledDid
91
+
92
+
// If one is tangled and the other isn't, non-tangled comes first
93
+
if iIsTangled != jIsTangled {
94
+
return jIsTangled // true if j is tangled (i should come first)
95
+
}
96
+
97
+
// Both tangled or both not tangled: sort by name
98
+
return sortedGroups[i].Repo.Name < sortedGroups[j].Repo.Name
99
+
})
100
+
101
+
groupStart := page.Offset
102
+
groupEnd := page.Offset + page.Limit
103
+
if groupStart > len(sortedGroups) {
104
+
groupStart = len(sortedGroups)
105
+
}
106
+
if groupEnd > len(sortedGroups) {
107
+
groupEnd = len(sortedGroups)
108
+
}
109
+
110
+
paginatedGroups := sortedGroups[groupStart:groupEnd]
111
+
112
+
var allIssuesFromGroups []models.Issue
113
+
for _, group := range paginatedGroups {
114
+
allIssuesFromGroups = append(allIssuesFromGroups, group.Issues...)
115
+
}
116
+
117
+
var allLabelDefs []models.LabelDefinition
118
+
if len(allIssuesFromGroups) > 0 {
119
+
labelDefUris := make(map[string]bool)
120
+
for _, issue := range allIssuesFromGroups {
121
+
for labelDefUri := range issue.Labels.Inner() {
122
+
labelDefUris[labelDefUri] = true
123
+
}
124
+
}
125
+
126
+
uriList := make([]string, 0, len(labelDefUris))
127
+
for uri := range labelDefUris {
128
+
uriList = append(uriList, uri)
129
+
}
130
+
131
+
if len(uriList) > 0 {
132
+
allLabelDefs, err = db.GetLabelDefinitions(s.db, db.FilterIn("at_uri", uriList))
133
+
if err != nil {
134
+
log.Println("failed to fetch labels", err)
135
+
}
136
+
}
137
+
}
138
+
139
+
labelDefsMap := make(map[string]*models.LabelDefinition)
140
+
for i := range allLabelDefs {
141
+
labelDefsMap[allLabelDefs[i].AtUri().String()] = &allLabelDefs[i]
142
+
}
143
+
144
+
s.pages.GoodFirstIssues(w, pages.GoodFirstIssuesParams{
145
+
LoggedInUser: user,
146
+
RepoGroups: paginatedGroups,
147
+
LabelDefs: labelDefsMap,
148
+
Page: page,
149
+
GfiLabel: labelDefsMap[goodFirstIssueLabel],
150
+
})
151
+
}
+4
-4
appview/state/git_http.go
+4
-4
appview/state/git_http.go
···
8
9
"github.com/bluesky-social/indigo/atproto/identity"
10
"github.com/go-chi/chi/v5"
11
-
"tangled.sh/tangled.sh/core/appview/db"
12
)
13
14
func (s *State) InfoRefs(w http.ResponseWriter, r *http.Request) {
15
user := r.Context().Value("resolvedId").(identity.Identity)
16
-
repo := r.Context().Value("repo").(*db.Repo)
17
18
scheme := "https"
19
if s.config.Core.Dev {
···
31
http.Error(w, "failed to resolve user", http.StatusInternalServerError)
32
return
33
}
34
-
repo := r.Context().Value("repo").(*db.Repo)
35
36
scheme := "https"
37
if s.config.Core.Dev {
···
48
http.Error(w, "failed to resolve user", http.StatusInternalServerError)
49
return
50
}
51
-
repo := r.Context().Value("repo").(*db.Repo)
52
53
scheme := "https"
54
if s.config.Core.Dev {
···
8
9
"github.com/bluesky-social/indigo/atproto/identity"
10
"github.com/go-chi/chi/v5"
11
+
"tangled.org/core/appview/models"
12
)
13
14
func (s *State) InfoRefs(w http.ResponseWriter, r *http.Request) {
15
user := r.Context().Value("resolvedId").(identity.Identity)
16
+
repo := r.Context().Value("repo").(*models.Repo)
17
18
scheme := "https"
19
if s.config.Core.Dev {
···
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 {
···
48
http.Error(w, "failed to resolve user", http.StatusInternalServerError)
49
return
50
}
51
+
repo := r.Context().Value("repo").(*models.Repo)
52
53
scheme := "https"
54
if s.config.Core.Dev {
+29
-15
appview/state/knotstream.go
+29
-15
appview/state/knotstream.go
···
8
"slices"
9
"time"
10
11
-
"tangled.sh/tangled.sh/core/api/tangled"
12
-
"tangled.sh/tangled.sh/core/appview/cache"
13
-
"tangled.sh/tangled.sh/core/appview/config"
14
-
"tangled.sh/tangled.sh/core/appview/db"
15
-
ec "tangled.sh/tangled.sh/core/eventconsumer"
16
-
"tangled.sh/tangled.sh/core/eventconsumer/cursor"
17
-
"tangled.sh/tangled.sh/core/log"
18
-
"tangled.sh/tangled.sh/core/rbac"
19
-
"tangled.sh/tangled.sh/core/workflow"
20
21
"github.com/bluesky-social/indigo/atproto/syntax"
22
"github.com/go-git/go-git/v5/plumbing"
···
124
}
125
}
126
127
-
punch := db.Punch{
128
Did: record.CommitterDid,
129
Date: time.Now(),
130
Count: count,
···
156
return fmt.Errorf("%s is not a valid reference name", ref)
157
}
158
159
-
var langs []db.RepoLanguage
160
for _, l := range record.Meta.LangBreakdown.Inputs {
161
if l == nil {
162
continue
163
}
164
165
-
langs = append(langs, db.RepoLanguage{
166
RepoAt: repo.RepoAt(),
167
Ref: ref.Short(),
168
IsDefaultRef: record.Meta.IsDefaultRef,
···
171
})
172
}
173
174
-
return db.InsertRepoLanguages(d, langs)
175
}
176
177
func ingestPipeline(d *db.DB, source ec.Source, msg ec.Message) error {
···
207
}
208
209
// trigger info
210
-
var trigger db.Trigger
211
var sha string
212
trigger.Kind = workflow.TriggerKind(record.TriggerMetadata.Kind)
213
switch trigger.Kind {
···
234
return fmt.Errorf("failed to add trigger entry: %w", err)
235
}
236
237
-
pipeline := db.Pipeline{
238
Rkey: msg.Rkey,
239
Knot: source.Key(),
240
RepoOwner: syntax.DID(record.TriggerMetadata.Repo.Did),
···
8
"slices"
9
"time"
10
11
+
"tangled.org/core/api/tangled"
12
+
"tangled.org/core/appview/cache"
13
+
"tangled.org/core/appview/config"
14
+
"tangled.org/core/appview/db"
15
+
"tangled.org/core/appview/models"
16
+
ec "tangled.org/core/eventconsumer"
17
+
"tangled.org/core/eventconsumer/cursor"
18
+
"tangled.org/core/log"
19
+
"tangled.org/core/rbac"
20
+
"tangled.org/core/workflow"
21
22
"github.com/bluesky-social/indigo/atproto/syntax"
23
"github.com/go-git/go-git/v5/plumbing"
···
125
}
126
}
127
128
+
punch := models.Punch{
129
Did: record.CommitterDid,
130
Date: time.Now(),
131
Count: count,
···
157
return fmt.Errorf("%s is not a valid reference name", ref)
158
}
159
160
+
var langs []models.RepoLanguage
161
for _, l := range record.Meta.LangBreakdown.Inputs {
162
if l == nil {
163
continue
164
}
165
166
+
langs = append(langs, models.RepoLanguage{
167
RepoAt: repo.RepoAt(),
168
Ref: ref.Short(),
169
IsDefaultRef: record.Meta.IsDefaultRef,
···
172
})
173
}
174
175
+
tx, err := d.Begin()
176
+
if err != nil {
177
+
return err
178
+
}
179
+
defer tx.Rollback()
180
+
181
+
// update appview's cache
182
+
err = db.UpdateRepoLanguages(tx, repo.RepoAt(), ref.Short(), langs)
183
+
if err != nil {
184
+
fmt.Printf("failed; %s\n", err)
185
+
// non-fatal
186
+
}
187
+
188
+
return tx.Commit()
189
}
190
191
func ingestPipeline(d *db.DB, source ec.Source, msg ec.Message) error {
···
221
}
222
223
// trigger info
224
+
var trigger models.Trigger
225
var sha string
226
trigger.Kind = workflow.TriggerKind(record.TriggerMetadata.Kind)
227
switch trigger.Kind {
···
248
return fmt.Errorf("failed to add trigger entry: %w", err)
249
}
250
251
+
pipeline := models.Pipeline{
252
Rkey: msg.Rkey,
253
Knot: source.Key(),
254
RepoOwner: syntax.DID(record.TriggerMetadata.Repo.Did),
+40
-46
appview/state/profile.go
+40
-46
appview/state/profile.go
···
15
lexutil "github.com/bluesky-social/indigo/lex/util"
16
"github.com/go-chi/chi/v5"
17
"github.com/gorilla/feeds"
18
-
"tangled.sh/tangled.sh/core/api/tangled"
19
-
"tangled.sh/tangled.sh/core/appview/db"
20
-
// "tangled.sh/tangled.sh/core/appview/oauth"
21
-
"tangled.sh/tangled.sh/core/appview/pages"
22
)
23
24
func (s *State) Profile(w http.ResponseWriter, r *http.Request) {
···
77
}
78
79
loggedInUser := s.oauth.GetUser(r)
80
-
followStatus := db.IsNotFollowing
81
if loggedInUser != nil {
82
followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, did)
83
}
···
131
}
132
133
// filter out ones that are pinned
134
-
pinnedRepos := []db.Repo{}
135
for i, r := range repos {
136
// if this is a pinned repo, add it
137
if slices.Contains(profile.Profile.PinnedRepos[:], r.RepoAt()) {
···
149
l.Error("failed to fetch collaborating repos", "err", err)
150
}
151
152
-
pinnedCollaboratingRepos := []db.Repo{}
153
for _, r := range collaboratingRepos {
154
// if this is a pinned repo, add it
155
if slices.Contains(profile.Profile.PinnedRepos[:], r.RepoAt()) {
···
217
s.pages.Error500(w)
218
return
219
}
220
-
var repoAts []string
221
for _, s := range stars {
222
-
repoAts = append(repoAts, string(s.RepoAt))
223
-
}
224
-
225
-
repos, err := db.GetRepos(
226
-
s.db,
227
-
0,
228
-
db.FilterIn("at_uri", repoAts),
229
-
)
230
-
if err != nil {
231
-
l.Error("failed to get repos", "err", err)
232
-
s.pages.Error500(w)
233
-
return
234
}
235
236
err = s.pages.ProfileStarred(w, pages.ProfileStarredParams{
···
272
273
func (s *State) followPage(
274
r *http.Request,
275
-
fetchFollows func(db.Execer, string) ([]db.Follow, error),
276
-
extractDid func(db.Follow) string,
277
) (*FollowsPageParams, error) {
278
l := s.logger.With("handler", "reposPage")
279
···
284
l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle)
285
286
loggedInUser := s.oauth.GetUser(r)
287
288
follows, err := fetchFollows(s.db, profile.UserDid)
289
if err != nil {
290
l.Error("failed to fetch follows", "err", err)
291
-
return nil, err
292
}
293
294
if len(follows) == 0 {
295
-
return nil, nil
296
}
297
298
followDids := make([]string, 0, len(follows))
···
303
profiles, err := db.GetProfiles(s.db, db.FilterIn("did", followDids))
304
if err != nil {
305
l.Error("failed to get profiles", "followDids", followDids, "err", err)
306
-
return nil, err
307
}
308
309
followStatsMap, err := db.GetFollowerFollowingCounts(s.db, followDids)
···
316
following, err := db.GetFollowing(s.db, loggedInUser.Did)
317
if err != nil {
318
l.Error("failed to get follow list", "err", err, "loggedInUser", loggedInUser.Did)
319
-
return nil, err
320
}
321
loggedInUserFollowing = make(map[string]struct{}, len(following))
322
for _, follow := range following {
···
327
followCards := make([]pages.FollowCard, len(follows))
328
for i, did := range followDids {
329
followStats := followStatsMap[did]
330
-
followStatus := db.IsNotFollowing
331
if _, exists := loggedInUserFollowing[did]; exists {
332
-
followStatus = db.IsFollowing
333
} else if loggedInUser != nil && loggedInUser.Did == did {
334
-
followStatus = db.IsSelf
335
}
336
337
-
var profile *db.Profile
338
if p, exists := profiles[did]; exists {
339
profile = p
340
} else {
341
-
profile = &db.Profile{}
342
profile.Did = did
343
}
344
followCards[i] = pages.FollowCard{
345
UserDid: did,
346
FollowStatus: followStatus,
347
FollowersCount: followStats.Followers,
···
350
}
351
}
352
353
-
return &FollowsPageParams{
354
-
Follows: followCards,
355
-
Card: profile,
356
-
}, nil
357
}
358
359
func (s *State) followersPage(w http.ResponseWriter, r *http.Request) {
360
-
followPage, err := s.followPage(r, db.GetFollowers, func(f db.Follow) string { return f.UserDid })
361
if err != nil {
362
s.pages.Notice(w, "all-followers", "Failed to load followers")
363
return
···
371
}
372
373
func (s *State) followingPage(w http.ResponseWriter, r *http.Request) {
374
-
followPage, err := s.followPage(r, db.GetFollowing, func(f db.Follow) string { return f.SubjectDid })
375
if err != nil {
376
s.pages.Notice(w, "all-following", "Failed to load following")
377
return
···
452
return &feed, nil
453
}
454
455
-
func (s *State) addPullRequestItems(ctx context.Context, feed *feeds.Feed, pulls []*db.Pull, author *feeds.Author) error {
456
for _, pull := range pulls {
457
owner, err := s.idResolver.ResolveIdent(ctx, pull.Repo.Did)
458
if err != nil {
···
465
return nil
466
}
467
468
-
func (s *State) addIssueItems(ctx context.Context, feed *feeds.Feed, issues []*db.Issue, author *feeds.Author) error {
469
for _, issue := range issues {
470
owner, err := s.idResolver.ResolveIdent(ctx, issue.Repo.Did)
471
if err != nil {
···
477
return nil
478
}
479
480
-
func (s *State) addRepoItems(ctx context.Context, feed *feeds.Feed, repos []db.RepoEvent, author *feeds.Author) error {
481
for _, repo := range repos {
482
item, err := s.createRepoItem(ctx, repo, author)
483
if err != nil {
···
488
return nil
489
}
490
491
-
func (s *State) createPullRequestItem(pull *db.Pull, owner *identity.Identity, author *feeds.Author) *feeds.Item {
492
return &feeds.Item{
493
Title: fmt.Sprintf("%s created pull request '%s' in @%s/%s", author.Name, pull.Title, owner.Handle, pull.Repo.Name),
494
Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/pulls/%d", s.config.Core.AppviewHost, owner.Handle, pull.Repo.Name, pull.PullId), Type: "text/html", Rel: "alternate"},
···
497
}
498
}
499
500
-
func (s *State) createIssueItem(issue *db.Issue, owner *identity.Identity, author *feeds.Author) *feeds.Item {
501
return &feeds.Item{
502
Title: fmt.Sprintf("%s created issue '%s' in @%s/%s", author.Name, issue.Title, owner.Handle, issue.Repo.Name),
503
Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/issues/%d", s.config.Core.AppviewHost, owner.Handle, issue.Repo.Name, issue.IssueId), Type: "text/html", Rel: "alternate"},
···
506
}
507
}
508
509
-
func (s *State) createRepoItem(ctx context.Context, repo db.RepoEvent, author *feeds.Author) (*feeds.Item, error) {
510
var title string
511
if repo.Source != nil {
512
sourceOwner, err := s.idResolver.ResolveIdent(ctx, repo.Source.Did)
···
557
stat1 := r.FormValue("stat1")
558
559
if stat0 != "" {
560
-
profile.Stats[0].Kind = db.VanityStatKind(stat0)
561
}
562
563
if stat1 != "" {
564
-
profile.Stats[1].Kind = db.VanityStatKind(stat1)
565
}
566
567
if err := db.ValidateProfile(s.db, profile); err != nil {
···
612
s.updateProfile(profile, w, r)
613
}
614
615
-
func (s *State) updateProfile(profile *db.Profile, w http.ResponseWriter, r *http.Request) {
616
user := s.oauth.GetUser(r)
617
tx, err := s.db.BeginTx(r.Context(), nil)
618
if err != nil {
···
15
lexutil "github.com/bluesky-social/indigo/lex/util"
16
"github.com/go-chi/chi/v5"
17
"github.com/gorilla/feeds"
18
+
"tangled.org/core/api/tangled"
19
+
"tangled.org/core/appview/db"
20
+
"tangled.org/core/appview/models"
21
+
"tangled.org/core/appview/pages"
22
)
23
24
func (s *State) Profile(w http.ResponseWriter, r *http.Request) {
···
77
}
78
79
loggedInUser := s.oauth.GetUser(r)
80
+
followStatus := models.IsNotFollowing
81
if loggedInUser != nil {
82
followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, did)
83
}
···
131
}
132
133
// filter out ones that are pinned
134
+
pinnedRepos := []models.Repo{}
135
for i, r := range repos {
136
// if this is a pinned repo, add it
137
if slices.Contains(profile.Profile.PinnedRepos[:], r.RepoAt()) {
···
149
l.Error("failed to fetch collaborating repos", "err", err)
150
}
151
152
+
pinnedCollaboratingRepos := []models.Repo{}
153
for _, r := range collaboratingRepos {
154
// if this is a pinned repo, add it
155
if slices.Contains(profile.Profile.PinnedRepos[:], r.RepoAt()) {
···
217
s.pages.Error500(w)
218
return
219
}
220
+
var repos []models.Repo
221
for _, s := range stars {
222
+
if s.Repo != nil {
223
+
repos = append(repos, *s.Repo)
224
+
}
225
}
226
227
err = s.pages.ProfileStarred(w, pages.ProfileStarredParams{
···
263
264
func (s *State) followPage(
265
r *http.Request,
266
+
fetchFollows func(db.Execer, string) ([]models.Follow, error),
267
+
extractDid func(models.Follow) string,
268
) (*FollowsPageParams, error) {
269
l := s.logger.With("handler", "reposPage")
270
···
275
l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle)
276
277
loggedInUser := s.oauth.GetUser(r)
278
+
params := FollowsPageParams{
279
+
Card: profile,
280
+
}
281
282
follows, err := fetchFollows(s.db, profile.UserDid)
283
if err != nil {
284
l.Error("failed to fetch follows", "err", err)
285
+
return ¶ms, err
286
}
287
288
if len(follows) == 0 {
289
+
return ¶ms, nil
290
}
291
292
followDids := make([]string, 0, len(follows))
···
297
profiles, err := db.GetProfiles(s.db, db.FilterIn("did", followDids))
298
if err != nil {
299
l.Error("failed to get profiles", "followDids", followDids, "err", err)
300
+
return ¶ms, err
301
}
302
303
followStatsMap, err := db.GetFollowerFollowingCounts(s.db, followDids)
···
310
following, err := db.GetFollowing(s.db, loggedInUser.Did)
311
if err != nil {
312
l.Error("failed to get follow list", "err", err, "loggedInUser", loggedInUser.Did)
313
+
return ¶ms, err
314
}
315
loggedInUserFollowing = make(map[string]struct{}, len(following))
316
for _, follow := range following {
···
321
followCards := make([]pages.FollowCard, len(follows))
322
for i, did := range followDids {
323
followStats := followStatsMap[did]
324
+
followStatus := models.IsNotFollowing
325
if _, exists := loggedInUserFollowing[did]; exists {
326
+
followStatus = models.IsFollowing
327
} else if loggedInUser != nil && loggedInUser.Did == did {
328
+
followStatus = models.IsSelf
329
}
330
331
+
var profile *models.Profile
332
if p, exists := profiles[did]; exists {
333
profile = p
334
} else {
335
+
profile = &models.Profile{}
336
profile.Did = did
337
}
338
followCards[i] = pages.FollowCard{
339
+
LoggedInUser: loggedInUser,
340
UserDid: did,
341
FollowStatus: followStatus,
342
FollowersCount: followStats.Followers,
···
345
}
346
}
347
348
+
params.Follows = followCards
349
+
350
+
return ¶ms, nil
351
}
352
353
func (s *State) followersPage(w http.ResponseWriter, r *http.Request) {
354
+
followPage, err := s.followPage(r, db.GetFollowers, func(f models.Follow) string { return f.UserDid })
355
if err != nil {
356
s.pages.Notice(w, "all-followers", "Failed to load followers")
357
return
···
365
}
366
367
func (s *State) followingPage(w http.ResponseWriter, r *http.Request) {
368
+
followPage, err := s.followPage(r, db.GetFollowing, func(f models.Follow) string { return f.SubjectDid })
369
if err != nil {
370
s.pages.Notice(w, "all-following", "Failed to load following")
371
return
···
446
return &feed, nil
447
}
448
449
+
func (s *State) addPullRequestItems(ctx context.Context, feed *feeds.Feed, pulls []*models.Pull, author *feeds.Author) error {
450
for _, pull := range pulls {
451
owner, err := s.idResolver.ResolveIdent(ctx, pull.Repo.Did)
452
if err != nil {
···
459
return nil
460
}
461
462
+
func (s *State) addIssueItems(ctx context.Context, feed *feeds.Feed, issues []*models.Issue, author *feeds.Author) error {
463
for _, issue := range issues {
464
owner, err := s.idResolver.ResolveIdent(ctx, issue.Repo.Did)
465
if err != nil {
···
471
return nil
472
}
473
474
+
func (s *State) addRepoItems(ctx context.Context, feed *feeds.Feed, repos []models.RepoEvent, author *feeds.Author) error {
475
for _, repo := range repos {
476
item, err := s.createRepoItem(ctx, repo, author)
477
if err != nil {
···
482
return nil
483
}
484
485
+
func (s *State) createPullRequestItem(pull *models.Pull, owner *identity.Identity, author *feeds.Author) *feeds.Item {
486
return &feeds.Item{
487
Title: fmt.Sprintf("%s created pull request '%s' in @%s/%s", author.Name, pull.Title, owner.Handle, pull.Repo.Name),
488
Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/pulls/%d", s.config.Core.AppviewHost, owner.Handle, pull.Repo.Name, pull.PullId), Type: "text/html", Rel: "alternate"},
···
491
}
492
}
493
494
+
func (s *State) createIssueItem(issue *models.Issue, owner *identity.Identity, author *feeds.Author) *feeds.Item {
495
return &feeds.Item{
496
Title: fmt.Sprintf("%s created issue '%s' in @%s/%s", author.Name, issue.Title, owner.Handle, issue.Repo.Name),
497
Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/issues/%d", s.config.Core.AppviewHost, owner.Handle, issue.Repo.Name, issue.IssueId), Type: "text/html", Rel: "alternate"},
···
500
}
501
}
502
503
+
func (s *State) createRepoItem(ctx context.Context, repo models.RepoEvent, author *feeds.Author) (*feeds.Item, error) {
504
var title string
505
if repo.Source != nil {
506
sourceOwner, err := s.idResolver.ResolveIdent(ctx, repo.Source.Did)
···
551
stat1 := r.FormValue("stat1")
552
553
if stat0 != "" {
554
+
profile.Stats[0].Kind = models.VanityStatKind(stat0)
555
}
556
557
if stat1 != "" {
558
+
profile.Stats[1].Kind = models.VanityStatKind(stat1)
559
}
560
561
if err := db.ValidateProfile(s.db, profile); err != nil {
···
606
s.updateProfile(profile, w, r)
607
}
608
609
+
func (s *State) updateProfile(profile *models.Profile, w http.ResponseWriter, r *http.Request) {
610
user := s.oauth.GetUser(r)
611
tx, err := s.db.BeginTx(r.Context(), nil)
612
if err != nil {
+6
-5
appview/state/reaction.go
+6
-5
appview/state/reaction.go
···
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
11
lexutil "github.com/bluesky-social/indigo/lex/util"
12
-
"tangled.sh/tangled.sh/core/api/tangled"
13
-
"tangled.sh/tangled.sh/core/appview/db"
14
-
"tangled.sh/tangled.sh/core/appview/pages"
15
-
"tangled.sh/tangled.sh/core/tid"
16
)
17
18
func (s *State) React(w http.ResponseWriter, r *http.Request) {
···
30
return
31
}
32
33
-
reactionKind, ok := db.ParseReactionKind(r.URL.Query().Get("kind"))
34
if !ok {
35
log.Println("invalid reaction kind")
36
return
···
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
11
lexutil "github.com/bluesky-social/indigo/lex/util"
12
+
"tangled.org/core/api/tangled"
13
+
"tangled.org/core/appview/db"
14
+
"tangled.org/core/appview/models"
15
+
"tangled.org/core/appview/pages"
16
+
"tangled.org/core/tid"
17
)
18
19
func (s *State) React(w http.ResponseWriter, r *http.Request) {
···
31
return
32
}
33
34
+
reactionKind, ok := models.ParseReactionKind(r.URL.Query().Get("kind"))
35
if !ok {
36
log.Println("invalid reaction kind")
37
return
+53
-18
appview/state/router.go
+53
-18
appview/state/router.go
···
6
7
"github.com/go-chi/chi/v5"
8
"github.com/gorilla/sessions"
9
-
"tangled.sh/tangled.sh/core/appview/issues"
10
-
"tangled.sh/tangled.sh/core/appview/knots"
11
-
"tangled.sh/tangled.sh/core/appview/middleware"
12
-
oauthhandler "tangled.sh/tangled.sh/core/appview/oauth/handler"
13
-
"tangled.sh/tangled.sh/core/appview/pipelines"
14
-
"tangled.sh/tangled.sh/core/appview/pulls"
15
-
"tangled.sh/tangled.sh/core/appview/repo"
16
-
"tangled.sh/tangled.sh/core/appview/settings"
17
-
"tangled.sh/tangled.sh/core/appview/signup"
18
-
"tangled.sh/tangled.sh/core/appview/spindles"
19
-
"tangled.sh/tangled.sh/core/appview/state/userutil"
20
-
avstrings "tangled.sh/tangled.sh/core/appview/strings"
21
-
"tangled.sh/tangled.sh/core/log"
22
)
23
24
func (s *State) Router() http.Handler {
···
32
s.pages,
33
)
34
35
router.Get("/favicon.svg", s.Favicon)
36
router.Get("/favicon.ico", s.Favicon)
37
38
userRouter := s.UserRouter(&middleware)
39
standardRouter := s.StandardRouter(&middleware)
···
90
r.Mount("/issues", s.IssuesRouter(mw))
91
r.Mount("/pulls", s.PullsRouter(mw))
92
r.Mount("/pipelines", s.PipelinesRouter(mw))
93
94
// These routes get proxied to the knot
95
r.Get("/info/refs", s.InfoRefs)
···
113
114
r.Get("/", s.HomeOrTimeline)
115
r.Get("/timeline", s.Timeline)
116
-
r.With(middleware.AuthMiddleware(s.oauth)).Get("/upgradeBanner", s.UpgradeBanner)
117
118
r.Route("/repo", func(r chi.Router) {
119
r.Route("/new", func(r chi.Router) {
···
124
// r.Post("/import", s.ImportRepo)
125
})
126
127
r.With(middleware.AuthMiddleware(s.oauth)).Route("/follow", func(r chi.Router) {
128
r.Post("/", s.Follow)
129
r.Delete("/", s.Follow)
···
151
r.Mount("/strings", s.StringsRouter(mw))
152
r.Mount("/knots", s.KnotsRouter())
153
r.Mount("/spindles", s.SpindlesRouter())
154
r.Mount("/signup", s.SignupRouter())
155
r.Mount("/", s.OAuthRouter())
156
157
r.Get("/keys/{user}", s.Keys)
158
r.Get("/terms", s.TermsOfService)
159
r.Get("/privacy", s.PrivacyPolicy)
160
161
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
162
s.pages.Error404(w)
···
164
return r
165
}
166
167
func (s *State) OAuthRouter() http.Handler {
168
store := sessions.NewCookieStore([]byte(s.config.Core.CookieSecret))
169
oauth := oauthhandler.New(s.config, s.pages, s.idResolver, s.db, s.sess, store, s.oauth, s.enforcer, s.posthog)
···
221
Db: s.db,
222
OAuth: s.oauth,
223
Pages: s.pages,
224
-
Config: s.config,
225
-
Enforcer: s.enforcer,
226
IdResolver: s.idResolver,
227
-
Knotstream: s.knotstream,
228
Logger: logger,
229
}
230
···
243
244
func (s *State) RepoRouter(mw *middleware.Middleware) http.Handler {
245
logger := log.New("repo")
246
-
repo := repo.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.notifier, s.enforcer, logger)
247
return repo.Router(mw)
248
}
249
250
func (s *State) PipelinesRouter(mw *middleware.Middleware) http.Handler {
251
pipes := pipelines.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.enforcer)
252
return pipes.Router(mw)
253
}
254
255
func (s *State) SignupRouter() http.Handler {
···
6
7
"github.com/go-chi/chi/v5"
8
"github.com/gorilla/sessions"
9
+
"tangled.org/core/appview/issues"
10
+
"tangled.org/core/appview/knots"
11
+
"tangled.org/core/appview/labels"
12
+
"tangled.org/core/appview/middleware"
13
+
"tangled.org/core/appview/notifications"
14
+
oauthhandler "tangled.org/core/appview/oauth/handler"
15
+
"tangled.org/core/appview/pipelines"
16
+
"tangled.org/core/appview/pulls"
17
+
"tangled.org/core/appview/repo"
18
+
"tangled.org/core/appview/settings"
19
+
"tangled.org/core/appview/signup"
20
+
"tangled.org/core/appview/spindles"
21
+
"tangled.org/core/appview/state/userutil"
22
+
avstrings "tangled.org/core/appview/strings"
23
+
"tangled.org/core/log"
24
)
25
26
func (s *State) Router() http.Handler {
···
34
s.pages,
35
)
36
37
+
router.Use(middleware.TryRefreshSession())
38
router.Get("/favicon.svg", s.Favicon)
39
router.Get("/favicon.ico", s.Favicon)
40
+
router.Get("/pwa-manifest.json", s.PWAManifest)
41
42
userRouter := s.UserRouter(&middleware)
43
standardRouter := s.StandardRouter(&middleware)
···
94
r.Mount("/issues", s.IssuesRouter(mw))
95
r.Mount("/pulls", s.PullsRouter(mw))
96
r.Mount("/pipelines", s.PipelinesRouter(mw))
97
+
r.Mount("/labels", s.LabelsRouter(mw))
98
99
// These routes get proxied to the knot
100
r.Get("/info/refs", s.InfoRefs)
···
118
119
r.Get("/", s.HomeOrTimeline)
120
r.Get("/timeline", s.Timeline)
121
+
r.Get("/upgradeBanner", s.UpgradeBanner)
122
+
123
+
// special-case handler for serving tangled.org/core
124
+
r.Get("/core", s.Core())
125
126
r.Route("/repo", func(r chi.Router) {
127
r.Route("/new", func(r chi.Router) {
···
132
// r.Post("/import", s.ImportRepo)
133
})
134
135
+
r.Get("/goodfirstissues", s.GoodFirstIssues)
136
+
137
r.With(middleware.AuthMiddleware(s.oauth)).Route("/follow", func(r chi.Router) {
138
r.Post("/", s.Follow)
139
r.Delete("/", s.Follow)
···
161
r.Mount("/strings", s.StringsRouter(mw))
162
r.Mount("/knots", s.KnotsRouter())
163
r.Mount("/spindles", s.SpindlesRouter())
164
+
r.Mount("/notifications", s.NotificationsRouter(mw))
165
+
166
r.Mount("/signup", s.SignupRouter())
167
r.Mount("/", s.OAuthRouter())
168
169
r.Get("/keys/{user}", s.Keys)
170
r.Get("/terms", s.TermsOfService)
171
r.Get("/privacy", s.PrivacyPolicy)
172
+
r.Get("/brand", s.Brand)
173
174
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
175
s.pages.Error404(w)
···
177
return r
178
}
179
180
+
// Core serves tangled.org/core go-import meta tags, and redirects
181
+
// to the core repository if accessed normally.
182
+
func (s *State) Core() http.HandlerFunc {
183
+
return func(w http.ResponseWriter, r *http.Request) {
184
+
if r.URL.Query().Get("go-get") == "1" {
185
+
w.Header().Set("Content-Type", "text/html")
186
+
w.Write([]byte(`<meta name="go-import" content="tangled.org/core git https://tangled.org/@tangled.org/core">`))
187
+
return
188
+
}
189
+
190
+
http.Redirect(w, r, "/@tangled.org/core", http.StatusFound)
191
+
}
192
+
}
193
+
194
func (s *State) OAuthRouter() http.Handler {
195
store := sessions.NewCookieStore([]byte(s.config.Core.CookieSecret))
196
oauth := oauthhandler.New(s.config, s.pages, s.idResolver, s.db, s.sess, store, s.oauth, s.enforcer, s.posthog)
···
248
Db: s.db,
249
OAuth: s.oauth,
250
Pages: s.pages,
251
IdResolver: s.idResolver,
252
+
Notifier: s.notifier,
253
Logger: logger,
254
}
255
···
268
269
func (s *State) RepoRouter(mw *middleware.Middleware) http.Handler {
270
logger := log.New("repo")
271
+
repo := repo.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.notifier, s.enforcer, logger, s.validator)
272
return repo.Router(mw)
273
}
274
275
func (s *State) PipelinesRouter(mw *middleware.Middleware) http.Handler {
276
pipes := pipelines.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.enforcer)
277
return pipes.Router(mw)
278
+
}
279
+
280
+
func (s *State) LabelsRouter(mw *middleware.Middleware) http.Handler {
281
+
ls := labels.New(s.oauth, s.pages, s.db, s.validator, s.enforcer)
282
+
return ls.Router(mw)
283
+
}
284
+
285
+
func (s *State) NotificationsRouter(mw *middleware.Middleware) http.Handler {
286
+
notifs := notifications.New(s.db, s.oauth, s.pages)
287
+
return notifs.Router(mw)
288
}
289
290
func (s *State) SignupRouter() http.Handler {
+11
-10
appview/state/spindlestream.go
+11
-10
appview/state/spindlestream.go
···
9
"time"
10
11
"github.com/bluesky-social/indigo/atproto/syntax"
12
-
"tangled.sh/tangled.sh/core/api/tangled"
13
-
"tangled.sh/tangled.sh/core/appview/cache"
14
-
"tangled.sh/tangled.sh/core/appview/config"
15
-
"tangled.sh/tangled.sh/core/appview/db"
16
-
ec "tangled.sh/tangled.sh/core/eventconsumer"
17
-
"tangled.sh/tangled.sh/core/eventconsumer/cursor"
18
-
"tangled.sh/tangled.sh/core/log"
19
-
"tangled.sh/tangled.sh/core/rbac"
20
-
spindle "tangled.sh/tangled.sh/core/spindle/models"
21
)
22
23
func Spindlestream(ctx context.Context, c *config.Config, d *db.DB, enforcer *rbac.Enforcer) (*ec.Consumer, error) {
···
89
created = t
90
}
91
92
-
status := db.PipelineStatus{
93
Spindle: source.Key(),
94
Rkey: msg.Rkey,
95
PipelineKnot: strings.TrimPrefix(pipelineUri.Authority().String(), "did:web:"),
···
9
"time"
10
11
"github.com/bluesky-social/indigo/atproto/syntax"
12
+
"tangled.org/core/api/tangled"
13
+
"tangled.org/core/appview/cache"
14
+
"tangled.org/core/appview/config"
15
+
"tangled.org/core/appview/db"
16
+
"tangled.org/core/appview/models"
17
+
ec "tangled.org/core/eventconsumer"
18
+
"tangled.org/core/eventconsumer/cursor"
19
+
"tangled.org/core/log"
20
+
"tangled.org/core/rbac"
21
+
spindle "tangled.org/core/spindle/models"
22
)
23
24
func Spindlestream(ctx context.Context, c *config.Config, d *db.DB, enforcer *rbac.Enforcer) (*ec.Consumer, error) {
···
90
created = t
91
}
92
93
+
status := models.PipelineStatus{
94
Spindle: source.Key(),
95
Rkey: msg.Rkey,
96
PipelineKnot: strings.TrimPrefix(pipelineUri.Authority().String(), "did:web:"),
+8
-7
appview/state/star.go
+8
-7
appview/state/star.go
···
8
comatproto "github.com/bluesky-social/indigo/api/atproto"
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
lexutil "github.com/bluesky-social/indigo/lex/util"
11
-
"tangled.sh/tangled.sh/core/api/tangled"
12
-
"tangled.sh/tangled.sh/core/appview/db"
13
-
"tangled.sh/tangled.sh/core/appview/pages"
14
-
"tangled.sh/tangled.sh/core/tid"
15
)
16
17
func (s *State) Star(w http.ResponseWriter, r *http.Request) {
···
55
}
56
log.Println("created atproto record: ", resp.Uri)
57
58
-
star := &db.Star{
59
StarredByDid: currentUser.Did,
60
RepoAt: subjectUri,
61
Rkey: rkey,
···
77
s.pages.RepoStarFragment(w, pages.RepoStarFragmentParams{
78
IsStarred: true,
79
RepoAt: subjectUri,
80
-
Stats: db.RepoStats{
81
StarCount: starCount,
82
},
83
})
···
119
s.pages.RepoStarFragment(w, pages.RepoStarFragmentParams{
120
IsStarred: false,
121
RepoAt: subjectUri,
122
-
Stats: db.RepoStats{
123
StarCount: starCount,
124
},
125
})
···
8
comatproto "github.com/bluesky-social/indigo/api/atproto"
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
lexutil "github.com/bluesky-social/indigo/lex/util"
11
+
"tangled.org/core/api/tangled"
12
+
"tangled.org/core/appview/db"
13
+
"tangled.org/core/appview/models"
14
+
"tangled.org/core/appview/pages"
15
+
"tangled.org/core/tid"
16
)
17
18
func (s *State) Star(w http.ResponseWriter, r *http.Request) {
···
56
}
57
log.Println("created atproto record: ", resp.Uri)
58
59
+
star := &models.Star{
60
StarredByDid: currentUser.Did,
61
RepoAt: subjectUri,
62
Rkey: rkey,
···
78
s.pages.RepoStarFragment(w, pages.RepoStarFragmentParams{
79
IsStarred: true,
80
RepoAt: subjectUri,
81
+
Stats: models.RepoStats{
82
StarCount: starCount,
83
},
84
})
···
120
s.pages.RepoStarFragment(w, pages.RepoStarFragmentParams{
121
IsStarred: false,
122
RepoAt: subjectUri,
123
+
Stats: models.RepoStats{
124
StarCount: starCount,
125
},
126
})
+121
-36
appview/state/state.go
+121
-36
appview/state/state.go
···
17
securejoin "github.com/cyphar/filepath-securejoin"
18
"github.com/go-chi/chi/v5"
19
"github.com/posthog/posthog-go"
20
-
"tangled.sh/tangled.sh/core/api/tangled"
21
-
"tangled.sh/tangled.sh/core/appview"
22
-
"tangled.sh/tangled.sh/core/appview/cache"
23
-
"tangled.sh/tangled.sh/core/appview/cache/session"
24
-
"tangled.sh/tangled.sh/core/appview/config"
25
-
"tangled.sh/tangled.sh/core/appview/db"
26
-
"tangled.sh/tangled.sh/core/appview/notify"
27
-
"tangled.sh/tangled.sh/core/appview/oauth"
28
-
"tangled.sh/tangled.sh/core/appview/pages"
29
-
posthogService "tangled.sh/tangled.sh/core/appview/posthog"
30
-
"tangled.sh/tangled.sh/core/appview/reporesolver"
31
-
"tangled.sh/tangled.sh/core/appview/validator"
32
-
xrpcclient "tangled.sh/tangled.sh/core/appview/xrpcclient"
33
-
"tangled.sh/tangled.sh/core/eventconsumer"
34
-
"tangled.sh/tangled.sh/core/idresolver"
35
-
"tangled.sh/tangled.sh/core/jetstream"
36
-
tlog "tangled.sh/tangled.sh/core/log"
37
-
"tangled.sh/tangled.sh/core/rbac"
38
-
"tangled.sh/tangled.sh/core/tid"
39
-
// xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
40
)
41
42
type State struct {
···
78
cache := cache.New(config.Redis.Addr)
79
sess := session.New(cache)
80
oauth := oauth.NewOAuth(config, sess)
81
-
validator := validator.New(d)
82
83
posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint})
84
if err != nil {
···
87
88
repoResolver := reporesolver.New(config, enforcer, res, d)
89
90
-
wrapper := db.DbWrapper{d}
91
jc, err := jetstream.NewJetstreamClient(
92
config.Jetstream.Endpoint,
93
"appview",
···
102
tangled.StringNSID,
103
tangled.RepoIssueNSID,
104
tangled.RepoIssueCommentNSID,
105
},
106
nil,
107
slog.Default(),
···
116
return nil, fmt.Errorf("failed to create jetstream client: %w", err)
117
}
118
119
ingester := appview.Ingester{
120
Db: wrapper,
121
Enforcer: enforcer,
···
142
spindlestream.Start(ctx)
143
144
var notifiers []notify.Notifier
145
if !config.Core.Dev {
146
-
notifiers = append(notifiers, posthogService.NewPosthogNotifier(posthog))
147
}
148
notifier := notify.NewMergedNotifier(notifiers...)
149
···
186
s.pages.Favicon(w)
187
}
188
189
func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) {
190
user := s.oauth.GetUser(r)
191
s.pages.TermsOfService(w, pages.TermsOfServiceParams{
···
200
})
201
}
202
203
func (s *State) HomeOrTimeline(w http.ResponseWriter, r *http.Request) {
204
if s.oauth.GetUser(r) != nil {
205
s.Timeline(w, r)
···
211
func (s *State) Timeline(w http.ResponseWriter, r *http.Request) {
212
user := s.oauth.GetUser(r)
213
214
-
timeline, err := db.MakeTimeline(s.db, 50)
215
if err != nil {
216
log.Println(err)
217
s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.")
···
224
return
225
}
226
227
-
s.pages.Timeline(w, pages.TimelineParams{
228
LoggedInUser: user,
229
Timeline: timeline,
230
Repos: repos,
231
-
})
232
}
233
234
func (s *State) UpgradeBanner(w http.ResponseWriter, r *http.Request) {
235
user := s.oauth.GetUser(r)
236
l := s.logger.With("handler", "UpgradeBanner")
237
l = l.With("did", user.Did)
238
l = l.With("handle", user.Handle)
···
266
}
267
268
func (s *State) Home(w http.ResponseWriter, r *http.Request) {
269
-
timeline, err := db.MakeTimeline(s.db, 5)
270
if err != nil {
271
log.Println(err)
272
s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.")
···
415
}
416
417
// Check for existing repos
418
-
existingRepo, err := db.GetRepo(s.db, user.Did, repoName)
419
if err == nil && existingRepo != nil {
420
l.Info("repo exists")
421
s.pages.Notice(w, "repo", fmt.Sprintf("You already have a repository by this name on %s", existingRepo.Knot))
···
424
425
// create atproto record for this repo
426
rkey := tid.TID()
427
-
repo := &db.Repo{
428
Did: user.Did,
429
Name: repoName,
430
Knot: domain,
431
Rkey: rkey,
432
Description: description,
433
}
434
435
xrpcClient, err := s.oauth.AuthorizedClient(r)
436
if err != nil {
···
439
return
440
}
441
442
-
createdAt := time.Now().Format(time.RFC3339)
443
atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
444
Collection: tangled.RepoNSID,
445
Repo: user.Did,
446
Rkey: rkey,
447
Record: &lexutil.LexiconTypeDecoder{
448
-
Val: &tangled.Repo{
449
-
Knot: repo.Knot,
450
-
Name: repoName,
451
-
CreatedAt: createdAt,
452
-
Owner: user.Did,
453
-
}},
454
})
455
if err != nil {
456
l.Info("PDS write failed", "err", err)
···
574
})
575
return err
576
}
···
17
securejoin "github.com/cyphar/filepath-securejoin"
18
"github.com/go-chi/chi/v5"
19
"github.com/posthog/posthog-go"
20
+
"tangled.org/core/api/tangled"
21
+
"tangled.org/core/appview"
22
+
"tangled.org/core/appview/cache"
23
+
"tangled.org/core/appview/cache/session"
24
+
"tangled.org/core/appview/config"
25
+
"tangled.org/core/appview/db"
26
+
"tangled.org/core/appview/models"
27
+
"tangled.org/core/appview/notify"
28
+
dbnotify "tangled.org/core/appview/notify/db"
29
+
phnotify "tangled.org/core/appview/notify/posthog"
30
+
"tangled.org/core/appview/oauth"
31
+
"tangled.org/core/appview/pages"
32
+
"tangled.org/core/appview/reporesolver"
33
+
"tangled.org/core/appview/validator"
34
+
xrpcclient "tangled.org/core/appview/xrpcclient"
35
+
"tangled.org/core/eventconsumer"
36
+
"tangled.org/core/idresolver"
37
+
"tangled.org/core/jetstream"
38
+
tlog "tangled.org/core/log"
39
+
"tangled.org/core/rbac"
40
+
"tangled.org/core/tid"
41
)
42
43
type State struct {
···
79
cache := cache.New(config.Redis.Addr)
80
sess := session.New(cache)
81
oauth := oauth.NewOAuth(config, sess)
82
+
validator := validator.New(d, res, enforcer)
83
84
posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint})
85
if err != nil {
···
88
89
repoResolver := reporesolver.New(config, enforcer, res, d)
90
91
+
wrapper := db.DbWrapper{Execer: d}
92
jc, err := jetstream.NewJetstreamClient(
93
config.Jetstream.Endpoint,
94
"appview",
···
103
tangled.StringNSID,
104
tangled.RepoIssueNSID,
105
tangled.RepoIssueCommentNSID,
106
+
tangled.LabelDefinitionNSID,
107
+
tangled.LabelOpNSID,
108
},
109
nil,
110
slog.Default(),
···
119
return nil, fmt.Errorf("failed to create jetstream client: %w", err)
120
}
121
122
+
if err := BackfillDefaultDefs(d, res); err != nil {
123
+
return nil, fmt.Errorf("failed to backfill default label defs: %w", err)
124
+
}
125
+
126
ingester := appview.Ingester{
127
Db: wrapper,
128
Enforcer: enforcer,
···
149
spindlestream.Start(ctx)
150
151
var notifiers []notify.Notifier
152
+
153
+
// Always add the database notifier
154
+
notifiers = append(notifiers, dbnotify.NewDatabaseNotifier(d, res))
155
+
156
+
// Add other notifiers in production only
157
if !config.Core.Dev {
158
+
notifiers = append(notifiers, phnotify.NewPosthogNotifier(posthog))
159
}
160
notifier := notify.NewMergedNotifier(notifiers...)
161
···
198
s.pages.Favicon(w)
199
}
200
201
+
// https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest
202
+
const manifestJson = `{
203
+
"name": "tangled",
204
+
"description": "tightly-knit social coding.",
205
+
"icons": [
206
+
{
207
+
"src": "/favicon.svg",
208
+
"sizes": "144x144"
209
+
}
210
+
],
211
+
"start_url": "/",
212
+
"id": "org.tangled",
213
+
214
+
"display": "standalone",
215
+
"background_color": "#111827",
216
+
"theme_color": "#111827"
217
+
}`
218
+
219
+
func (p *State) PWAManifest(w http.ResponseWriter, r *http.Request) {
220
+
w.Header().Set("Content-Type", "application/json")
221
+
w.Write([]byte(manifestJson))
222
+
}
223
+
224
func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) {
225
user := s.oauth.GetUser(r)
226
s.pages.TermsOfService(w, pages.TermsOfServiceParams{
···
235
})
236
}
237
238
+
func (s *State) Brand(w http.ResponseWriter, r *http.Request) {
239
+
user := s.oauth.GetUser(r)
240
+
s.pages.Brand(w, pages.BrandParams{
241
+
LoggedInUser: user,
242
+
})
243
+
}
244
+
245
func (s *State) HomeOrTimeline(w http.ResponseWriter, r *http.Request) {
246
if s.oauth.GetUser(r) != nil {
247
s.Timeline(w, r)
···
253
func (s *State) Timeline(w http.ResponseWriter, r *http.Request) {
254
user := s.oauth.GetUser(r)
255
256
+
var userDid string
257
+
if user != nil {
258
+
userDid = user.Did
259
+
}
260
+
timeline, err := db.MakeTimeline(s.db, 50, userDid)
261
if err != nil {
262
log.Println(err)
263
s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.")
···
270
return
271
}
272
273
+
gfiLabel, err := db.GetLabelDefinition(s.db, db.FilterEq("at_uri", models.LabelGoodFirstIssue))
274
+
if err != nil {
275
+
// non-fatal
276
+
}
277
+
278
+
fmt.Println(s.pages.Timeline(w, pages.TimelineParams{
279
LoggedInUser: user,
280
Timeline: timeline,
281
Repos: repos,
282
+
GfiLabel: gfiLabel,
283
+
}))
284
}
285
286
func (s *State) UpgradeBanner(w http.ResponseWriter, r *http.Request) {
287
user := s.oauth.GetUser(r)
288
+
if user == nil {
289
+
return
290
+
}
291
+
292
l := s.logger.With("handler", "UpgradeBanner")
293
l = l.With("did", user.Did)
294
l = l.With("handle", user.Handle)
···
322
}
323
324
func (s *State) Home(w http.ResponseWriter, r *http.Request) {
325
+
timeline, err := db.MakeTimeline(s.db, 5, "")
326
if err != nil {
327
log.Println(err)
328
s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.")
···
471
}
472
473
// Check for existing repos
474
+
existingRepo, err := db.GetRepo(
475
+
s.db,
476
+
db.FilterEq("did", user.Did),
477
+
db.FilterEq("name", repoName),
478
+
)
479
if err == nil && existingRepo != nil {
480
l.Info("repo exists")
481
s.pages.Notice(w, "repo", fmt.Sprintf("You already have a repository by this name on %s", existingRepo.Knot))
···
484
485
// create atproto record for this repo
486
rkey := tid.TID()
487
+
repo := &models.Repo{
488
Did: user.Did,
489
Name: repoName,
490
Knot: domain,
491
Rkey: rkey,
492
Description: description,
493
+
Created: time.Now(),
494
+
Labels: models.DefaultLabelDefs(),
495
}
496
+
record := repo.AsRecord()
497
498
xrpcClient, err := s.oauth.AuthorizedClient(r)
499
if err != nil {
···
502
return
503
}
504
505
atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
506
Collection: tangled.RepoNSID,
507
Repo: user.Did,
508
Rkey: rkey,
509
Record: &lexutil.LexiconTypeDecoder{
510
+
Val: &record,
511
+
},
512
})
513
if err != nil {
514
l.Info("PDS write failed", "err", err)
···
632
})
633
return err
634
}
635
+
636
+
func BackfillDefaultDefs(e db.Execer, r *idresolver.Resolver) error {
637
+
defaults := models.DefaultLabelDefs()
638
+
defaultLabels, err := db.GetLabelDefinitions(e, db.FilterIn("at_uri", defaults))
639
+
if err != nil {
640
+
return err
641
+
}
642
+
// already present
643
+
if len(defaultLabels) == len(defaults) {
644
+
return nil
645
+
}
646
+
647
+
labelDefs, err := models.FetchDefaultDefs(r)
648
+
if err != nil {
649
+
return err
650
+
}
651
+
652
+
// Insert each label definition to the database
653
+
for _, labelDef := range labelDefs {
654
+
_, err = db.AddLabelDefinition(e, &labelDef)
655
+
if err != nil {
656
+
return fmt.Errorf("failed to add label definition %s: %v", labelDef.Name, err)
657
+
}
658
+
}
659
+
660
+
return nil
661
+
}
+19
-16
appview/strings/strings.go
+19
-16
appview/strings/strings.go
···
8
"strconv"
9
"time"
10
11
-
"tangled.sh/tangled.sh/core/api/tangled"
12
-
"tangled.sh/tangled.sh/core/appview/config"
13
-
"tangled.sh/tangled.sh/core/appview/db"
14
-
"tangled.sh/tangled.sh/core/appview/middleware"
15
-
"tangled.sh/tangled.sh/core/appview/oauth"
16
-
"tangled.sh/tangled.sh/core/appview/pages"
17
-
"tangled.sh/tangled.sh/core/appview/pages/markup"
18
-
"tangled.sh/tangled.sh/core/eventconsumer"
19
-
"tangled.sh/tangled.sh/core/idresolver"
20
-
"tangled.sh/tangled.sh/core/rbac"
21
-
"tangled.sh/tangled.sh/core/tid"
22
23
"github.com/bluesky-social/indigo/api/atproto"
24
"github.com/bluesky-social/indigo/atproto/identity"
···
31
Db *db.DB
32
OAuth *oauth.OAuth
33
Pages *pages.Pages
34
-
Config *config.Config
35
-
Enforcer *rbac.Enforcer
36
IdResolver *idresolver.Resolver
37
Logger *slog.Logger
38
-
Knotstream *eventconsumer.Consumer
39
}
40
41
func (s *Strings) Router(mw *middleware.Middleware) http.Handler {
···
239
description := r.FormValue("description")
240
241
// construct new string from form values
242
-
entry := db.String{
243
Did: first.Did,
244
Rkey: first.Rkey,
245
Filename: filename,
···
284
return
285
}
286
287
// if that went okay, redir to the string
288
s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+entry.Rkey)
289
}
···
320
321
description := r.FormValue("description")
322
323
-
string := db.String{
324
Did: syntax.DID(user.Did),
325
Rkey: tid.TID(),
326
Filename: filename,
···
357
fail("Failed to create string.", err)
358
return
359
}
360
361
// successful
362
s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+string.Rkey)
···
399
fail("Failed to delete string.", err)
400
return
401
}
402
403
s.Pages.HxRedirect(w, "/strings/"+user.Handle)
404
}
···
8
"strconv"
9
"time"
10
11
+
"tangled.org/core/api/tangled"
12
+
"tangled.org/core/appview/db"
13
+
"tangled.org/core/appview/middleware"
14
+
"tangled.org/core/appview/models"
15
+
"tangled.org/core/appview/notify"
16
+
"tangled.org/core/appview/oauth"
17
+
"tangled.org/core/appview/pages"
18
+
"tangled.org/core/appview/pages/markup"
19
+
"tangled.org/core/idresolver"
20
+
"tangled.org/core/tid"
21
22
"github.com/bluesky-social/indigo/api/atproto"
23
"github.com/bluesky-social/indigo/atproto/identity"
···
30
Db *db.DB
31
OAuth *oauth.OAuth
32
Pages *pages.Pages
33
IdResolver *idresolver.Resolver
34
Logger *slog.Logger
35
+
Notifier notify.Notifier
36
}
37
38
func (s *Strings) Router(mw *middleware.Middleware) http.Handler {
···
236
description := r.FormValue("description")
237
238
// construct new string from form values
239
+
entry := models.String{
240
Did: first.Did,
241
Rkey: first.Rkey,
242
Filename: filename,
···
281
return
282
}
283
284
+
s.Notifier.EditString(r.Context(), &entry)
285
+
286
// if that went okay, redir to the string
287
s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+entry.Rkey)
288
}
···
319
320
description := r.FormValue("description")
321
322
+
string := models.String{
323
Did: syntax.DID(user.Did),
324
Rkey: tid.TID(),
325
Filename: filename,
···
356
fail("Failed to create string.", err)
357
return
358
}
359
+
360
+
s.Notifier.NewString(r.Context(), &string)
361
362
// successful
363
s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+string.Rkey)
···
400
fail("Failed to delete string.", err)
401
return
402
}
403
+
404
+
s.Notifier.DeleteString(r.Context(), user.Did, rkey)
405
406
s.Pages.HxRedirect(w, "/strings/"+user.Handle)
407
}
+4
-3
appview/validator/issue.go
+4
-3
appview/validator/issue.go
···
4
"fmt"
5
"strings"
6
7
-
"tangled.sh/tangled.sh/core/appview/db"
8
)
9
10
-
func (v *Validator) ValidateIssueComment(comment *db.IssueComment) error {
11
// if comments have parents, only ingest ones that are 1 level deep
12
if comment.ReplyTo != nil {
13
parents, err := db.GetIssueComments(v.db, db.FilterEq("at_uri", *comment.ReplyTo))
···
32
return nil
33
}
34
35
-
func (v *Validator) ValidateIssue(issue *db.Issue) error {
36
if issue.Title == "" {
37
return fmt.Errorf("issue title is empty")
38
}
···
4
"fmt"
5
"strings"
6
7
+
"tangled.org/core/appview/db"
8
+
"tangled.org/core/appview/models"
9
)
10
11
+
func (v *Validator) ValidateIssueComment(comment *models.IssueComment) error {
12
// if comments have parents, only ingest ones that are 1 level deep
13
if comment.ReplyTo != nil {
14
parents, err := db.GetIssueComments(v.db, db.FilterEq("at_uri", *comment.ReplyTo))
···
33
return nil
34
}
35
36
+
func (v *Validator) ValidateIssue(issue *models.Issue) error {
37
if issue.Title == "" {
38
return fmt.Errorf("issue title is empty")
39
}
+217
appview/validator/label.go
+217
appview/validator/label.go
···
···
1
+
package validator
2
+
3
+
import (
4
+
"context"
5
+
"fmt"
6
+
"regexp"
7
+
"strings"
8
+
9
+
"github.com/bluesky-social/indigo/atproto/syntax"
10
+
"golang.org/x/exp/slices"
11
+
"tangled.org/core/api/tangled"
12
+
"tangled.org/core/appview/models"
13
+
)
14
+
15
+
var (
16
+
// Label name should be alphanumeric with hyphens/underscores, but not start/end with them
17
+
labelNameRegex = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9_-]*[a-zA-Z0-9])?$`)
18
+
// Color should be a valid hex color
19
+
colorRegex = regexp.MustCompile(`^#[a-fA-F0-9]{6}$`)
20
+
// You can only label issues and pulls presently
21
+
validScopes = []string{tangled.RepoIssueNSID, tangled.RepoPullNSID}
22
+
)
23
+
24
+
func (v *Validator) ValidateLabelDefinition(label *models.LabelDefinition) error {
25
+
if label.Name == "" {
26
+
return fmt.Errorf("label name is empty")
27
+
}
28
+
if len(label.Name) > 40 {
29
+
return fmt.Errorf("label name too long (max 40 graphemes)")
30
+
}
31
+
if len(label.Name) < 1 {
32
+
return fmt.Errorf("label name too short (min 1 grapheme)")
33
+
}
34
+
if !labelNameRegex.MatchString(label.Name) {
35
+
return fmt.Errorf("label name contains invalid characters (use only letters, numbers, hyphens, and underscores)")
36
+
}
37
+
38
+
if !label.ValueType.IsConcreteType() {
39
+
return fmt.Errorf("invalid value type: %q (must be one of: null, boolean, integer, string)", label.ValueType.Type)
40
+
}
41
+
42
+
// null type checks: cannot be enums, multiple or explicit format
43
+
if label.ValueType.IsNull() && label.ValueType.IsEnum() {
44
+
return fmt.Errorf("null type cannot be used in conjunction with enum type")
45
+
}
46
+
if label.ValueType.IsNull() && label.Multiple {
47
+
return fmt.Errorf("null type labels cannot be multiple")
48
+
}
49
+
if label.ValueType.IsNull() && !label.ValueType.IsAnyFormat() {
50
+
return fmt.Errorf("format cannot be used in conjunction with null type")
51
+
}
52
+
53
+
// format checks: cannot be used with enum, or integers
54
+
if !label.ValueType.IsAnyFormat() && label.ValueType.IsEnum() {
55
+
return fmt.Errorf("enum types cannot be used in conjunction with format specification")
56
+
}
57
+
58
+
if !label.ValueType.IsAnyFormat() && !label.ValueType.IsString() {
59
+
return fmt.Errorf("format specifications are only permitted on string types")
60
+
}
61
+
62
+
// validate scope (nsid format)
63
+
if label.Scope == nil {
64
+
return fmt.Errorf("scope is required")
65
+
}
66
+
for _, s := range label.Scope {
67
+
if _, err := syntax.ParseNSID(s); err != nil {
68
+
return fmt.Errorf("failed to parse scope: %w", err)
69
+
}
70
+
if !slices.Contains(validScopes, s) {
71
+
return fmt.Errorf("invalid scope: scope must be present in %q", validScopes)
72
+
}
73
+
}
74
+
75
+
// validate color if provided
76
+
if label.Color != nil {
77
+
color := strings.TrimSpace(*label.Color)
78
+
if color == "" {
79
+
// empty color is fine, set to nil
80
+
label.Color = nil
81
+
} else {
82
+
if !colorRegex.MatchString(color) {
83
+
return fmt.Errorf("color must be a valid hex color (e.g. #79FFE1 or #000)")
84
+
}
85
+
// expand 3-digit hex to 6-digit hex
86
+
if len(color) == 4 { // #ABC
87
+
color = fmt.Sprintf("#%c%c%c%c%c%c", color[1], color[1], color[2], color[2], color[3], color[3])
88
+
}
89
+
// convert to uppercase for consistency
90
+
color = strings.ToUpper(color)
91
+
label.Color = &color
92
+
}
93
+
}
94
+
95
+
return nil
96
+
}
97
+
98
+
func (v *Validator) ValidateLabelOp(labelDef *models.LabelDefinition, repo *models.Repo, labelOp *models.LabelOp) error {
99
+
if labelDef == nil {
100
+
return fmt.Errorf("label definition is required")
101
+
}
102
+
if repo == nil {
103
+
return fmt.Errorf("repo is required")
104
+
}
105
+
if labelOp == nil {
106
+
return fmt.Errorf("label operation is required")
107
+
}
108
+
109
+
// validate permissions: only collaborators can apply labels currently
110
+
//
111
+
// TODO: introduce a repo:triage permission
112
+
ok, err := v.enforcer.IsPushAllowed(labelOp.Did, repo.Knot, repo.DidSlashRepo())
113
+
if err != nil {
114
+
return fmt.Errorf("failed to enforce permissions: %w", err)
115
+
}
116
+
if !ok {
117
+
return fmt.Errorf("unauhtorized label operation")
118
+
}
119
+
120
+
expectedKey := labelDef.AtUri().String()
121
+
if labelOp.OperandKey != expectedKey {
122
+
return fmt.Errorf("operand key %q does not match label definition URI %q", labelOp.OperandKey, expectedKey)
123
+
}
124
+
125
+
if labelOp.Operation != models.LabelOperationAdd && labelOp.Operation != models.LabelOperationDel {
126
+
return fmt.Errorf("invalid operation: %q (must be 'add' or 'del')", labelOp.Operation)
127
+
}
128
+
129
+
if labelOp.Subject == "" {
130
+
return fmt.Errorf("subject URI is required")
131
+
}
132
+
if _, err := syntax.ParseATURI(string(labelOp.Subject)); err != nil {
133
+
return fmt.Errorf("invalid subject URI: %w", err)
134
+
}
135
+
136
+
if err := v.validateOperandValue(labelDef, labelOp); err != nil {
137
+
return fmt.Errorf("invalid operand value: %w", err)
138
+
}
139
+
140
+
// Validate performed time is not zero/invalid
141
+
if labelOp.PerformedAt.IsZero() {
142
+
return fmt.Errorf("performed_at timestamp is required")
143
+
}
144
+
145
+
return nil
146
+
}
147
+
148
+
func (v *Validator) validateOperandValue(labelDef *models.LabelDefinition, labelOp *models.LabelOp) error {
149
+
valueType := labelDef.ValueType
150
+
151
+
// this is permitted, it "unsets" a label
152
+
if labelOp.OperandValue == "" {
153
+
labelOp.Operation = models.LabelOperationDel
154
+
return nil
155
+
}
156
+
157
+
switch valueType.Type {
158
+
case models.ConcreteTypeNull:
159
+
// For null type, value should be empty
160
+
if labelOp.OperandValue != "null" {
161
+
return fmt.Errorf("null type requires empty value, got %q", labelOp.OperandValue)
162
+
}
163
+
164
+
case models.ConcreteTypeString:
165
+
// For string type, validate enum constraints if present
166
+
if valueType.IsEnum() {
167
+
if !slices.Contains(valueType.Enum, labelOp.OperandValue) {
168
+
return fmt.Errorf("value %q is not in allowed enum values %v", labelOp.OperandValue, valueType.Enum)
169
+
}
170
+
}
171
+
172
+
switch valueType.Format {
173
+
case models.ValueTypeFormatDid:
174
+
id, err := v.resolver.ResolveIdent(context.Background(), labelOp.OperandValue)
175
+
if err != nil {
176
+
return fmt.Errorf("failed to resolve did/handle: %w", err)
177
+
}
178
+
179
+
labelOp.OperandValue = id.DID.String()
180
+
181
+
case models.ValueTypeFormatAny, "":
182
+
default:
183
+
return fmt.Errorf("unsupported format constraint: %q", valueType.Format)
184
+
}
185
+
186
+
case models.ConcreteTypeInt:
187
+
if labelOp.OperandValue == "" {
188
+
return fmt.Errorf("integer type requires non-empty value")
189
+
}
190
+
if _, err := fmt.Sscanf(labelOp.OperandValue, "%d", new(int)); err != nil {
191
+
return fmt.Errorf("value %q is not a valid integer", labelOp.OperandValue)
192
+
}
193
+
194
+
if valueType.IsEnum() {
195
+
if !slices.Contains(valueType.Enum, labelOp.OperandValue) {
196
+
return fmt.Errorf("value %q is not in allowed enum values %v", labelOp.OperandValue, valueType.Enum)
197
+
}
198
+
}
199
+
200
+
case models.ConcreteTypeBool:
201
+
if labelOp.OperandValue != "true" && labelOp.OperandValue != "false" {
202
+
return fmt.Errorf("boolean type requires value to be 'true' or 'false', got %q", labelOp.OperandValue)
203
+
}
204
+
205
+
// validate enum constraints if present (though uncommon for booleans)
206
+
if valueType.IsEnum() {
207
+
if !slices.Contains(valueType.Enum, labelOp.OperandValue) {
208
+
return fmt.Errorf("value %q is not in allowed enum values %v", labelOp.OperandValue, valueType.Enum)
209
+
}
210
+
}
211
+
212
+
default:
213
+
return fmt.Errorf("unsupported value type: %q", valueType.Type)
214
+
}
215
+
216
+
return nil
217
+
}
+27
appview/validator/string.go
+27
appview/validator/string.go
···
···
1
+
package validator
2
+
3
+
import (
4
+
"errors"
5
+
"fmt"
6
+
"unicode/utf8"
7
+
8
+
"tangled.org/core/appview/models"
9
+
)
10
+
11
+
func (v *Validator) ValidateString(s *models.String) error {
12
+
var err error
13
+
14
+
if utf8.RuneCountInString(s.Filename) > 140 {
15
+
err = errors.Join(err, fmt.Errorf("filename too long"))
16
+
}
17
+
18
+
if utf8.RuneCountInString(s.Description) > 280 {
19
+
err = errors.Join(err, fmt.Errorf("description too long"))
20
+
}
21
+
22
+
if len(s.Contents) == 0 {
23
+
err = errors.Join(err, fmt.Errorf("contents is empty"))
24
+
}
25
+
26
+
return err
27
+
}
+9
-3
appview/validator/validator.go
+9
-3
appview/validator/validator.go
···
1
package validator
2
3
import (
4
-
"tangled.sh/tangled.sh/core/appview/db"
5
-
"tangled.sh/tangled.sh/core/appview/pages/markup"
6
)
7
8
type Validator struct {
9
db *db.DB
10
sanitizer markup.Sanitizer
11
}
12
13
-
func New(db *db.DB) *Validator {
14
return &Validator{
15
db: db,
16
sanitizer: markup.NewSanitizer(),
17
}
18
}
···
1
package validator
2
3
import (
4
+
"tangled.org/core/appview/db"
5
+
"tangled.org/core/appview/pages/markup"
6
+
"tangled.org/core/idresolver"
7
+
"tangled.org/core/rbac"
8
)
9
10
type Validator struct {
11
db *db.DB
12
sanitizer markup.Sanitizer
13
+
resolver *idresolver.Resolver
14
+
enforcer *rbac.Enforcer
15
}
16
17
+
func New(db *db.DB, res *idresolver.Resolver, enforcer *rbac.Enforcer) *Validator {
18
return &Validator{
19
db: db,
20
sanitizer: markup.NewSanitizer(),
21
+
resolver: res,
22
+
enforcer: enforcer,
23
}
24
}
+2
-2
cmd/appview/main.go
+2
-2
cmd/appview/main.go
+1
-1
cmd/combinediff/main.go
+1
-1
cmd/combinediff/main.go
+6
-2
cmd/gen.go
+6
-2
cmd/gen.go
···
2
3
import (
4
cbg "github.com/whyrusleeping/cbor-gen"
5
-
"tangled.sh/tangled.sh/core/api/tangled"
6
)
7
8
func main() {
···
20
tangled.GitRefUpdate{},
21
tangled.GitRefUpdate_CommitCountBreakdown{},
22
tangled.GitRefUpdate_IndividualEmailCommitCount{},
23
-
tangled.GitRefUpdate_LangBreakdown{},
24
tangled.GitRefUpdate_IndividualLanguageSize{},
25
tangled.GitRefUpdate_Meta{},
26
tangled.GraphFollow{},
27
tangled.Knot{},
28
tangled.KnotMember{},
29
tangled.Pipeline{},
30
tangled.Pipeline_CloneOpts{},
31
tangled.Pipeline_ManualTriggerData{},
···
2
3
import (
4
cbg "github.com/whyrusleeping/cbor-gen"
5
+
"tangled.org/core/api/tangled"
6
)
7
8
func main() {
···
20
tangled.GitRefUpdate{},
21
tangled.GitRefUpdate_CommitCountBreakdown{},
22
tangled.GitRefUpdate_IndividualEmailCommitCount{},
23
tangled.GitRefUpdate_IndividualLanguageSize{},
24
+
tangled.GitRefUpdate_LangBreakdown{},
25
tangled.GitRefUpdate_Meta{},
26
tangled.GraphFollow{},
27
tangled.Knot{},
28
tangled.KnotMember{},
29
+
tangled.LabelDefinition{},
30
+
tangled.LabelDefinition_ValueType{},
31
+
tangled.LabelOp{},
32
+
tangled.LabelOp_Operand{},
33
tangled.Pipeline{},
34
tangled.Pipeline_CloneOpts{},
35
tangled.Pipeline_ManualTriggerData{},
+1
-1
cmd/interdiff/main.go
+1
-1
cmd/interdiff/main.go
+5
-5
cmd/knot/main.go
+5
-5
cmd/knot/main.go
+3
-3
cmd/spindle/main.go
+3
-3
cmd/spindle/main.go
+1
-1
cmd/verifysig/main.go
+1
-1
cmd/verifysig/main.go
+9
consts/consts.go
+9
consts/consts.go
+44
contrib/Tiltfile
+44
contrib/Tiltfile
···
···
1
+
common_env = {
2
+
"TANGLED_VM_SPINDLE_OWNER": os.getenv("TANGLED_VM_SPINDLE_OWNER", default=""),
3
+
"TANGLED_VM_KNOT_OWNER": os.getenv("TANGLED_VM_KNOT_OWNER", default=""),
4
+
"TANGLED_DB_PATH": os.getenv("TANGLED_DB_PATH", default="dev.db"),
5
+
"TANGLED_DEV": os.getenv("TANGLED_DEV", default="true"),
6
+
}
7
+
8
+
nix_globs = ["nix/**", "flake.nix", "flake.lock"]
9
+
10
+
local_resource(
11
+
name="appview",
12
+
serve_cmd="nix run .#watch-appview",
13
+
serve_dir="..",
14
+
deps=nix_globs,
15
+
env=common_env,
16
+
allow_parallel=True,
17
+
)
18
+
19
+
local_resource(
20
+
name="tailwind",
21
+
serve_cmd="nix run .#watch-tailwind",
22
+
serve_dir="..",
23
+
deps=nix_globs,
24
+
env=common_env,
25
+
allow_parallel=True,
26
+
)
27
+
28
+
local_resource(
29
+
name="redis",
30
+
serve_cmd="redis-server",
31
+
serve_dir="..",
32
+
deps=nix_globs,
33
+
env=common_env,
34
+
allow_parallel=True,
35
+
)
36
+
37
+
local_resource(
38
+
name="vm",
39
+
serve_cmd="nix run --impure .#vm",
40
+
serve_dir="..",
41
+
deps=nix_globs,
42
+
env=common_env,
43
+
allow_parallel=True,
44
+
)
+1
-1
crypto/verify.go
+1
-1
crypto/verify.go
+16
default.nix
+16
default.nix
···
···
1
+
# Default setup from https://git.lix.systems/lix-project/flake-compat
2
+
let
3
+
lockFile = builtins.fromJSON (builtins.readFile ./flake.lock);
4
+
flake-compat-node = lockFile.nodes.${lockFile.nodes.root.inputs.flake-compat};
5
+
flake-compat = builtins.fetchTarball {
6
+
inherit (flake-compat-node.locked) url;
7
+
sha256 = flake-compat-node.locked.narHash;
8
+
};
9
+
10
+
flake = (
11
+
import flake-compat {
12
+
src = ./.;
13
+
}
14
+
);
15
+
in
16
+
flake.defaultNix
+2
-2
docs/knot-hosting.md
+2
-2
docs/knot-hosting.md
···
19
First, clone this repository:
20
21
```
22
-
git clone https://tangled.sh/@tangled.sh/core
23
```
24
25
Then, build the `knot` CLI. This is the knot administration and operation tool.
···
130
131
You should now have a running knot server! You can finalize
132
your registration by hitting the `verify` button on the
133
-
[/knots](https://tangled.sh/knots) page. This simply creates
134
a record on your PDS to announce the existence of the knot.
135
136
### custom paths
···
19
First, clone this repository:
20
21
```
22
+
git clone https://tangled.org/@tangled.org/core
23
```
24
25
Then, build the `knot` CLI. This is the knot administration and operation tool.
···
130
131
You should now have a running knot server! You can finalize
132
your registration by hitting the `verify` button on the
133
+
[/knots](https://tangled.org/knots) page. This simply creates
134
a record on your PDS to announce the existence of the knot.
135
136
### custom paths
-35
docs/migrations/knot-1.7.0.md
-35
docs/migrations/knot-1.7.0.md
···
1
-
# Upgrading from v1.7.0
2
-
3
-
After v1.7.0, knot secrets have been deprecated. You no
4
-
longer need a secret from the appview to run a knot. All
5
-
authorized commands to knots are managed via [Inter-Service
6
-
Authentication](https://atproto.com/specs/xrpc#inter-service-authentication-jwt).
7
-
Knots will be read-only until upgraded.
8
-
9
-
Upgrading is quite easy, in essence:
10
-
11
-
- `KNOT_SERVER_SECRET` is no more, you can remove this
12
-
environment variable entirely
13
-
- `KNOT_SERVER_OWNER` is now required on boot, set this to
14
-
your DID. You can find your DID in the
15
-
[settings](https://tangled.sh/settings) page.
16
-
- Restart your knot once you have replaced the environment
17
-
variable
18
-
- Head to the [knot dashboard](https://tangled.sh/knots) and
19
-
hit the "retry" button to verify your knot. This simply
20
-
writes a `sh.tangled.knot` record to your PDS.
21
-
22
-
## Nix
23
-
24
-
If you use the nix module, simply bump the flake to the
25
-
latest revision, and change your config block like so:
26
-
27
-
```diff
28
-
services.tangled-knot = {
29
-
enable = true;
30
-
server = {
31
-
- secretFile = /path/to/secret;
32
-
+ owner = "did:plc:foo";
33
-
};
34
-
};
35
-
```
···
+59
docs/migrations.md
+59
docs/migrations.md
···
···
1
+
# Migrations
2
+
3
+
This document is laid out in reverse-chronological order.
4
+
Newer migration guides are listed first, and older guides
5
+
are further down the page.
6
+
7
+
## Upgrading from v1.8.x
8
+
9
+
After v1.8.2, the HTTP API for knot and spindles have been
10
+
deprecated and replaced with XRPC. Repositories on outdated
11
+
knots will not be viewable from the appview. Upgrading is
12
+
straightforward however.
13
+
14
+
For knots:
15
+
16
+
- Upgrade to latest tag (v1.9.0 or above)
17
+
- Head to the [knot dashboard](https://tangled.org/knots) and
18
+
hit the "retry" button to verify your knot
19
+
20
+
For spindles:
21
+
22
+
- Upgrade to latest tag (v1.9.0 or above)
23
+
- Head to the [spindle
24
+
dashboard](https://tangled.org/spindles) and hit the
25
+
"retry" button to verify your spindle
26
+
27
+
## Upgrading from v1.7.x
28
+
29
+
After v1.7.0, knot secrets have been deprecated. You no
30
+
longer need a secret from the appview to run a knot. All
31
+
authorized commands to knots are managed via [Inter-Service
32
+
Authentication](https://atproto.com/specs/xrpc#inter-service-authentication-jwt).
33
+
Knots will be read-only until upgraded.
34
+
35
+
Upgrading is quite easy, in essence:
36
+
37
+
- `KNOT_SERVER_SECRET` is no more, you can remove this
38
+
environment variable entirely
39
+
- `KNOT_SERVER_OWNER` is now required on boot, set this to
40
+
your DID. You can find your DID in the
41
+
[settings](https://tangled.org/settings) page.
42
+
- Restart your knot once you have replaced the environment
43
+
variable
44
+
- Head to the [knot dashboard](https://tangled.org/knots) and
45
+
hit the "retry" button to verify your knot. This simply
46
+
writes a `sh.tangled.knot` record to your PDS.
47
+
48
+
If you use the nix module, simply bump the flake to the
49
+
latest revision, and change your config block like so:
50
+
51
+
```diff
52
+
services.tangled-knot = {
53
+
enable = true;
54
+
server = {
55
+
- secretFile = /path/to/secret;
56
+
+ owner = "did:plc:foo";
57
+
};
58
+
};
59
+
```
+1
-1
docs/spindle/openbao.md
+1
-1
docs/spindle/openbao.md
+3
-3
docs/spindle/pipeline.md
+3
-3
docs/spindle/pipeline.md
···
21
- `manual`: The workflow can be triggered manually.
22
- `branch`: This is a **required** field that defines which branches the workflow should run for. If used with the `push` event, commits to the branch(es) listed here will trigger the workflow. If used with the `pull_request` event, updates to pull requests targeting the branch(es) listed here will trigger the workflow. This field has no effect with the `manual` event.
23
24
-
For example, if you'd like define a workflow that runs when commits are pushed to the `main` and `develop` branches, or when pull requests that target the `main` branch are updated, or manually, you can do so with:
25
26
```yaml
27
when:
···
73
- nodejs
74
- go
75
# custom registry
76
-
git+https://tangled.sh/@example.com/my_pkg:
77
- my_pkg
78
```
79
···
141
- nodejs
142
- go
143
# custom registry
144
-
git+https://tangled.sh/@example.com/my_pkg:
145
- my_pkg
146
147
environment:
···
21
- `manual`: The workflow can be triggered manually.
22
- `branch`: This is a **required** field that defines which branches the workflow should run for. If used with the `push` event, commits to the branch(es) listed here will trigger the workflow. If used with the `pull_request` event, updates to pull requests targeting the branch(es) listed here will trigger the workflow. This field has no effect with the `manual` event.
23
24
+
For example, if you'd like to define a workflow that runs when commits are pushed to the `main` and `develop` branches, or when pull requests that target the `main` branch are updated, or manually, you can do so with:
25
26
```yaml
27
when:
···
73
- nodejs
74
- go
75
# custom registry
76
+
git+https://tangled.org/@example.com/my_pkg:
77
- my_pkg
78
```
79
···
141
- nodejs
142
- go
143
# custom registry
144
+
git+https://tangled.org/@example.com/my_pkg:
145
- my_pkg
146
147
environment:
+2
-2
eventconsumer/consumer.go
+2
-2
eventconsumer/consumer.go
+1
-1
eventconsumer/cursor/redis.go
+1
-1
eventconsumer/cursor/redis.go
+15
flake.lock
+15
flake.lock
···
1
{
2
"nodes": {
3
+
"flake-compat": {
4
+
"flake": false,
5
+
"locked": {
6
+
"lastModified": 1751685974,
7
+
"narHash": "sha256-NKw96t+BgHIYzHUjkTK95FqYRVKB8DHpVhefWSz/kTw=",
8
+
"rev": "549f2762aebeff29a2e5ece7a7dc0f955281a1d1",
9
+
"type": "tarball",
10
+
"url": "https://git.lix.systems/api/v1/repos/lix-project/flake-compat/archive/549f2762aebeff29a2e5ece7a7dc0f955281a1d1.tar.gz?rev=549f2762aebeff29a2e5ece7a7dc0f955281a1d1"
11
+
},
12
+
"original": {
13
+
"type": "tarball",
14
+
"url": "https://git.lix.systems/lix-project/flake-compat/archive/main.tar.gz"
15
+
}
16
+
},
17
"flake-utils": {
18
"inputs": {
19
"systems": "systems"
···
150
},
151
"root": {
152
"inputs": {
153
+
"flake-compat": "flake-compat",
154
"gomod2nix": "gomod2nix",
155
"htmx-src": "htmx-src",
156
"htmx-ws-src": "htmx-ws-src",
+7
-1
flake.nix
+7
-1
flake.nix
···
7
url = "github:nix-community/gomod2nix";
8
inputs.nixpkgs.follows = "nixpkgs";
9
};
10
indigo = {
11
url = "github:oppiliappan/indigo";
12
flake = false;
···
50
inter-fonts-src,
51
sqlite-lib-src,
52
ibm-plex-mono-src,
53
}: let
54
supportedSystems = ["x86_64-linux" "x86_64-darwin" "aarch64-linux" "aarch64-darwin"];
55
forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
···
146
nativeBuildInputs = [
147
pkgs.go
148
pkgs.air
149
pkgs.gopls
150
pkgs.httpie
151
pkgs.litecli
···
182
tailwind-watcher =
183
pkgs.writeShellScriptBin "run"
184
''
185
-
${pkgs.tailwindcss}/bin/tailwindcss -w -i input.css -o ./appview/pages/static/tw.css
186
'';
187
in {
188
fmt = {
···
7
url = "github:nix-community/gomod2nix";
8
inputs.nixpkgs.follows = "nixpkgs";
9
};
10
+
flake-compat = {
11
+
url = "https://git.lix.systems/lix-project/flake-compat/archive/main.tar.gz";
12
+
flake = false;
13
+
};
14
indigo = {
15
url = "github:oppiliappan/indigo";
16
flake = false;
···
54
inter-fonts-src,
55
sqlite-lib-src,
56
ibm-plex-mono-src,
57
+
...
58
}: let
59
supportedSystems = ["x86_64-linux" "x86_64-darwin" "aarch64-linux" "aarch64-darwin"];
60
forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
···
151
nativeBuildInputs = [
152
pkgs.go
153
pkgs.air
154
+
pkgs.tilt
155
pkgs.gopls
156
pkgs.httpie
157
pkgs.litecli
···
188
tailwind-watcher =
189
pkgs.writeShellScriptBin "run"
190
''
191
+
${pkgs.tailwindcss}/bin/tailwindcss --watch=always -i input.css -o ./appview/pages/static/tw.css
192
'';
193
in {
194
fmt = {
+2
-2
go.mod
+2
-2
go.mod
···
1
-
module tangled.sh/tangled.sh/core
2
3
go 1.24.4
4
···
43
github.com/yuin/goldmark v1.7.12
44
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
45
golang.org/x/crypto v0.40.0
46
golang.org/x/net v0.42.0
47
golang.org/x/sync v0.16.0
48
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da
···
168
go.uber.org/atomic v1.11.0 // indirect
169
go.uber.org/multierr v1.11.0 // indirect
170
go.uber.org/zap v1.27.0 // indirect
171
-
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
172
golang.org/x/sys v0.34.0 // indirect
173
golang.org/x/text v0.27.0 // indirect
174
golang.org/x/time v0.12.0 // indirect
···
1
+
module tangled.org/core
2
3
go 1.24.4
4
···
43
github.com/yuin/goldmark v1.7.12
44
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
45
golang.org/x/crypto v0.40.0
46
+
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b
47
golang.org/x/net v0.42.0
48
golang.org/x/sync v0.16.0
49
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da
···
169
go.uber.org/atomic v1.11.0 // indirect
170
go.uber.org/multierr v1.11.0 // indirect
171
go.uber.org/zap v1.27.0 // indirect
172
golang.org/x/sys v0.34.0 // indirect
173
golang.org/x/text v0.27.0 // indirect
174
golang.org/x/time v0.12.0 // indirect
+2
-2
guard/guard.go
+2
-2
guard/guard.go
+2
-5
input.css
+2
-5
input.css
···
228
}
229
/* LineHighlight */
230
.chroma .hl {
231
-
background-color: #bcc0cc;
232
}
233
/* LineNumbersTable */
234
.chroma .lnt {
235
white-space: pre;
···
864
text-decoration: underline;
865
}
866
}
867
-
868
-
.chroma .line:has(.ln:target) {
869
-
@apply bg-amber-400/30 dark:bg-amber-500/20;
870
-
}
+1
-1
jetstream/jetstream.go
+1
-1
jetstream/jetstream.go
+1
-1
keyfetch/keyfetch.go
+1
-1
keyfetch/keyfetch.go
+1
-1
knotserver/config/config.go
+1
-1
knotserver/config/config.go
+1
-1
knotserver/db/events.go
+1
-1
knotserver/db/events.go
+1
-1
knotserver/db/pubkeys.go
+1
-1
knotserver/db/pubkeys.go
+1
-1
knotserver/git/branch.go
+1
-1
knotserver/git/branch.go
+2
-2
knotserver/git/diff.go
+2
-2
knotserver/git/diff.go
-103
knotserver/git/git.go
-103
knotserver/git/git.go
···
27
h plumbing.Hash
28
}
29
30
-
type TagList struct {
31
-
refs []*TagReference
32
-
r *git.Repository
33
-
}
34
-
35
-
// TagReference is used to list both tag and non-annotated tags.
36
-
// Non-annotated tags should only contains a reference.
37
-
// Annotated tags should contain its reference and its tag information.
38
-
type TagReference struct {
39
-
ref *plumbing.Reference
40
-
tag *object.Tag
41
-
}
42
-
43
// infoWrapper wraps the property of a TreeEntry so it can export fs.FileInfo
44
// to tar WriteHeader
45
type infoWrapper struct {
···
48
mode fs.FileMode
49
modTime time.Time
50
isDir bool
51
-
}
52
-
53
-
func (self *TagList) Len() int {
54
-
return len(self.refs)
55
-
}
56
-
57
-
func (self *TagList) Swap(i, j int) {
58
-
self.refs[i], self.refs[j] = self.refs[j], self.refs[i]
59
-
}
60
-
61
-
// sorting tags in reverse chronological order
62
-
func (self *TagList) Less(i, j int) bool {
63
-
var dateI time.Time
64
-
var dateJ time.Time
65
-
66
-
if self.refs[i].tag != nil {
67
-
dateI = self.refs[i].tag.Tagger.When
68
-
} else {
69
-
c, err := self.r.CommitObject(self.refs[i].ref.Hash())
70
-
if err != nil {
71
-
dateI = time.Now()
72
-
} else {
73
-
dateI = c.Committer.When
74
-
}
75
-
}
76
-
77
-
if self.refs[j].tag != nil {
78
-
dateJ = self.refs[j].tag.Tagger.When
79
-
} else {
80
-
c, err := self.r.CommitObject(self.refs[j].ref.Hash())
81
-
if err != nil {
82
-
dateJ = time.Now()
83
-
} else {
84
-
dateJ = c.Committer.When
85
-
}
86
-
}
87
-
88
-
return dateI.After(dateJ)
89
}
90
91
func Open(path string, ref string) (*GitRepo, error) {
···
171
return g.r.CommitObject(h)
172
}
173
174
-
func (g *GitRepo) LastCommit() (*object.Commit, error) {
175
-
c, err := g.r.CommitObject(g.h)
176
-
if err != nil {
177
-
return nil, fmt.Errorf("last commit: %w", err)
178
-
}
179
-
return c, nil
180
-
}
181
-
182
func (g *GitRepo) FileContentN(path string, cap int64) ([]byte, error) {
183
c, err := g.r.CommitObject(g.h)
184
if err != nil {
···
211
}
212
213
return buf.Bytes(), nil
214
-
}
215
-
216
-
func (g *GitRepo) FileContent(path string) (string, error) {
217
-
c, err := g.r.CommitObject(g.h)
218
-
if err != nil {
219
-
return "", fmt.Errorf("commit object: %w", err)
220
-
}
221
-
222
-
tree, err := c.Tree()
223
-
if err != nil {
224
-
return "", fmt.Errorf("file tree: %w", err)
225
-
}
226
-
227
-
file, err := tree.File(path)
228
-
if err != nil {
229
-
return "", err
230
-
}
231
-
232
-
isbin, _ := file.IsBinary()
233
-
234
-
if !isbin {
235
-
return file.Contents()
236
-
} else {
237
-
return "", ErrBinaryFile
238
-
}
239
}
240
241
func (g *GitRepo) RawContent(path string) ([]byte, error) {
···
410
func (i *infoWrapper) Sys() any {
411
return nil
412
}
413
-
414
-
func (t *TagReference) Name() string {
415
-
return t.ref.Name().Short()
416
-
}
417
-
418
-
func (t *TagReference) Message() string {
419
-
if t.tag != nil {
420
-
return t.tag.Message
421
-
}
422
-
return ""
423
-
}
424
-
425
-
func (t *TagReference) TagObject() *object.Tag {
426
-
return t.tag
427
-
}
428
-
429
-
func (t *TagReference) Hash() plumbing.Hash {
430
-
return t.ref.Hash()
431
-
}
···
27
h plumbing.Hash
28
}
29
30
// infoWrapper wraps the property of a TreeEntry so it can export fs.FileInfo
31
// to tar WriteHeader
32
type infoWrapper struct {
···
35
mode fs.FileMode
36
modTime time.Time
37
isDir bool
38
}
39
40
func Open(path string, ref string) (*GitRepo, error) {
···
120
return g.r.CommitObject(h)
121
}
122
123
func (g *GitRepo) FileContentN(path string, cap int64) ([]byte, error) {
124
c, err := g.r.CommitObject(g.h)
125
if err != nil {
···
152
}
153
154
return buf.Bytes(), nil
155
}
156
157
func (g *GitRepo) RawContent(path string) ([]byte, error) {
···
326
func (i *infoWrapper) Sys() any {
327
return nil
328
}
+4
-1
knotserver/git/language.go
+4
-1
knotserver/git/language.go
···
3
import (
4
"context"
5
"path"
6
+
"strings"
7
8
"github.com/go-enry/go-enry/v2"
9
"github.com/go-git/go-git/v5/plumbing/object"
···
21
return nil
22
}
23
24
+
if enry.IsGenerated(filepath, content) ||
25
+
enry.IsBinary(content) ||
26
+
strings.HasSuffix(filepath, "bun.lock") {
27
return nil
28
}
29
+1
-1
knotserver/git/post_receive.go
+1
-1
knotserver/git/post_receive.go
+1
-3
knotserver/git/tag.go
+1
-3
knotserver/git/tag.go
···
2
3
import (
4
"fmt"
5
-
"slices"
6
"strconv"
7
"strings"
8
"time"
···
35
outFormat.WriteString("")
36
outFormat.WriteString(recordSeparator)
37
38
-
output, err := g.forEachRef(outFormat.String(), "refs/tags")
39
if err != nil {
40
return nil, fmt.Errorf("failed to get tags: %w", err)
41
}
···
94
tags = append(tags, tag)
95
}
96
97
-
slices.Reverse(tags)
98
return tags, nil
99
}
···
2
3
import (
4
"fmt"
5
"strconv"
6
"strings"
7
"time"
···
34
outFormat.WriteString("")
35
outFormat.WriteString(recordSeparator)
36
37
+
output, err := g.forEachRef(outFormat.String(), "--sort=-creatordate", "refs/tags")
38
if err != nil {
39
return nil, fmt.Errorf("failed to get tags: %w", err)
40
}
···
93
tags = append(tags, tag)
94
}
95
96
return tags, nil
97
}
+1
-1
knotserver/git/tree.go
+1
-1
knotserver/git/tree.go
+1
-1
knotserver/git.go
+1
-1
knotserver/git.go
-4
knotserver/http_util.go
-4
knotserver/http_util.go
+10
-10
knotserver/ingester.go
+10
-10
knotserver/ingester.go
···
15
"github.com/bluesky-social/indigo/xrpc"
16
"github.com/bluesky-social/jetstream/pkg/models"
17
securejoin "github.com/cyphar/filepath-securejoin"
18
-
"tangled.sh/tangled.sh/core/api/tangled"
19
-
"tangled.sh/tangled.sh/core/idresolver"
20
-
"tangled.sh/tangled.sh/core/knotserver/db"
21
-
"tangled.sh/tangled.sh/core/knotserver/git"
22
-
"tangled.sh/tangled.sh/core/log"
23
-
"tangled.sh/tangled.sh/core/rbac"
24
-
"tangled.sh/tangled.sh/core/workflow"
25
)
26
27
func (h *Knot) processPublicKey(ctx context.Context, event *models.Event) error {
···
141
return fmt.Errorf("rejected pull record: not this knot, %s != %s", repo.Knot, h.c.Server.Hostname)
142
}
143
144
-
didSlashRepo, err := securejoin.SecureJoin(repo.Owner, repo.Name)
145
if err != nil {
146
return fmt.Errorf("failed to construct relative repo path: %w", err)
147
}
···
151
return fmt.Errorf("failed to construct absolute repo path: %w", err)
152
}
153
154
-
gr, err := git.Open(repoPath, record.Source.Branch)
155
if err != nil {
156
return fmt.Errorf("failed to open git repository: %w", err)
157
}
···
191
Kind: string(workflow.TriggerKindPullRequest),
192
PullRequest: &trigger,
193
Repo: &tangled.Pipeline_TriggerRepo{
194
-
Did: repo.Owner,
195
Knot: repo.Knot,
196
Repo: repo.Name,
197
},
···
15
"github.com/bluesky-social/indigo/xrpc"
16
"github.com/bluesky-social/jetstream/pkg/models"
17
securejoin "github.com/cyphar/filepath-securejoin"
18
+
"tangled.org/core/api/tangled"
19
+
"tangled.org/core/idresolver"
20
+
"tangled.org/core/knotserver/db"
21
+
"tangled.org/core/knotserver/git"
22
+
"tangled.org/core/log"
23
+
"tangled.org/core/rbac"
24
+
"tangled.org/core/workflow"
25
)
26
27
func (h *Knot) processPublicKey(ctx context.Context, event *models.Event) error {
···
141
return fmt.Errorf("rejected pull record: not this knot, %s != %s", repo.Knot, h.c.Server.Hostname)
142
}
143
144
+
didSlashRepo, err := securejoin.SecureJoin(ident.DID.String(), repo.Name)
145
if err != nil {
146
return fmt.Errorf("failed to construct relative repo path: %w", err)
147
}
···
151
return fmt.Errorf("failed to construct absolute repo path: %w", err)
152
}
153
154
+
gr, err := git.Open(repoPath, record.Source.Sha)
155
if err != nil {
156
return fmt.Errorf("failed to open git repository: %w", err)
157
}
···
191
Kind: string(workflow.TriggerKindPullRequest),
192
PullRequest: &trigger,
193
Repo: &tangled.Pipeline_TriggerRepo{
194
+
Did: ident.DID.String(),
195
Knot: repo.Knot,
196
Repo: repo.Name,
197
},
+8
-8
knotserver/internal.go
+8
-8
knotserver/internal.go
···
13
securejoin "github.com/cyphar/filepath-securejoin"
14
"github.com/go-chi/chi/v5"
15
"github.com/go-chi/chi/v5/middleware"
16
-
"tangled.sh/tangled.sh/core/api/tangled"
17
-
"tangled.sh/tangled.sh/core/hook"
18
-
"tangled.sh/tangled.sh/core/knotserver/config"
19
-
"tangled.sh/tangled.sh/core/knotserver/db"
20
-
"tangled.sh/tangled.sh/core/knotserver/git"
21
-
"tangled.sh/tangled.sh/core/notifier"
22
-
"tangled.sh/tangled.sh/core/rbac"
23
-
"tangled.sh/tangled.sh/core/workflow"
24
)
25
26
type InternalHandle struct {
···
13
securejoin "github.com/cyphar/filepath-securejoin"
14
"github.com/go-chi/chi/v5"
15
"github.com/go-chi/chi/v5/middleware"
16
+
"tangled.org/core/api/tangled"
17
+
"tangled.org/core/hook"
18
+
"tangled.org/core/knotserver/config"
19
+
"tangled.org/core/knotserver/db"
20
+
"tangled.org/core/knotserver/git"
21
+
"tangled.org/core/notifier"
22
+
"tangled.org/core/rbac"
23
+
"tangled.org/core/workflow"
24
)
25
26
type InternalHandle struct {
+9
-9
knotserver/router.go
+9
-9
knotserver/router.go
···
7
"net/http"
8
9
"github.com/go-chi/chi/v5"
10
-
"tangled.sh/tangled.sh/core/idresolver"
11
-
"tangled.sh/tangled.sh/core/jetstream"
12
-
"tangled.sh/tangled.sh/core/knotserver/config"
13
-
"tangled.sh/tangled.sh/core/knotserver/db"
14
-
"tangled.sh/tangled.sh/core/knotserver/xrpc"
15
-
tlog "tangled.sh/tangled.sh/core/log"
16
-
"tangled.sh/tangled.sh/core/notifier"
17
-
"tangled.sh/tangled.sh/core/rbac"
18
-
"tangled.sh/tangled.sh/core/xrpc/serviceauth"
19
)
20
21
type Knot struct {
···
7
"net/http"
8
9
"github.com/go-chi/chi/v5"
10
+
"tangled.org/core/idresolver"
11
+
"tangled.org/core/jetstream"
12
+
"tangled.org/core/knotserver/config"
13
+
"tangled.org/core/knotserver/db"
14
+
"tangled.org/core/knotserver/xrpc"
15
+
tlog "tangled.org/core/log"
16
+
"tangled.org/core/notifier"
17
+
"tangled.org/core/rbac"
18
+
"tangled.org/core/xrpc/serviceauth"
19
)
20
21
type Knot struct {
+8
-8
knotserver/server.go
+8
-8
knotserver/server.go
···
6
"net/http"
7
8
"github.com/urfave/cli/v3"
9
-
"tangled.sh/tangled.sh/core/api/tangled"
10
-
"tangled.sh/tangled.sh/core/hook"
11
-
"tangled.sh/tangled.sh/core/jetstream"
12
-
"tangled.sh/tangled.sh/core/knotserver/config"
13
-
"tangled.sh/tangled.sh/core/knotserver/db"
14
-
"tangled.sh/tangled.sh/core/log"
15
-
"tangled.sh/tangled.sh/core/notifier"
16
-
"tangled.sh/tangled.sh/core/rbac"
17
)
18
19
func Command() *cli.Command {
···
6
"net/http"
7
8
"github.com/urfave/cli/v3"
9
+
"tangled.org/core/api/tangled"
10
+
"tangled.org/core/hook"
11
+
"tangled.org/core/jetstream"
12
+
"tangled.org/core/knotserver/config"
13
+
"tangled.org/core/knotserver/db"
14
+
"tangled.org/core/log"
15
+
"tangled.org/core/notifier"
16
+
"tangled.org/core/rbac"
17
)
18
19
func Command() *cli.Command {
-36
knotserver/util.go
-36
knotserver/util.go
···
1
package knotserver
2
3
import (
4
-
"net/http"
5
-
"os"
6
-
"path/filepath"
7
-
8
"github.com/bluesky-social/indigo/atproto/syntax"
9
-
securejoin "github.com/cyphar/filepath-securejoin"
10
-
"github.com/go-chi/chi/v5"
11
)
12
-
13
-
func didPath(r *http.Request) string {
14
-
did := chi.URLParam(r, "did")
15
-
name := chi.URLParam(r, "name")
16
-
path, _ := securejoin.SecureJoin(did, name)
17
-
filepath.Clean(path)
18
-
return path
19
-
}
20
-
21
-
func getDescription(path string) (desc string) {
22
-
db, err := os.ReadFile(filepath.Join(path, "description"))
23
-
if err == nil {
24
-
desc = string(db)
25
-
} else {
26
-
desc = ""
27
-
}
28
-
return
29
-
}
30
-
func setContentDisposition(w http.ResponseWriter, name string) {
31
-
h := "inline; filename=\"" + name + "\""
32
-
w.Header().Add("Content-Disposition", h)
33
-
}
34
-
35
-
func setGZipMIME(w http.ResponseWriter) {
36
-
setMIME(w, "application/gzip")
37
-
}
38
-
39
-
func setMIME(w http.ResponseWriter, mime string) {
40
-
w.Header().Add("Content-Type", mime)
41
-
}
42
43
var TIDClock = syntax.NewTIDClock(0)
44
+5
-5
knotserver/xrpc/create_repo.go
+5
-5
knotserver/xrpc/create_repo.go
···
13
"github.com/bluesky-social/indigo/xrpc"
14
securejoin "github.com/cyphar/filepath-securejoin"
15
gogit "github.com/go-git/go-git/v5"
16
-
"tangled.sh/tangled.sh/core/api/tangled"
17
-
"tangled.sh/tangled.sh/core/hook"
18
-
"tangled.sh/tangled.sh/core/knotserver/git"
19
-
"tangled.sh/tangled.sh/core/rbac"
20
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
21
)
22
23
func (h *Xrpc) CreateRepo(w http.ResponseWriter, r *http.Request) {
···
13
"github.com/bluesky-social/indigo/xrpc"
14
securejoin "github.com/cyphar/filepath-securejoin"
15
gogit "github.com/go-git/go-git/v5"
16
+
"tangled.org/core/api/tangled"
17
+
"tangled.org/core/hook"
18
+
"tangled.org/core/knotserver/git"
19
+
"tangled.org/core/rbac"
20
+
xrpcerr "tangled.org/core/xrpc/errors"
21
)
22
23
func (h *Xrpc) CreateRepo(w http.ResponseWriter, r *http.Request) {
+3
-3
knotserver/xrpc/delete_repo.go
+3
-3
knotserver/xrpc/delete_repo.go
···
11
"github.com/bluesky-social/indigo/atproto/syntax"
12
"github.com/bluesky-social/indigo/xrpc"
13
securejoin "github.com/cyphar/filepath-securejoin"
14
-
"tangled.sh/tangled.sh/core/api/tangled"
15
-
"tangled.sh/tangled.sh/core/rbac"
16
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
17
)
18
19
func (x *Xrpc) DeleteRepo(w http.ResponseWriter, r *http.Request) {
···
11
"github.com/bluesky-social/indigo/atproto/syntax"
12
"github.com/bluesky-social/indigo/xrpc"
13
securejoin "github.com/cyphar/filepath-securejoin"
14
+
"tangled.org/core/api/tangled"
15
+
"tangled.org/core/rbac"
16
+
xrpcerr "tangled.org/core/xrpc/errors"
17
)
18
19
func (x *Xrpc) DeleteRepo(w http.ResponseWriter, r *http.Request) {
+5
-5
knotserver/xrpc/fork_status.go
+5
-5
knotserver/xrpc/fork_status.go
···
8
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
securejoin "github.com/cyphar/filepath-securejoin"
11
-
"tangled.sh/tangled.sh/core/api/tangled"
12
-
"tangled.sh/tangled.sh/core/knotserver/git"
13
-
"tangled.sh/tangled.sh/core/rbac"
14
-
"tangled.sh/tangled.sh/core/types"
15
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
16
)
17
18
func (x *Xrpc) ForkStatus(w http.ResponseWriter, r *http.Request) {
···
8
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
securejoin "github.com/cyphar/filepath-securejoin"
11
+
"tangled.org/core/api/tangled"
12
+
"tangled.org/core/knotserver/git"
13
+
"tangled.org/core/rbac"
14
+
"tangled.org/core/types"
15
+
xrpcerr "tangled.org/core/xrpc/errors"
16
)
17
18
func (x *Xrpc) ForkStatus(w http.ResponseWriter, r *http.Request) {
+4
-4
knotserver/xrpc/fork_sync.go
+4
-4
knotserver/xrpc/fork_sync.go
···
8
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
securejoin "github.com/cyphar/filepath-securejoin"
11
-
"tangled.sh/tangled.sh/core/api/tangled"
12
-
"tangled.sh/tangled.sh/core/knotserver/git"
13
-
"tangled.sh/tangled.sh/core/rbac"
14
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
15
)
16
17
func (x *Xrpc) ForkSync(w http.ResponseWriter, r *http.Request) {
···
8
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
securejoin "github.com/cyphar/filepath-securejoin"
11
+
"tangled.org/core/api/tangled"
12
+
"tangled.org/core/knotserver/git"
13
+
"tangled.org/core/rbac"
14
+
xrpcerr "tangled.org/core/xrpc/errors"
15
)
16
17
func (x *Xrpc) ForkSync(w http.ResponseWriter, r *http.Request) {
+3
-12
knotserver/xrpc/list_keys.go
+3
-12
knotserver/xrpc/list_keys.go
···
1
package xrpc
2
3
import (
4
-
"encoding/json"
5
"net/http"
6
"strconv"
7
8
-
"tangled.sh/tangled.sh/core/api/tangled"
9
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
10
)
11
12
func (x *Xrpc) ListKeys(w http.ResponseWriter, r *http.Request) {
···
46
response.Cursor = &nextCursor
47
}
48
49
-
w.Header().Set("Content-Type", "application/json")
50
-
if err := json.NewEncoder(w).Encode(response); err != nil {
51
-
x.Logger.Error("failed to encode response", "error", err)
52
-
writeError(w, xrpcerr.NewXrpcError(
53
-
xrpcerr.WithTag("InternalServerError"),
54
-
xrpcerr.WithMessage("failed to encode response"),
55
-
), http.StatusInternalServerError)
56
-
return
57
-
}
58
}
+6
-6
knotserver/xrpc/merge.go
+6
-6
knotserver/xrpc/merge.go
···
8
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
securejoin "github.com/cyphar/filepath-securejoin"
11
-
"tangled.sh/tangled.sh/core/api/tangled"
12
-
"tangled.sh/tangled.sh/core/knotserver/git"
13
-
"tangled.sh/tangled.sh/core/patchutil"
14
-
"tangled.sh/tangled.sh/core/rbac"
15
-
"tangled.sh/tangled.sh/core/types"
16
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
17
)
18
19
func (x *Xrpc) Merge(w http.ResponseWriter, r *http.Request) {
···
8
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
securejoin "github.com/cyphar/filepath-securejoin"
11
+
"tangled.org/core/api/tangled"
12
+
"tangled.org/core/knotserver/git"
13
+
"tangled.org/core/patchutil"
14
+
"tangled.org/core/rbac"
15
+
"tangled.org/core/types"
16
+
xrpcerr "tangled.org/core/xrpc/errors"
17
)
18
19
func (x *Xrpc) Merge(w http.ResponseWriter, r *http.Request) {
+3
-3
knotserver/xrpc/merge_check.go
+3
-3
knotserver/xrpc/merge_check.go
+3
-12
knotserver/xrpc/owner.go
+3
-12
knotserver/xrpc/owner.go
···
1
package xrpc
2
3
import (
4
-
"encoding/json"
5
"net/http"
6
7
-
"tangled.sh/tangled.sh/core/api/tangled"
8
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
9
)
10
11
func (x *Xrpc) Owner(w http.ResponseWriter, r *http.Request) {
···
19
Owner: owner,
20
}
21
22
-
w.Header().Set("Content-Type", "application/json")
23
-
if err := json.NewEncoder(w).Encode(response); err != nil {
24
-
x.Logger.Error("failed to encode response", "error", err)
25
-
writeError(w, xrpcerr.NewXrpcError(
26
-
xrpcerr.WithTag("InternalServerError"),
27
-
xrpcerr.WithMessage("failed to encode response"),
28
-
), http.StatusInternalServerError)
29
-
return
30
-
}
31
}
+10
-9
knotserver/xrpc/repo_archive.go
+10
-9
knotserver/xrpc/repo_archive.go
···
8
9
"github.com/go-git/go-git/v5/plumbing"
10
11
-
"tangled.sh/tangled.sh/core/knotserver/git"
12
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
13
)
14
15
func (x *Xrpc) RepoArchive(w http.ResponseWriter, r *http.Request) {
16
-
repo, repoPath, unescapedRef, err := x.parseStandardParams(r)
17
if err != nil {
18
writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest)
19
return
20
}
21
22
format := r.URL.Query().Get("format")
23
if format == "" {
···
34
return
35
}
36
37
-
gr, err := git.Open(repoPath, unescapedRef)
38
if err != nil {
39
-
writeError(w, xrpcerr.NewXrpcError(
40
-
xrpcerr.WithTag("RefNotFound"),
41
-
xrpcerr.WithMessage("repository or ref not found"),
42
-
), http.StatusNotFound)
43
return
44
}
45
46
repoParts := strings.Split(repo, "/")
47
repoName := repoParts[len(repoParts)-1]
48
49
-
safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(unescapedRef).Short(), "/", "-")
50
51
var archivePrefix string
52
if prefix != "" {
···
8
9
"github.com/go-git/go-git/v5/plumbing"
10
11
+
"tangled.org/core/knotserver/git"
12
+
xrpcerr "tangled.org/core/xrpc/errors"
13
)
14
15
func (x *Xrpc) RepoArchive(w http.ResponseWriter, r *http.Request) {
16
+
repo := r.URL.Query().Get("repo")
17
+
repoPath, err := x.parseRepoParam(repo)
18
if err != nil {
19
writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest)
20
return
21
}
22
+
23
+
ref := r.URL.Query().Get("ref")
24
+
// ref can be empty (git.Open handles this)
25
26
format := r.URL.Query().Get("format")
27
if format == "" {
···
38
return
39
}
40
41
+
gr, err := git.Open(repoPath, ref)
42
if err != nil {
43
+
writeError(w, xrpcerr.RefNotFoundError, http.StatusNotFound)
44
return
45
}
46
47
repoParts := strings.Split(repo, "/")
48
repoName := repoParts[len(repoParts)-1]
49
50
+
safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(ref).Short(), "/", "-")
51
52
var archivePrefix string
53
if prefix != "" {
+12
-19
knotserver/xrpc/repo_blob.go
+12
-19
knotserver/xrpc/repo_blob.go
···
3
import (
4
"crypto/sha256"
5
"encoding/base64"
6
-
"encoding/json"
7
"fmt"
8
"net/http"
9
"path/filepath"
10
"slices"
11
"strings"
12
13
-
"tangled.sh/tangled.sh/core/api/tangled"
14
-
"tangled.sh/tangled.sh/core/knotserver/git"
15
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
16
)
17
18
func (x *Xrpc) RepoBlob(w http.ResponseWriter, r *http.Request) {
19
-
_, repoPath, ref, err := x.parseStandardParams(r)
20
if err != nil {
21
writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest)
22
return
23
}
24
25
treePath := r.URL.Query().Get("path")
26
if treePath == "" {
···
35
36
gr, err := git.Open(repoPath, ref)
37
if err != nil {
38
-
writeError(w, xrpcerr.NewXrpcError(
39
-
xrpcerr.WithTag("RefNotFound"),
40
-
xrpcerr.WithMessage("repository or ref not found"),
41
-
), http.StatusNotFound)
42
return
43
}
44
45
contents, err := gr.RawContent(treePath)
46
if err != nil {
47
-
x.Logger.Error("file content", "error", err.Error())
48
writeError(w, xrpcerr.NewXrpcError(
49
xrpcerr.WithTag("FileNotFound"),
50
xrpcerr.WithMessage("file not found at the specified path"),
···
69
return
70
}
71
w.Header().Set("ETag", eTag)
72
73
case strings.HasPrefix(mimeType, "text/"):
74
w.Header().Set("Cache-Control", "public, no-cache")
···
122
response.MimeType = &mimeType
123
}
124
125
-
w.Header().Set("Content-Type", "application/json")
126
-
if err := json.NewEncoder(w).Encode(response); err != nil {
127
-
x.Logger.Error("failed to encode response", "error", err)
128
-
writeError(w, xrpcerr.NewXrpcError(
129
-
xrpcerr.WithTag("InternalServerError"),
130
-
xrpcerr.WithMessage("failed to encode response"),
131
-
), http.StatusInternalServerError)
132
-
return
133
-
}
134
}
135
136
// isTextualMimeType returns true if the MIME type represents textual content
···
3
import (
4
"crypto/sha256"
5
"encoding/base64"
6
"fmt"
7
"net/http"
8
"path/filepath"
9
"slices"
10
"strings"
11
12
+
"tangled.org/core/api/tangled"
13
+
"tangled.org/core/knotserver/git"
14
+
xrpcerr "tangled.org/core/xrpc/errors"
15
)
16
17
func (x *Xrpc) RepoBlob(w http.ResponseWriter, r *http.Request) {
18
+
repo := r.URL.Query().Get("repo")
19
+
repoPath, err := x.parseRepoParam(repo)
20
if err != nil {
21
writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest)
22
return
23
}
24
+
25
+
ref := r.URL.Query().Get("ref")
26
+
// ref can be empty (git.Open handles this)
27
28
treePath := r.URL.Query().Get("path")
29
if treePath == "" {
···
38
39
gr, err := git.Open(repoPath, ref)
40
if err != nil {
41
+
writeError(w, xrpcerr.RefNotFoundError, http.StatusNotFound)
42
return
43
}
44
45
contents, err := gr.RawContent(treePath)
46
if err != nil {
47
+
x.Logger.Error("file content", "error", err.Error(), "treePath", treePath)
48
writeError(w, xrpcerr.NewXrpcError(
49
xrpcerr.WithTag("FileNotFound"),
50
xrpcerr.WithMessage("file not found at the specified path"),
···
69
return
70
}
71
w.Header().Set("ETag", eTag)
72
+
w.Header().Set("Content-Type", mimeType)
73
74
case strings.HasPrefix(mimeType, "text/"):
75
w.Header().Set("Cache-Control", "public, no-cache")
···
123
response.MimeType = &mimeType
124
}
125
126
+
writeJson(w, response)
127
}
128
129
// isTextualMimeType returns true if the MIME type represents textual content
+8
-19
knotserver/xrpc/repo_branch.go
+8
-19
knotserver/xrpc/repo_branch.go
···
1
package xrpc
2
3
import (
4
-
"encoding/json"
5
"net/http"
6
"net/url"
7
8
-
"tangled.sh/tangled.sh/core/api/tangled"
9
-
"tangled.sh/tangled.sh/core/knotserver/git"
10
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
11
)
12
13
func (x *Xrpc) RepoBranch(w http.ResponseWriter, r *http.Request) {
···
31
32
gr, err := git.PlainOpen(repoPath)
33
if err != nil {
34
-
writeError(w, xrpcerr.NewXrpcError(
35
-
xrpcerr.WithTag("RepoNotFound"),
36
-
xrpcerr.WithMessage("repository not found"),
37
-
), http.StatusNotFound)
38
return
39
}
40
···
70
Name: ref.Name().Short(),
71
Hash: ref.Hash().String(),
72
ShortHash: &[]string{ref.Hash().String()[:7]}[0],
73
-
When: commit.Author.When.Format("2006-01-02T15:04:05.000Z"),
74
IsDefault: &isDefault,
75
}
76
···
81
response.Author = &tangled.RepoBranch_Signature{
82
Name: commit.Author.Name,
83
Email: commit.Author.Email,
84
-
When: commit.Author.When.Format("2006-01-02T15:04:05.000Z"),
85
}
86
87
-
w.Header().Set("Content-Type", "application/json")
88
-
if err := json.NewEncoder(w).Encode(response); err != nil {
89
-
x.Logger.Error("failed to encode response", "error", err)
90
-
writeError(w, xrpcerr.NewXrpcError(
91
-
xrpcerr.WithTag("InternalServerError"),
92
-
xrpcerr.WithMessage("failed to encode response"),
93
-
), http.StatusInternalServerError)
94
-
return
95
-
}
96
}
···
1
package xrpc
2
3
import (
4
"net/http"
5
"net/url"
6
+
"time"
7
8
+
"tangled.org/core/api/tangled"
9
+
"tangled.org/core/knotserver/git"
10
+
xrpcerr "tangled.org/core/xrpc/errors"
11
)
12
13
func (x *Xrpc) RepoBranch(w http.ResponseWriter, r *http.Request) {
···
31
32
gr, err := git.PlainOpen(repoPath)
33
if err != nil {
34
+
writeError(w, xrpcerr.RepoNotFoundError, http.StatusNoContent)
35
return
36
}
37
···
67
Name: ref.Name().Short(),
68
Hash: ref.Hash().String(),
69
ShortHash: &[]string{ref.Hash().String()[:7]}[0],
70
+
When: commit.Author.When.Format(time.RFC3339),
71
IsDefault: &isDefault,
72
}
73
···
78
response.Author = &tangled.RepoBranch_Signature{
79
Name: commit.Author.Name,
80
Email: commit.Author.Email,
81
+
When: commit.Author.When.Format(time.RFC3339),
82
}
83
84
+
writeJson(w, response)
85
}
+14
-28
knotserver/xrpc/repo_branches.go
+14
-28
knotserver/xrpc/repo_branches.go
···
1
package xrpc
2
3
import (
4
-
"encoding/json"
5
"net/http"
6
"strconv"
7
8
-
"tangled.sh/tangled.sh/core/knotserver/git"
9
-
"tangled.sh/tangled.sh/core/types"
10
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
11
)
12
13
func (x *Xrpc) RepoBranches(w http.ResponseWriter, r *http.Request) {
···
20
21
cursor := r.URL.Query().Get("cursor")
22
23
-
limit := 50 // default
24
-
if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
25
-
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
26
-
limit = l
27
-
}
28
-
}
29
30
gr, err := git.PlainOpen(repoPath)
31
if err != nil {
32
-
writeError(w, xrpcerr.NewXrpcError(
33
-
xrpcerr.WithTag("RepoNotFound"),
34
-
xrpcerr.WithMessage("repository not found"),
35
-
), http.StatusNotFound)
36
return
37
}
38
···
45
}
46
}
47
48
-
end := offset + limit
49
-
if end > len(branches) {
50
-
end = len(branches)
51
-
}
52
53
paginatedBranches := branches[offset:end]
54
···
57
Branches: paginatedBranches,
58
}
59
60
-
// Write JSON response directly
61
-
w.Header().Set("Content-Type", "application/json")
62
-
if err := json.NewEncoder(w).Encode(response); err != nil {
63
-
x.Logger.Error("failed to encode response", "error", err)
64
-
writeError(w, xrpcerr.NewXrpcError(
65
-
xrpcerr.WithTag("InternalServerError"),
66
-
xrpcerr.WithMessage("failed to encode response"),
67
-
), http.StatusInternalServerError)
68
-
return
69
-
}
70
}
···
1
package xrpc
2
3
import (
4
"net/http"
5
"strconv"
6
7
+
"tangled.org/core/knotserver/git"
8
+
"tangled.org/core/types"
9
+
xrpcerr "tangled.org/core/xrpc/errors"
10
)
11
12
func (x *Xrpc) RepoBranches(w http.ResponseWriter, r *http.Request) {
···
19
20
cursor := r.URL.Query().Get("cursor")
21
22
+
// limit := 50 // default
23
+
// if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
24
+
// if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
25
+
// limit = l
26
+
// }
27
+
// }
28
+
29
+
limit := 500
30
31
gr, err := git.PlainOpen(repoPath)
32
if err != nil {
33
+
writeError(w, xrpcerr.RepoNotFoundError, http.StatusNoContent)
34
return
35
}
36
···
43
}
44
}
45
46
+
end := min(offset+limit, len(branches))
47
48
paginatedBranches := branches[offset:end]
49
···
52
Branches: paginatedBranches,
53
}
54
55
+
writeJson(w, response)
56
}
+10
-26
knotserver/xrpc/repo_compare.go
+10
-26
knotserver/xrpc/repo_compare.go
···
1
package xrpc
2
3
import (
4
-
"encoding/json"
5
"fmt"
6
"net/http"
7
-
"net/url"
8
9
-
"tangled.sh/tangled.sh/core/knotserver/git"
10
-
"tangled.sh/tangled.sh/core/types"
11
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
12
)
13
14
func (x *Xrpc) RepoCompare(w http.ResponseWriter, r *http.Request) {
···
19
return
20
}
21
22
-
rev1Param := r.URL.Query().Get("rev1")
23
-
if rev1Param == "" {
24
writeError(w, xrpcerr.NewXrpcError(
25
xrpcerr.WithTag("InvalidRequest"),
26
xrpcerr.WithMessage("missing rev1 parameter"),
···
28
return
29
}
30
31
-
rev2Param := r.URL.Query().Get("rev2")
32
-
if rev2Param == "" {
33
writeError(w, xrpcerr.NewXrpcError(
34
xrpcerr.WithTag("InvalidRequest"),
35
xrpcerr.WithMessage("missing rev2 parameter"),
···
37
return
38
}
39
40
-
rev1, _ := url.PathUnescape(rev1Param)
41
-
rev2, _ := url.PathUnescape(rev2Param)
42
-
43
gr, err := git.PlainOpen(repoPath)
44
if err != nil {
45
-
writeError(w, xrpcerr.NewXrpcError(
46
-
xrpcerr.WithTag("RepoNotFound"),
47
-
xrpcerr.WithMessage("repository not found"),
48
-
), http.StatusNotFound)
49
return
50
}
51
···
79
return
80
}
81
82
-
resp := types.RepoFormatPatchResponse{
83
Rev1: commit1.Hash.String(),
84
Rev2: commit2.Hash.String(),
85
FormatPatch: formatPatch,
86
Patch: rawPatch,
87
}
88
89
-
w.Header().Set("Content-Type", "application/json")
90
-
if err := json.NewEncoder(w).Encode(resp); err != nil {
91
-
x.Logger.Error("failed to encode response", "error", err)
92
-
writeError(w, xrpcerr.NewXrpcError(
93
-
xrpcerr.WithTag("InternalServerError"),
94
-
xrpcerr.WithMessage("failed to encode response"),
95
-
), http.StatusInternalServerError)
96
-
return
97
-
}
98
}
···
1
package xrpc
2
3
import (
4
"fmt"
5
"net/http"
6
7
+
"tangled.org/core/knotserver/git"
8
+
"tangled.org/core/types"
9
+
xrpcerr "tangled.org/core/xrpc/errors"
10
)
11
12
func (x *Xrpc) RepoCompare(w http.ResponseWriter, r *http.Request) {
···
17
return
18
}
19
20
+
rev1 := r.URL.Query().Get("rev1")
21
+
if rev1 == "" {
22
writeError(w, xrpcerr.NewXrpcError(
23
xrpcerr.WithTag("InvalidRequest"),
24
xrpcerr.WithMessage("missing rev1 parameter"),
···
26
return
27
}
28
29
+
rev2 := r.URL.Query().Get("rev2")
30
+
if rev2 == "" {
31
writeError(w, xrpcerr.NewXrpcError(
32
xrpcerr.WithTag("InvalidRequest"),
33
xrpcerr.WithMessage("missing rev2 parameter"),
···
35
return
36
}
37
38
gr, err := git.PlainOpen(repoPath)
39
if err != nil {
40
+
writeError(w, xrpcerr.RepoNotFoundError, http.StatusNoContent)
41
return
42
}
43
···
71
return
72
}
73
74
+
response := types.RepoFormatPatchResponse{
75
Rev1: commit1.Hash.String(),
76
Rev2: commit2.Hash.String(),
77
FormatPatch: formatPatch,
78
Patch: rawPatch,
79
}
80
81
+
writeJson(w, response)
82
}
+9
-33
knotserver/xrpc/repo_diff.go
+9
-33
knotserver/xrpc/repo_diff.go
···
1
package xrpc
2
3
import (
4
-
"encoding/json"
5
"net/http"
6
-
"net/url"
7
8
-
"tangled.sh/tangled.sh/core/knotserver/git"
9
-
"tangled.sh/tangled.sh/core/types"
10
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
11
)
12
13
func (x *Xrpc) RepoDiff(w http.ResponseWriter, r *http.Request) {
···
18
return
19
}
20
21
-
refParam := r.URL.Query().Get("ref")
22
-
if refParam == "" {
23
-
writeError(w, xrpcerr.NewXrpcError(
24
-
xrpcerr.WithTag("InvalidRequest"),
25
-
xrpcerr.WithMessage("missing ref parameter"),
26
-
), http.StatusBadRequest)
27
-
return
28
-
}
29
-
30
-
ref, _ := url.QueryUnescape(refParam)
31
32
gr, err := git.Open(repoPath, ref)
33
if err != nil {
34
-
writeError(w, xrpcerr.NewXrpcError(
35
-
xrpcerr.WithTag("RefNotFound"),
36
-
xrpcerr.WithMessage("repository or ref not found"),
37
-
), http.StatusNotFound)
38
return
39
}
40
41
diff, err := gr.Diff()
42
if err != nil {
43
x.Logger.Error("getting diff", "error", err.Error())
44
-
writeError(w, xrpcerr.NewXrpcError(
45
-
xrpcerr.WithTag("RefNotFound"),
46
-
xrpcerr.WithMessage("failed to generate diff"),
47
-
), http.StatusInternalServerError)
48
return
49
}
50
51
-
resp := types.RepoCommitResponse{
52
Ref: ref,
53
Diff: diff,
54
}
55
56
-
w.Header().Set("Content-Type", "application/json")
57
-
if err := json.NewEncoder(w).Encode(resp); err != nil {
58
-
x.Logger.Error("failed to encode response", "error", err)
59
-
writeError(w, xrpcerr.NewXrpcError(
60
-
xrpcerr.WithTag("InternalServerError"),
61
-
xrpcerr.WithMessage("failed to encode response"),
62
-
), http.StatusInternalServerError)
63
-
return
64
-
}
65
}
···
1
package xrpc
2
3
import (
4
"net/http"
5
6
+
"tangled.org/core/knotserver/git"
7
+
"tangled.org/core/types"
8
+
xrpcerr "tangled.org/core/xrpc/errors"
9
)
10
11
func (x *Xrpc) RepoDiff(w http.ResponseWriter, r *http.Request) {
···
16
return
17
}
18
19
+
ref := r.URL.Query().Get("ref")
20
+
// ref can be empty (git.Open handles this)
21
22
gr, err := git.Open(repoPath, ref)
23
if err != nil {
24
+
writeError(w, xrpcerr.RefNotFoundError, http.StatusNotFound)
25
return
26
}
27
28
diff, err := gr.Diff()
29
if err != nil {
30
x.Logger.Error("getting diff", "error", err.Error())
31
+
writeError(w, xrpcerr.RefNotFoundError, http.StatusInternalServerError)
32
return
33
}
34
35
+
response := types.RepoCommitResponse{
36
Ref: ref,
37
Diff: diff,
38
}
39
40
+
writeJson(w, response)
41
}
+7
-22
knotserver/xrpc/repo_get_default_branch.go
+7
-22
knotserver/xrpc/repo_get_default_branch.go
···
1
package xrpc
2
3
import (
4
-
"encoding/json"
5
"net/http"
6
7
-
"tangled.sh/tangled.sh/core/api/tangled"
8
-
"tangled.sh/tangled.sh/core/knotserver/git"
9
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
10
)
11
12
func (x *Xrpc) RepoGetDefaultBranch(w http.ResponseWriter, r *http.Request) {
···
17
return
18
}
19
20
-
gr, err := git.Open(repoPath, "")
21
-
if err != nil {
22
-
writeError(w, xrpcerr.NewXrpcError(
23
-
xrpcerr.WithTag("RepoNotFound"),
24
-
xrpcerr.WithMessage("repository not found"),
25
-
), http.StatusNotFound)
26
-
return
27
-
}
28
29
branch, err := gr.FindMainBranch()
30
if err != nil {
···
39
response := tangled.RepoGetDefaultBranch_Output{
40
Name: branch,
41
Hash: "",
42
-
When: "1970-01-01T00:00:00.000Z",
43
}
44
45
-
w.Header().Set("Content-Type", "application/json")
46
-
if err := json.NewEncoder(w).Encode(response); err != nil {
47
-
x.Logger.Error("failed to encode response", "error", err)
48
-
writeError(w, xrpcerr.NewXrpcError(
49
-
xrpcerr.WithTag("InternalServerError"),
50
-
xrpcerr.WithMessage("failed to encode response"),
51
-
), http.StatusInternalServerError)
52
-
return
53
-
}
54
}
···
1
package xrpc
2
3
import (
4
"net/http"
5
+
"time"
6
7
+
"tangled.org/core/api/tangled"
8
+
"tangled.org/core/knotserver/git"
9
+
xrpcerr "tangled.org/core/xrpc/errors"
10
)
11
12
func (x *Xrpc) RepoGetDefaultBranch(w http.ResponseWriter, r *http.Request) {
···
17
return
18
}
19
20
+
gr, err := git.PlainOpen(repoPath)
21
22
branch, err := gr.FindMainBranch()
23
if err != nil {
···
32
response := tangled.RepoGetDefaultBranch_Output{
33
Name: branch,
34
Hash: "",
35
+
When: time.UnixMicro(0).Format(time.RFC3339),
36
}
37
38
+
writeJson(w, response)
39
}
+7
-24
knotserver/xrpc/repo_languages.go
+7
-24
knotserver/xrpc/repo_languages.go
···
2
3
import (
4
"context"
5
-
"encoding/json"
6
"math"
7
"net/http"
8
-
"net/url"
9
"time"
10
11
-
"tangled.sh/tangled.sh/core/api/tangled"
12
-
"tangled.sh/tangled.sh/core/knotserver/git"
13
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
14
)
15
16
func (x *Xrpc) RepoLanguages(w http.ResponseWriter, r *http.Request) {
17
-
refParam := r.URL.Query().Get("ref")
18
-
if refParam == "" {
19
-
refParam = "HEAD" // default
20
-
}
21
-
ref, _ := url.PathUnescape(refParam)
22
-
23
repo := r.URL.Query().Get("repo")
24
repoPath, err := x.parseRepoParam(repo)
25
if err != nil {
26
writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest)
27
return
28
}
29
30
gr, err := git.Open(repoPath, ref)
31
if err != nil {
32
x.Logger.Error("opening repo", "error", err.Error())
33
-
writeError(w, xrpcerr.NewXrpcError(
34
-
xrpcerr.WithTag("RefNotFound"),
35
-
xrpcerr.WithMessage("repository or ref not found"),
36
-
), http.StatusNotFound)
37
return
38
}
39
···
81
response.TotalFiles = &totalFiles
82
}
83
84
-
w.Header().Set("Content-Type", "application/json")
85
-
if err := json.NewEncoder(w).Encode(response); err != nil {
86
-
x.Logger.Error("failed to encode response", "error", err)
87
-
writeError(w, xrpcerr.NewXrpcError(
88
-
xrpcerr.WithTag("InternalServerError"),
89
-
xrpcerr.WithMessage("failed to encode response"),
90
-
), http.StatusInternalServerError)
91
-
return
92
-
}
93
}
···
2
3
import (
4
"context"
5
"math"
6
"net/http"
7
"time"
8
9
+
"tangled.org/core/api/tangled"
10
+
"tangled.org/core/knotserver/git"
11
+
xrpcerr "tangled.org/core/xrpc/errors"
12
)
13
14
func (x *Xrpc) RepoLanguages(w http.ResponseWriter, r *http.Request) {
15
repo := r.URL.Query().Get("repo")
16
repoPath, err := x.parseRepoParam(repo)
17
if err != nil {
18
writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest)
19
return
20
}
21
+
22
+
ref := r.URL.Query().Get("ref")
23
24
gr, err := git.Open(repoPath, ref)
25
if err != nil {
26
x.Logger.Error("opening repo", "error", err.Error())
27
+
writeError(w, xrpcerr.RefNotFoundError, http.StatusNotFound)
28
return
29
}
30
···
72
response.TotalFiles = &totalFiles
73
}
74
75
+
writeJson(w, response)
76
}
+17
-37
knotserver/xrpc/repo_log.go
+17
-37
knotserver/xrpc/repo_log.go
···
1
package xrpc
2
3
import (
4
-
"encoding/json"
5
"net/http"
6
-
"net/url"
7
"strconv"
8
9
-
"tangled.sh/tangled.sh/core/knotserver/git"
10
-
"tangled.sh/tangled.sh/core/types"
11
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
12
)
13
14
func (x *Xrpc) RepoLog(w http.ResponseWriter, r *http.Request) {
···
19
return
20
}
21
22
-
refParam := r.URL.Query().Get("ref")
23
-
if refParam == "" {
24
-
writeError(w, xrpcerr.NewXrpcError(
25
-
xrpcerr.WithTag("InvalidRequest"),
26
-
xrpcerr.WithMessage("missing ref parameter"),
27
-
), http.StatusBadRequest)
28
-
return
29
-
}
30
31
path := r.URL.Query().Get("path")
32
cursor := r.URL.Query().Get("cursor")
···
38
}
39
}
40
41
-
ref, err := url.QueryUnescape(refParam)
42
-
if err != nil {
43
-
writeError(w, xrpcerr.NewXrpcError(
44
-
xrpcerr.WithTag("InvalidRequest"),
45
-
xrpcerr.WithMessage("invalid ref parameter"),
46
-
), http.StatusBadRequest)
47
-
return
48
-
}
49
-
50
gr, err := git.Open(repoPath, ref)
51
if err != nil {
52
-
writeError(w, xrpcerr.NewXrpcError(
53
-
xrpcerr.WithTag("RefNotFound"),
54
-
xrpcerr.WithMessage("repository or ref not found"),
55
-
), http.StatusNotFound)
56
return
57
}
58
···
69
writeError(w, xrpcerr.NewXrpcError(
70
xrpcerr.WithTag("PathNotFound"),
71
xrpcerr.WithMessage("failed to read commit log"),
72
), http.StatusNotFound)
73
return
74
}
···
79
Ref: ref,
80
Page: (offset / limit) + 1,
81
PerPage: limit,
82
-
Total: len(commits), // This is not accurate for pagination, but matches existing behavior
83
}
84
85
if path != "" {
···
88
89
response.Log = true
90
91
-
// Write JSON response directly
92
-
w.Header().Set("Content-Type", "application/json")
93
-
if err := json.NewEncoder(w).Encode(response); err != nil {
94
-
x.Logger.Error("failed to encode response", "error", err)
95
-
writeError(w, xrpcerr.NewXrpcError(
96
-
xrpcerr.WithTag("InternalServerError"),
97
-
xrpcerr.WithMessage("failed to encode response"),
98
-
), http.StatusInternalServerError)
99
-
return
100
-
}
101
}
···
1
package xrpc
2
3
import (
4
"net/http"
5
"strconv"
6
7
+
"tangled.org/core/knotserver/git"
8
+
"tangled.org/core/types"
9
+
xrpcerr "tangled.org/core/xrpc/errors"
10
)
11
12
func (x *Xrpc) RepoLog(w http.ResponseWriter, r *http.Request) {
···
17
return
18
}
19
20
+
ref := r.URL.Query().Get("ref")
21
22
path := r.URL.Query().Get("path")
23
cursor := r.URL.Query().Get("cursor")
···
29
}
30
}
31
32
gr, err := git.Open(repoPath, ref)
33
if err != nil {
34
+
writeError(w, xrpcerr.RefNotFoundError, http.StatusNotFound)
35
return
36
}
37
···
48
writeError(w, xrpcerr.NewXrpcError(
49
xrpcerr.WithTag("PathNotFound"),
50
xrpcerr.WithMessage("failed to read commit log"),
51
+
), http.StatusNotFound)
52
+
return
53
+
}
54
+
55
+
total, err := gr.TotalCommits()
56
+
if err != nil {
57
+
x.Logger.Error("fetching total commits", "error", err.Error())
58
+
writeError(w, xrpcerr.NewXrpcError(
59
+
xrpcerr.WithTag("InternalServerError"),
60
+
xrpcerr.WithMessage("failed to fetch total commits"),
61
), http.StatusNotFound)
62
return
63
}
···
68
Ref: ref,
69
Page: (offset / limit) + 1,
70
PerPage: limit,
71
+
Total: total,
72
}
73
74
if path != "" {
···
77
78
response.Log = true
79
80
+
writeJson(w, response)
81
}
+33
-36
knotserver/xrpc/repo_tree.go
+33
-36
knotserver/xrpc/repo_tree.go
···
1
package xrpc
2
3
import (
4
-
"encoding/json"
5
"net/http"
6
-
"net/url"
7
"path/filepath"
8
9
-
"tangled.sh/tangled.sh/core/api/tangled"
10
-
"tangled.sh/tangled.sh/core/knotserver/git"
11
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
12
)
13
14
func (x *Xrpc) RepoTree(w http.ResponseWriter, r *http.Request) {
···
21
return
22
}
23
24
-
refParam := r.URL.Query().Get("ref")
25
-
if refParam == "" {
26
-
writeError(w, xrpcerr.NewXrpcError(
27
-
xrpcerr.WithTag("InvalidRequest"),
28
-
xrpcerr.WithMessage("missing ref parameter"),
29
-
), http.StatusBadRequest)
30
-
return
31
-
}
32
33
path := r.URL.Query().Get("path")
34
// path can be empty (defaults to root)
35
36
-
ref, err := url.QueryUnescape(refParam)
37
-
if err != nil {
38
-
writeError(w, xrpcerr.NewXrpcError(
39
-
xrpcerr.WithTag("InvalidRequest"),
40
-
xrpcerr.WithMessage("invalid ref parameter"),
41
-
), http.StatusBadRequest)
42
-
return
43
-
}
44
-
45
gr, err := git.Open(repoPath, ref)
46
if err != nil {
47
x.Logger.Error("failed to open git repository", "error", err, "path", repoPath, "ref", ref)
48
-
writeError(w, xrpcerr.NewXrpcError(
49
-
xrpcerr.WithTag("RefNotFound"),
50
-
xrpcerr.WithMessage("repository or ref not found"),
51
-
), http.StatusNotFound)
52
return
53
}
54
···
62
return
63
}
64
65
// convert NiceTree -> tangled.RepoTree_TreeEntry
66
treeEntries := make([]*tangled.RepoTree_TreeEntry, len(files))
67
for i, file := range files {
···
77
entry.Last_commit = &tangled.RepoTree_LastCommit{
78
Hash: file.LastCommit.Hash.String(),
79
Message: file.LastCommit.Message,
80
-
When: file.LastCommit.When.Format("2006-01-02T15:04:05.000Z"),
81
}
82
}
83
···
102
Parent: parentPtr,
103
Dotdot: dotdotPtr,
104
Files: treeEntries,
105
}
106
107
-
w.Header().Set("Content-Type", "application/json")
108
-
if err := json.NewEncoder(w).Encode(response); err != nil {
109
-
x.Logger.Error("failed to encode response", "error", err)
110
-
writeError(w, xrpcerr.NewXrpcError(
111
-
xrpcerr.WithTag("InternalServerError"),
112
-
xrpcerr.WithMessage("failed to encode response"),
113
-
), http.StatusInternalServerError)
114
-
return
115
-
}
116
}
···
1
package xrpc
2
3
import (
4
"net/http"
5
"path/filepath"
6
+
"time"
7
+
"unicode/utf8"
8
9
+
"tangled.org/core/api/tangled"
10
+
"tangled.org/core/appview/pages/markup"
11
+
"tangled.org/core/knotserver/git"
12
+
xrpcerr "tangled.org/core/xrpc/errors"
13
)
14
15
func (x *Xrpc) RepoTree(w http.ResponseWriter, r *http.Request) {
···
22
return
23
}
24
25
+
ref := r.URL.Query().Get("ref")
26
+
// ref can be empty (git.Open handles this)
27
28
path := r.URL.Query().Get("path")
29
// path can be empty (defaults to root)
30
31
gr, err := git.Open(repoPath, ref)
32
if err != nil {
33
x.Logger.Error("failed to open git repository", "error", err, "path", repoPath, "ref", ref)
34
+
writeError(w, xrpcerr.RefNotFoundError, http.StatusNotFound)
35
return
36
}
37
···
45
return
46
}
47
48
+
// if any of these files are a readme candidate, pass along its blob contents too
49
+
var readmeFileName string
50
+
var readmeContents string
51
+
for _, file := range files {
52
+
if markup.IsReadmeFile(file.Name) {
53
+
contents, err := gr.RawContent(filepath.Join(path, file.Name))
54
+
if err != nil {
55
+
x.Logger.Error("failed to read contents of file", "path", path, "file", file.Name)
56
+
}
57
+
58
+
if utf8.Valid(contents) {
59
+
readmeFileName = file.Name
60
+
readmeContents = string(contents)
61
+
break
62
+
}
63
+
}
64
+
}
65
+
66
// convert NiceTree -> tangled.RepoTree_TreeEntry
67
treeEntries := make([]*tangled.RepoTree_TreeEntry, len(files))
68
for i, file := range files {
···
78
entry.Last_commit = &tangled.RepoTree_LastCommit{
79
Hash: file.LastCommit.Hash.String(),
80
Message: file.LastCommit.Message,
81
+
When: file.LastCommit.When.Format(time.RFC3339),
82
}
83
}
84
···
103
Parent: parentPtr,
104
Dotdot: dotdotPtr,
105
Files: treeEntries,
106
+
Readme: &tangled.RepoTree_Readme{
107
+
Filename: readmeFileName,
108
+
Contents: readmeContents,
109
+
},
110
}
111
112
+
writeJson(w, response)
113
}
+4
-4
knotserver/xrpc/set_default_branch.go
+4
-4
knotserver/xrpc/set_default_branch.go
···
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
"github.com/bluesky-social/indigo/xrpc"
11
securejoin "github.com/cyphar/filepath-securejoin"
12
-
"tangled.sh/tangled.sh/core/api/tangled"
13
-
"tangled.sh/tangled.sh/core/knotserver/git"
14
-
"tangled.sh/tangled.sh/core/rbac"
15
16
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
17
)
18
19
const ActorDid string = "ActorDid"
···
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
"github.com/bluesky-social/indigo/xrpc"
11
securejoin "github.com/cyphar/filepath-securejoin"
12
+
"tangled.org/core/api/tangled"
13
+
"tangled.org/core/knotserver/git"
14
+
"tangled.org/core/rbac"
15
16
+
xrpcerr "tangled.org/core/xrpc/errors"
17
)
18
19
const ActorDid string = "ActorDid"
+3
-13
knotserver/xrpc/version.go
+3
-13
knotserver/xrpc/version.go
···
1
package xrpc
2
3
import (
4
-
"encoding/json"
5
"fmt"
6
"net/http"
7
"runtime/debug"
8
9
-
"tangled.sh/tangled.sh/core/api/tangled"
10
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
11
)
12
13
// version is set during build time.
···
26
var modified bool
27
28
for _, mod := range info.Deps {
29
-
if mod.Path == "tangled.sh/tangled.sh/knotserver/xrpc" {
30
modVer = mod.Version
31
break
32
}
···
58
Version: version,
59
}
60
61
-
w.Header().Set("Content-Type", "application/json")
62
-
if err := json.NewEncoder(w).Encode(response); err != nil {
63
-
x.Logger.Error("failed to encode response", "error", err)
64
-
writeError(w, xrpcerr.NewXrpcError(
65
-
xrpcerr.WithTag("InternalServerError"),
66
-
xrpcerr.WithMessage("failed to encode response"),
67
-
), http.StatusInternalServerError)
68
-
return
69
-
}
70
}
···
1
package xrpc
2
3
import (
4
"fmt"
5
"net/http"
6
"runtime/debug"
7
8
+
"tangled.org/core/api/tangled"
9
)
10
11
// version is set during build time.
···
24
var modified bool
25
26
for _, mod := range info.Deps {
27
+
if mod.Path == "tangled.org/tangled.org/knotserver/xrpc" {
28
modVer = mod.Version
29
break
30
}
···
56
Version: version,
57
}
58
59
+
writeJson(w, response)
60
}
+23
-44
knotserver/xrpc/xrpc.go
+23
-44
knotserver/xrpc/xrpc.go
···
4
"encoding/json"
5
"log/slog"
6
"net/http"
7
-
"net/url"
8
"strings"
9
10
securejoin "github.com/cyphar/filepath-securejoin"
11
-
"tangled.sh/tangled.sh/core/api/tangled"
12
-
"tangled.sh/tangled.sh/core/idresolver"
13
-
"tangled.sh/tangled.sh/core/jetstream"
14
-
"tangled.sh/tangled.sh/core/knotserver/config"
15
-
"tangled.sh/tangled.sh/core/knotserver/db"
16
-
"tangled.sh/tangled.sh/core/notifier"
17
-
"tangled.sh/tangled.sh/core/rbac"
18
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
19
-
"tangled.sh/tangled.sh/core/xrpc/serviceauth"
20
21
"github.com/go-chi/chi/v5"
22
)
···
88
}
89
90
// Parse repo string (did/repoName format)
91
-
parts := strings.Split(repo, "/")
92
-
if len(parts) < 2 {
93
return "", xrpcerr.NewXrpcError(
94
xrpcerr.WithTag("InvalidRequest"),
95
xrpcerr.WithMessage("invalid repo format, expected 'did/repoName'"),
96
)
97
}
98
99
-
did := strings.Join(parts[:len(parts)-1], "/")
100
-
repoName := parts[len(parts)-1]
101
102
// Construct repository path using the same logic as didPath
103
didRepoPath, err := securejoin.SecureJoin(did, repoName)
104
if err != nil {
105
-
return "", xrpcerr.NewXrpcError(
106
-
xrpcerr.WithTag("RepoNotFound"),
107
-
xrpcerr.WithMessage("failed to access repository"),
108
-
)
109
}
110
111
repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, didRepoPath)
112
if err != nil {
113
-
return "", xrpcerr.NewXrpcError(
114
-
xrpcerr.WithTag("RepoNotFound"),
115
-
xrpcerr.WithMessage("failed to access repository"),
116
-
)
117
}
118
119
return repoPath, nil
120
}
121
122
-
// parseStandardParams parses common query parameters used by most handlers
123
-
func (x *Xrpc) parseStandardParams(r *http.Request) (repo, repoPath, ref string, err error) {
124
-
// Parse repo parameter
125
-
repo = r.URL.Query().Get("repo")
126
-
repoPath, err = x.parseRepoParam(repo)
127
-
if err != nil {
128
-
return "", "", "", err
129
-
}
130
-
131
-
// Parse and unescape ref parameter
132
-
refParam := r.URL.Query().Get("ref")
133
-
if refParam == "" {
134
-
return "", "", "", xrpcerr.NewXrpcError(
135
-
xrpcerr.WithTag("InvalidRequest"),
136
-
xrpcerr.WithMessage("missing ref parameter"),
137
-
)
138
-
}
139
-
140
-
ref, _ = url.QueryUnescape(refParam)
141
-
return repo, repoPath, ref, nil
142
-
}
143
-
144
func writeError(w http.ResponseWriter, e xrpcerr.XrpcError, status int) {
145
w.Header().Set("Content-Type", "application/json")
146
w.WriteHeader(status)
147
json.NewEncoder(w).Encode(e)
148
}
···
4
"encoding/json"
5
"log/slog"
6
"net/http"
7
"strings"
8
9
securejoin "github.com/cyphar/filepath-securejoin"
10
+
"tangled.org/core/api/tangled"
11
+
"tangled.org/core/idresolver"
12
+
"tangled.org/core/jetstream"
13
+
"tangled.org/core/knotserver/config"
14
+
"tangled.org/core/knotserver/db"
15
+
"tangled.org/core/notifier"
16
+
"tangled.org/core/rbac"
17
+
xrpcerr "tangled.org/core/xrpc/errors"
18
+
"tangled.org/core/xrpc/serviceauth"
19
20
"github.com/go-chi/chi/v5"
21
)
···
87
}
88
89
// Parse repo string (did/repoName format)
90
+
parts := strings.SplitN(repo, "/", 2)
91
+
if len(parts) != 2 {
92
return "", xrpcerr.NewXrpcError(
93
xrpcerr.WithTag("InvalidRequest"),
94
xrpcerr.WithMessage("invalid repo format, expected 'did/repoName'"),
95
)
96
}
97
98
+
did := parts[0]
99
+
repoName := parts[1]
100
101
// Construct repository path using the same logic as didPath
102
didRepoPath, err := securejoin.SecureJoin(did, repoName)
103
if err != nil {
104
+
return "", xrpcerr.RepoNotFoundError
105
}
106
107
repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, didRepoPath)
108
if err != nil {
109
+
return "", xrpcerr.RepoNotFoundError
110
}
111
112
return repoPath, nil
113
}
114
115
func writeError(w http.ResponseWriter, e xrpcerr.XrpcError, status int) {
116
w.Header().Set("Content-Type", "application/json")
117
w.WriteHeader(status)
118
json.NewEncoder(w).Encode(e)
119
}
120
+
121
+
func writeJson(w http.ResponseWriter, response any) {
122
+
w.Header().Set("Content-Type", "application/json")
123
+
if err := json.NewEncoder(w).Encode(response); err != nil {
124
+
writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError)
125
+
return
126
+
}
127
+
}
-158
legal/privacy.md
-158
legal/privacy.md
···
1
-
# Privacy Policy
2
-
3
-
**Last updated:** January 15, 2025
4
-
5
-
This Privacy Policy describes how Tangled ("we," "us," or "our")
6
-
collects, uses, and shares your personal information when you use our
7
-
platform and services (the "Service").
8
-
9
-
## 1. Information We Collect
10
-
11
-
### Account Information
12
-
13
-
When you create an account, we collect:
14
-
15
-
- Your chosen username
16
-
- Email address
17
-
- Profile information you choose to provide
18
-
- Authentication data
19
-
20
-
### Content and Activity
21
-
22
-
We store:
23
-
24
-
- Code repositories and associated metadata
25
-
- Issues, pull requests, and comments
26
-
- Activity logs and usage patterns
27
-
- Public keys for authentication
28
-
29
-
## 2. Data Location and Hosting
30
-
31
-
### EU Data Hosting
32
-
33
-
**All Tangled service data is hosted within the European Union.**
34
-
Specifically:
35
-
36
-
- **Personal Data Servers (PDS):** Accounts hosted on Tangled PDS
37
-
(*.tngl.sh) are located in Finland
38
-
- **Application Data:** All other service data is stored on EU-based
39
-
servers
40
-
- **Data Processing:** All data processing occurs within EU
41
-
jurisdiction
42
-
43
-
### External PDS Notice
44
-
45
-
**Important:** If your account is hosted on Bluesky's PDS or other
46
-
self-hosted Personal Data Servers (not *.tngl.sh), we do not control
47
-
that data. The data protection, storage location, and privacy
48
-
practices for such accounts are governed by the respective PDS
49
-
provider's policies, not this Privacy Policy. We only control data
50
-
processing within our own services and infrastructure.
51
-
52
-
## 3. Third-Party Data Processors
53
-
54
-
We only share your data with the following third-party processors:
55
-
56
-
### Resend (Email Services)
57
-
58
-
- **Purpose:** Sending transactional emails (account verification,
59
-
notifications)
60
-
- **Data Shared:** Email address and necessary message content
61
-
62
-
### Cloudflare (Image Caching)
63
-
64
-
- **Purpose:** Caching and optimizing image delivery
65
-
- **Data Shared:** Public images and associated metadata for caching
66
-
purposes
67
-
68
-
### Posthog (Usage Metrics Tracking)
69
-
70
-
- **Purpose:** Tracking usage and platform metrics
71
-
- **Data Shared:** Anonymous usage data, IP addresses, DIDs, and browser
72
-
information
73
-
74
-
## 4. How We Use Your Information
75
-
76
-
We use your information to:
77
-
78
-
- Provide and maintain the Service
79
-
- Process your transactions and requests
80
-
- Send you technical notices and support messages
81
-
- Improve and develop new features
82
-
- Ensure security and prevent fraud
83
-
- Comply with legal obligations
84
-
85
-
## 5. Data Sharing and Disclosure
86
-
87
-
We do not sell, trade, or rent your personal information. We may share
88
-
your information only in the following circumstances:
89
-
90
-
- With the third-party processors listed above
91
-
- When required by law or legal process
92
-
- To protect our rights, property, or safety, or that of our users
93
-
- In connection with a merger, acquisition, or sale of assets (with
94
-
appropriate protections)
95
-
96
-
## 6. Data Security
97
-
98
-
We implement appropriate technical and organizational measures to
99
-
protect your personal information against unauthorized access,
100
-
alteration, disclosure, or destruction. However, no method of
101
-
transmission over the Internet is 100% secure.
102
-
103
-
## 7. Data Retention
104
-
105
-
We retain your personal information for as long as necessary to provide
106
-
the Service and fulfill the purposes outlined in this Privacy Policy,
107
-
unless a longer retention period is required by law.
108
-
109
-
## 8. Your Rights
110
-
111
-
Under applicable data protection laws, you have the right to:
112
-
113
-
- Access your personal information
114
-
- Correct inaccurate information
115
-
- Request deletion of your information
116
-
- Object to processing of your information
117
-
- Data portability
118
-
- Withdraw consent (where applicable)
119
-
120
-
## 9. Cookies and Tracking
121
-
122
-
We use cookies and similar technologies to:
123
-
124
-
- Maintain your login session
125
-
- Remember your preferences
126
-
- Analyze usage patterns to improve the Service
127
-
128
-
You can control cookie settings through your browser preferences.
129
-
130
-
## 10. Children's Privacy
131
-
132
-
The Service is not intended for children under 16 years of age. We do
133
-
not knowingly collect personal information from children under 16. If
134
-
we become aware that we have collected such information, we will take
135
-
steps to delete it.
136
-
137
-
## 11. International Data Transfers
138
-
139
-
While all our primary data processing occurs within the EU, some of our
140
-
third-party processors may process data outside the EU. When this
141
-
occurs, we ensure appropriate safeguards are in place, such as Standard
142
-
Contractual Clauses or adequacy decisions.
143
-
144
-
## 12. Changes to This Privacy Policy
145
-
146
-
We may update this Privacy Policy from time to time. We will notify you
147
-
of any changes by posting the new Privacy Policy on this page and
148
-
updating the "Last updated" date.
149
-
150
-
## 13. Contact Information
151
-
152
-
If you have any questions about this Privacy Policy or wish to exercise
153
-
your rights, please contact us through our platform or via email.
154
-
155
-
---
156
-
157
-
This Privacy Policy complies with the EU General Data Protection
158
-
Regulation (GDPR) and other applicable data protection laws.
···
-109
legal/terms.md
-109
legal/terms.md
···
1
-
# Terms of Service
2
-
3
-
**Last updated:** January 15, 2025
4
-
5
-
Welcome to Tangled. These Terms of Service ("Terms") govern your access
6
-
to and use of the Tangled platform and services (the "Service")
7
-
operated by us ("Tangled," "we," "us," or "our").
8
-
9
-
## 1. Acceptance of Terms
10
-
11
-
By accessing or using our Service, you agree to be bound by these Terms.
12
-
If you disagree with any part of these terms, then you may not access
13
-
the Service.
14
-
15
-
## 2. Account Registration
16
-
17
-
To use certain features of the Service, you must register for an
18
-
account. You agree to provide accurate, current, and complete
19
-
information during the registration process and to update such
20
-
information to keep it accurate, current, and complete.
21
-
22
-
## 3. Account Termination
23
-
24
-
> **Important Notice**
25
-
>
26
-
> **We reserve the right to terminate, suspend, or restrict access to
27
-
> your account at any time, for any reason, or for no reason at all, at
28
-
> our sole discretion.** This includes, but is not limited to,
29
-
> termination for violation of these Terms, inappropriate conduct, spam,
30
-
> abuse, or any other behavior we deem harmful to the Service or other
31
-
> users.
32
-
>
33
-
> Account termination may result in the loss of access to your
34
-
> repositories, data, and other content associated with your account. We
35
-
> are not obligated to provide advance notice of termination, though we
36
-
> may do so in our discretion.
37
-
38
-
## 4. Acceptable Use
39
-
40
-
You agree not to use the Service to:
41
-
42
-
- Violate any applicable laws or regulations
43
-
- Infringe upon the rights of others
44
-
- Upload, store, or share content that is illegal, harmful, threatening,
45
-
abusive, harassing, defamatory, vulgar, obscene, or otherwise
46
-
objectionable
47
-
- Engage in spam, phishing, or other deceptive practices
48
-
- Attempt to gain unauthorized access to the Service or other users'
49
-
accounts
50
-
- Interfere with or disrupt the Service or servers connected to the
51
-
Service
52
-
53
-
## 5. Content and Intellectual Property
54
-
55
-
You retain ownership of the content you upload to the Service. By
56
-
uploading content, you grant us a non-exclusive, worldwide, royalty-free
57
-
license to use, reproduce, modify, and distribute your content as
58
-
necessary to provide the Service.
59
-
60
-
## 6. Privacy
61
-
62
-
Your privacy is important to us. Please review our [Privacy
63
-
Policy](/privacy), which also governs your use of the Service.
64
-
65
-
## 7. Disclaimers
66
-
67
-
The Service is provided on an "AS IS" and "AS AVAILABLE" basis. We make
68
-
no warranties, expressed or implied, and hereby disclaim and negate all
69
-
other warranties including without limitation, implied warranties or
70
-
conditions of merchantability, fitness for a particular purpose, or
71
-
non-infringement of intellectual property or other violation of rights.
72
-
73
-
## 8. Limitation of Liability
74
-
75
-
In no event shall Tangled, nor its directors, employees, partners,
76
-
agents, suppliers, or affiliates, be liable for any indirect,
77
-
incidental, special, consequential, or punitive damages, including
78
-
without limitation, loss of profits, data, use, goodwill, or other
79
-
intangible losses, resulting from your use of the Service.
80
-
81
-
## 9. Indemnification
82
-
83
-
You agree to defend, indemnify, and hold harmless Tangled and its
84
-
affiliates, officers, directors, employees, and agents from and against
85
-
any and all claims, damages, obligations, losses, liabilities, costs,
86
-
or debt, and expenses (including attorney's fees).
87
-
88
-
## 10. Governing Law
89
-
90
-
These Terms shall be interpreted and governed by the laws of Finland,
91
-
without regard to its conflict of law provisions.
92
-
93
-
## 11. Changes to Terms
94
-
95
-
We reserve the right to modify or replace these Terms at any time. If a
96
-
revision is material, we will try to provide at least 30 days notice
97
-
prior to any new terms taking effect.
98
-
99
-
## 12. Contact Information
100
-
101
-
If you have any questions about these Terms of Service, please contact
102
-
us through our platform or via email.
103
-
104
-
---
105
-
106
-
These terms are effective as of the last updated date shown above and
107
-
will remain in effect except with respect to any changes in their
108
-
provisions in the future, which will be in effect immediately after
109
-
being posted on this page.
···
+1
-1
lexicon-build-config.json
+1
-1
lexicon-build-config.json
+89
lexicons/label/definition.json
+89
lexicons/label/definition.json
···
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.label.definition",
4
+
"needsCbor": true,
5
+
"needsType": true,
6
+
"defs": {
7
+
"main": {
8
+
"type": "record",
9
+
"key": "any",
10
+
"record": {
11
+
"type": "object",
12
+
"required": [
13
+
"name",
14
+
"valueType",
15
+
"scope",
16
+
"createdAt"
17
+
],
18
+
"properties": {
19
+
"name": {
20
+
"type": "string",
21
+
"description": "The display name of this label.",
22
+
"minGraphemes": 1,
23
+
"maxGraphemes": 40
24
+
},
25
+
"valueType": {
26
+
"type": "ref",
27
+
"ref": "#valueType",
28
+
"description": "The type definition of this label. Appviews may allow sorting for certain types."
29
+
},
30
+
"scope": {
31
+
"type": "array",
32
+
"description": "The areas of the repo this label may apply to, eg.: sh.tangled.repo.issue. Appviews may choose to respect this.",
33
+
"items": {
34
+
"type": "string",
35
+
"format": "nsid"
36
+
}
37
+
},
38
+
"color": {
39
+
"type": "string",
40
+
"description": "The hex value for the background color for the label. Appviews may choose to respect this."
41
+
},
42
+
"createdAt": {
43
+
"type": "string",
44
+
"format": "datetime"
45
+
},
46
+
"multiple": {
47
+
"type": "boolean",
48
+
"description": "Whether this label can be repeated for a given entity, eg.: [reviewer:foo, reviewer:bar]"
49
+
}
50
+
}
51
+
}
52
+
},
53
+
"valueType": {
54
+
"type": "object",
55
+
"required": [
56
+
"type",
57
+
"format"
58
+
],
59
+
"properties": {
60
+
"type": {
61
+
"type": "string",
62
+
"enum": [
63
+
"null",
64
+
"boolean",
65
+
"integer",
66
+
"string"
67
+
],
68
+
"description": "The concrete type of this label's value."
69
+
},
70
+
"format": {
71
+
"type": "string",
72
+
"enum": [
73
+
"any",
74
+
"did",
75
+
"nsid"
76
+
],
77
+
"description": "An optional constraint that can be applied on string concrete types."
78
+
},
79
+
"enum": {
80
+
"type": "array",
81
+
"description": "Closed set of values that this label can take.",
82
+
"items": {
83
+
"type": "string"
84
+
}
85
+
}
86
+
}
87
+
}
88
+
}
89
+
}
+64
lexicons/label/op.json
+64
lexicons/label/op.json
···
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.label.op",
4
+
"needsCbor": true,
5
+
"needsType": true,
6
+
"defs": {
7
+
"main": {
8
+
"type": "record",
9
+
"key": "tid",
10
+
"record": {
11
+
"type": "object",
12
+
"required": [
13
+
"subject",
14
+
"add",
15
+
"delete",
16
+
"performedAt"
17
+
],
18
+
"properties": {
19
+
"subject": {
20
+
"type": "string",
21
+
"format": "at-uri",
22
+
"description": "The subject (task, pull or discussion) of this label. Appviews may apply a `scope` check and refuse this op."
23
+
},
24
+
"performedAt": {
25
+
"type": "string",
26
+
"format": "datetime"
27
+
},
28
+
"add": {
29
+
"type": "array",
30
+
"items": {
31
+
"type": "ref",
32
+
"ref": "#operand"
33
+
}
34
+
},
35
+
"delete": {
36
+
"type": "array",
37
+
"items": {
38
+
"type": "ref",
39
+
"ref": "#operand"
40
+
}
41
+
}
42
+
}
43
+
}
44
+
},
45
+
"operand": {
46
+
"type": "object",
47
+
"required": [
48
+
"key",
49
+
"value"
50
+
],
51
+
"properties": {
52
+
"key": {
53
+
"type": "string",
54
+
"format": "at-uri",
55
+
"description": "ATURI to the label definition"
56
+
},
57
+
"value": {
58
+
"type": "string",
59
+
"description": "Stringified value of the label. This is first unstringed by appviews and then interpreted as a concrete value."
60
+
}
61
+
}
62
+
}
63
+
}
64
+
}
+8
-5
lexicons/repo/repo.json
+8
-5
lexicons/repo/repo.json
···
12
"required": [
13
"name",
14
"knot",
15
-
"owner",
16
"createdAt"
17
],
18
"properties": {
19
"name": {
20
"type": "string",
21
"description": "name of the repo"
22
-
},
23
-
"owner": {
24
-
"type": "string",
25
-
"format": "did"
26
},
27
"knot": {
28
"type": "string",
···
41
"type": "string",
42
"format": "uri",
43
"description": "source of the repo"
44
},
45
"createdAt": {
46
"type": "string",
···
12
"required": [
13
"name",
14
"knot",
15
"createdAt"
16
],
17
"properties": {
18
"name": {
19
"type": "string",
20
"description": "name of the repo"
21
},
22
"knot": {
23
"type": "string",
···
36
"type": "string",
37
"format": "uri",
38
"description": "source of the repo"
39
+
},
40
+
"labels": {
41
+
"type": "array",
42
+
"description": "List of labels that this repo subscribes to",
43
+
"items": {
44
+
"type": "string",
45
+
"format": "at-uri"
46
+
}
47
},
48
"createdAt": {
49
"type": "string",
+19
lexicons/repo/tree.json
+19
lexicons/repo/tree.json
···
41
"type": "string",
42
"description": "Parent directory path"
43
},
44
+
"readme": {
45
+
"type": "ref",
46
+
"ref": "#readme",
47
+
"description": "Readme for this file tree"
48
+
},
49
"files": {
50
"type": "array",
51
"items": {
···
74
"description": "Invalid request parameters"
75
}
76
]
77
+
},
78
+
"readme": {
79
+
"type": "object",
80
+
"required": ["filename", "contents"],
81
+
"properties": {
82
+
"filename": {
83
+
"type": "string",
84
+
"description": "Name of the readme file"
85
+
},
86
+
"contents": {
87
+
"type": "string",
88
+
"description": "Contents of the readme file"
89
+
}
90
+
}
91
},
92
"treeEntry": {
93
"type": "object",
+8
-2
nix/gomod2nix.toml
+8
-2
nix/gomod2nix.toml
···
425
[mod."github.com/whyrusleeping/cbor-gen"]
426
version = "v0.3.1"
427
hash = "sha256-PAd8M2Z8t6rVRBII+Rg8Bz+QaJIwbW64bfyqsv31kgc="
428
[mod."github.com/yuin/goldmark"]
429
-
version = "v1.4.15"
430
-
hash = "sha256-MvSOT6dwf5hVYkIg4MnqMpsy5ZtWZ7amAE7Zo9HkEa0="
431
[mod."github.com/yuin/goldmark-highlighting/v2"]
432
version = "v2.0.0-20230729083705-37449abec8cc"
433
hash = "sha256-HpiwU7jIeDUAg2zOpTIiviQir8dpRPuXYh2nqFFccpg="
···
425
[mod."github.com/whyrusleeping/cbor-gen"]
426
version = "v0.3.1"
427
hash = "sha256-PAd8M2Z8t6rVRBII+Rg8Bz+QaJIwbW64bfyqsv31kgc="
428
+
[mod."github.com/wyatt915/goldmark-treeblood"]
429
+
version = "v0.0.0-20250825231212-5dcbdb2f4b57"
430
+
hash = "sha256-IZEsUXTBTsNgWoD7vqRUc9aFCCHNjzk1IUmI9O+NCnM="
431
+
[mod."github.com/wyatt915/treeblood"]
432
+
version = "v0.1.15"
433
+
hash = "sha256-hb99exdkoY2Qv8WdDxhwgPXGbEYimUr6wFtPXEvcO9g="
434
[mod."github.com/yuin/goldmark"]
435
+
version = "v1.7.12"
436
+
hash = "sha256-thLYBS4woL2X5qRdo7vP+xCvjlGRDU0jXtDCUt6vvWM="
437
[mod."github.com/yuin/goldmark-highlighting/v2"]
438
version = "v2.0.0-20230729083705-37449abec8cc"
439
hash = "sha256-HpiwU7jIeDUAg2zOpTIiviQir8dpRPuXYh2nqFFccpg="
+15
-17
nix/pkgs/knot-unwrapped.nix
+15
-17
nix/pkgs/knot-unwrapped.nix
···
3
modules,
4
sqlite-lib,
5
src,
6
-
}:
7
-
let
8
-
version = "1.8.1-alpha";
9
in
10
-
buildGoApplication {
11
-
pname = "knot";
12
-
version = "1.8.1";
13
-
inherit src modules;
14
15
-
doCheck = false;
16
17
-
subPackages = ["cmd/knot"];
18
-
tags = ["libsqlite3"];
19
20
-
ldflags = [
21
-
"-X tangled.sh/tangled.sh/core/knotserver/xrpc.version=${version}"
22
-
];
23
24
-
env.CGO_CFLAGS = "-I ${sqlite-lib}/include ";
25
-
env.CGO_LDFLAGS = "-L ${sqlite-lib}/lib";
26
-
CGO_ENABLED = 1;
27
-
}
···
3
modules,
4
sqlite-lib,
5
src,
6
+
}: let
7
+
version = "1.9.1-alpha";
8
in
9
+
buildGoApplication {
10
+
pname = "knot";
11
+
inherit src version modules;
12
13
+
doCheck = false;
14
15
+
subPackages = ["cmd/knot"];
16
+
tags = ["libsqlite3"];
17
18
+
ldflags = [
19
+
"-X tangled.org/core/knotserver/xrpc.version=${version}"
20
+
];
21
22
+
env.CGO_CFLAGS = "-I ${sqlite-lib}/include ";
23
+
env.CGO_LDFLAGS = "-L ${sqlite-lib}/lib";
24
+
CGO_ENABLED = 1;
25
+
}
+1
-1
patchutil/interdiff.go
+1
-1
patchutil/interdiff.go
+1
-1
patchutil/patchutil.go
+1
-1
patchutil/patchutil.go
+1
-1
rbac/rbac_test.go
+1
-1
rbac/rbac_test.go
+4
-4
readme.md
+4
-4
readme.md
···
1
# tangled
2
3
Hello Tanglers! This is the codebase for
4
-
[Tangled](https://tangled.sh)—a code collaboration platform built
5
on the [AT Protocol](https://atproto.com).
6
7
-
Read the introduction to Tangled [here](https://blog.tangled.sh/intro). Join the
8
-
[Discord](https://chat.tangled.sh) or IRC at [#tangled on
9
libera.chat](https://web.libera.chat/#tangled).
10
11
## docs
···
17
## security
18
19
If you've identified a security issue in Tangled, please email
20
-
[security@tangled.sh](mailto:security@tangled.sh) with details!
···
1
# tangled
2
3
Hello Tanglers! This is the codebase for
4
+
[Tangled](https://tangled.org)—a code collaboration platform built
5
on the [AT Protocol](https://atproto.com).
6
7
+
Read the introduction to Tangled [here](https://blog.tangled.org/intro). Join the
8
+
[Discord](https://chat.tangled.org) or IRC at [#tangled on
9
libera.chat](https://web.libera.chat/#tangled).
10
11
## docs
···
17
## security
18
19
If you've identified a security issue in Tangled, please email
20
+
[security@tangled.org](mailto:security@tangled.org) with details!
+4
-4
spindle/db/events.go
+4
-4
spindle/db/events.go
+5
-5
spindle/engine/engine.go
+5
-5
spindle/engine/engine.go
···
8
9
securejoin "github.com/cyphar/filepath-securejoin"
10
"golang.org/x/sync/errgroup"
11
-
"tangled.sh/tangled.sh/core/notifier"
12
-
"tangled.sh/tangled.sh/core/spindle/config"
13
-
"tangled.sh/tangled.sh/core/spindle/db"
14
-
"tangled.sh/tangled.sh/core/spindle/models"
15
-
"tangled.sh/tangled.sh/core/spindle/secrets"
16
)
17
18
var (
+6
-6
spindle/engines/nixery/engine.go
+6
-6
spindle/engines/nixery/engine.go
···
19
"github.com/docker/docker/client"
20
"github.com/docker/docker/pkg/stdcopy"
21
"gopkg.in/yaml.v3"
22
-
"tangled.sh/tangled.sh/core/api/tangled"
23
-
"tangled.sh/tangled.sh/core/log"
24
-
"tangled.sh/tangled.sh/core/spindle/config"
25
-
"tangled.sh/tangled.sh/core/spindle/engine"
26
-
"tangled.sh/tangled.sh/core/spindle/models"
27
-
"tangled.sh/tangled.sh/core/spindle/secrets"
28
)
29
30
const (
···
19
"github.com/docker/docker/client"
20
"github.com/docker/docker/pkg/stdcopy"
21
"gopkg.in/yaml.v3"
22
+
"tangled.org/core/api/tangled"
23
+
"tangled.org/core/log"
24
+
"tangled.org/core/spindle/config"
25
+
"tangled.org/core/spindle/engine"
26
+
"tangled.org/core/spindle/models"
27
+
"tangled.org/core/spindle/secrets"
28
)
29
30
const (
+2
-2
spindle/engines/nixery/setup_steps.go
+2
-2
spindle/engines/nixery/setup_steps.go
+11
-11
spindle/ingester.go
+11
-11
spindle/ingester.go
···
7
"fmt"
8
"time"
9
10
-
"tangled.sh/tangled.sh/core/api/tangled"
11
-
"tangled.sh/tangled.sh/core/eventconsumer"
12
-
"tangled.sh/tangled.sh/core/idresolver"
13
-
"tangled.sh/tangled.sh/core/rbac"
14
-
"tangled.sh/tangled.sh/core/spindle/db"
15
16
comatproto "github.com/bluesky-social/indigo/api/atproto"
17
"github.com/bluesky-social/indigo/atproto/identity"
···
146
147
l := s.l.With("component", "ingester", "record", tangled.RepoNSID)
148
149
-
l.Info("ingesting repo record")
150
151
switch e.Commit.Operation {
152
case models.CommitOperationCreate, models.CommitOperationUpdate:
···
162
163
// no spindle configured for this repo
164
if record.Spindle == nil {
165
-
l.Info("no spindle configured", "did", record.Owner, "name", record.Name)
166
return nil
167
}
168
169
// this repo did not want this spindle
170
if *record.Spindle != domain {
171
-
l.Info("different spindle configured", "did", record.Owner, "name", record.Name, "spindle", *record.Spindle, "domain", domain)
172
return nil
173
}
174
175
// add this repo to the watch list
176
-
if err := s.db.AddRepo(record.Knot, record.Owner, record.Name); err != nil {
177
l.Error("failed to add repo", "error", err)
178
return fmt.Errorf("failed to add repo: %w", err)
179
}
180
181
-
didSlashRepo, err := securejoin.SecureJoin(record.Owner, record.Name)
182
if err != nil {
183
return err
184
}
185
186
// add repo to rbac
187
-
if err := s.e.AddRepo(record.Owner, rbac.ThisServer, didSlashRepo); err != nil {
188
l.Error("failed to add repo to enforcer", "error", err)
189
return fmt.Errorf("failed to add repo: %w", err)
190
}
···
7
"fmt"
8
"time"
9
10
+
"tangled.org/core/api/tangled"
11
+
"tangled.org/core/eventconsumer"
12
+
"tangled.org/core/idresolver"
13
+
"tangled.org/core/rbac"
14
+
"tangled.org/core/spindle/db"
15
16
comatproto "github.com/bluesky-social/indigo/api/atproto"
17
"github.com/bluesky-social/indigo/atproto/identity"
···
146
147
l := s.l.With("component", "ingester", "record", tangled.RepoNSID)
148
149
+
l.Info("ingesting repo record", "did", did)
150
151
switch e.Commit.Operation {
152
case models.CommitOperationCreate, models.CommitOperationUpdate:
···
162
163
// no spindle configured for this repo
164
if record.Spindle == nil {
165
+
l.Info("no spindle configured", "name", record.Name)
166
return nil
167
}
168
169
// this repo did not want this spindle
170
if *record.Spindle != domain {
171
+
l.Info("different spindle configured", "name", record.Name, "spindle", *record.Spindle, "domain", domain)
172
return nil
173
}
174
175
// add this repo to the watch list
176
+
if err := s.db.AddRepo(record.Knot, did, record.Name); err != nil {
177
l.Error("failed to add repo", "error", err)
178
return fmt.Errorf("failed to add repo: %w", err)
179
}
180
181
+
didSlashRepo, err := securejoin.SecureJoin(did, record.Name)
182
if err != nil {
183
return err
184
}
185
186
// add repo to rbac
187
+
if err := s.e.AddRepo(did, rbac.ThisServer, didSlashRepo); err != nil {
188
l.Error("failed to add repo to enforcer", "error", err)
189
return fmt.Errorf("failed to add repo: %w", err)
190
}
+2
-2
spindle/models/engine.go
+2
-2
spindle/models/engine.go
+1
-1
spindle/models/models.go
+1
-1
spindle/models/models.go
+17
-17
spindle/server.go
+17
-17
spindle/server.go
···
9
"net/http"
10
11
"github.com/go-chi/chi/v5"
12
-
"tangled.sh/tangled.sh/core/api/tangled"
13
-
"tangled.sh/tangled.sh/core/eventconsumer"
14
-
"tangled.sh/tangled.sh/core/eventconsumer/cursor"
15
-
"tangled.sh/tangled.sh/core/idresolver"
16
-
"tangled.sh/tangled.sh/core/jetstream"
17
-
"tangled.sh/tangled.sh/core/log"
18
-
"tangled.sh/tangled.sh/core/notifier"
19
-
"tangled.sh/tangled.sh/core/rbac"
20
-
"tangled.sh/tangled.sh/core/spindle/config"
21
-
"tangled.sh/tangled.sh/core/spindle/db"
22
-
"tangled.sh/tangled.sh/core/spindle/engine"
23
-
"tangled.sh/tangled.sh/core/spindle/engines/nixery"
24
-
"tangled.sh/tangled.sh/core/spindle/models"
25
-
"tangled.sh/tangled.sh/core/spindle/queue"
26
-
"tangled.sh/tangled.sh/core/spindle/secrets"
27
-
"tangled.sh/tangled.sh/core/spindle/xrpc"
28
-
"tangled.sh/tangled.sh/core/xrpc/serviceauth"
29
)
30
31
//go:embed motd
···
9
"net/http"
10
11
"github.com/go-chi/chi/v5"
12
+
"tangled.org/core/api/tangled"
13
+
"tangled.org/core/eventconsumer"
14
+
"tangled.org/core/eventconsumer/cursor"
15
+
"tangled.org/core/idresolver"
16
+
"tangled.org/core/jetstream"
17
+
"tangled.org/core/log"
18
+
"tangled.org/core/notifier"
19
+
"tangled.org/core/rbac"
20
+
"tangled.org/core/spindle/config"
21
+
"tangled.org/core/spindle/db"
22
+
"tangled.org/core/spindle/engine"
23
+
"tangled.org/core/spindle/engines/nixery"
24
+
"tangled.org/core/spindle/models"
25
+
"tangled.org/core/spindle/queue"
26
+
"tangled.org/core/spindle/secrets"
27
+
"tangled.org/core/spindle/xrpc"
28
+
"tangled.org/core/xrpc/serviceauth"
29
)
30
31
//go:embed motd
+1
-1
spindle/stream.go
+1
-1
spindle/stream.go
+5
-5
spindle/xrpc/add_secret.go
+5
-5
spindle/xrpc/add_secret.go
···
10
"github.com/bluesky-social/indigo/atproto/syntax"
11
"github.com/bluesky-social/indigo/xrpc"
12
securejoin "github.com/cyphar/filepath-securejoin"
13
-
"tangled.sh/tangled.sh/core/api/tangled"
14
-
"tangled.sh/tangled.sh/core/rbac"
15
-
"tangled.sh/tangled.sh/core/spindle/secrets"
16
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
17
)
18
19
func (x *Xrpc) AddSecret(w http.ResponseWriter, r *http.Request) {
···
62
}
63
64
repo := resp.Value.Val.(*tangled.Repo)
65
-
didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name)
66
if err != nil {
67
fail(xrpcerr.GenericError(err))
68
return
···
10
"github.com/bluesky-social/indigo/atproto/syntax"
11
"github.com/bluesky-social/indigo/xrpc"
12
securejoin "github.com/cyphar/filepath-securejoin"
13
+
"tangled.org/core/api/tangled"
14
+
"tangled.org/core/rbac"
15
+
"tangled.org/core/spindle/secrets"
16
+
xrpcerr "tangled.org/core/xrpc/errors"
17
)
18
19
func (x *Xrpc) AddSecret(w http.ResponseWriter, r *http.Request) {
···
62
}
63
64
repo := resp.Value.Val.(*tangled.Repo)
65
+
didPath, err := securejoin.SecureJoin(ident.DID.String(), repo.Name)
66
if err != nil {
67
fail(xrpcerr.GenericError(err))
68
return
+5
-5
spindle/xrpc/list_secrets.go
+5
-5
spindle/xrpc/list_secrets.go
···
10
"github.com/bluesky-social/indigo/atproto/syntax"
11
"github.com/bluesky-social/indigo/xrpc"
12
securejoin "github.com/cyphar/filepath-securejoin"
13
-
"tangled.sh/tangled.sh/core/api/tangled"
14
-
"tangled.sh/tangled.sh/core/rbac"
15
-
"tangled.sh/tangled.sh/core/spindle/secrets"
16
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
17
)
18
19
func (x *Xrpc) ListSecrets(w http.ResponseWriter, r *http.Request) {
···
57
}
58
59
repo := resp.Value.Val.(*tangled.Repo)
60
-
didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name)
61
if err != nil {
62
fail(xrpcerr.GenericError(err))
63
return
···
10
"github.com/bluesky-social/indigo/atproto/syntax"
11
"github.com/bluesky-social/indigo/xrpc"
12
securejoin "github.com/cyphar/filepath-securejoin"
13
+
"tangled.org/core/api/tangled"
14
+
"tangled.org/core/rbac"
15
+
"tangled.org/core/spindle/secrets"
16
+
xrpcerr "tangled.org/core/xrpc/errors"
17
)
18
19
func (x *Xrpc) ListSecrets(w http.ResponseWriter, r *http.Request) {
···
57
}
58
59
repo := resp.Value.Val.(*tangled.Repo)
60
+
didPath, err := securejoin.SecureJoin(ident.DID.String(), repo.Name)
61
if err != nil {
62
fail(xrpcerr.GenericError(err))
63
return
+2
-2
spindle/xrpc/owner.go
+2
-2
spindle/xrpc/owner.go
+5
-5
spindle/xrpc/remove_secret.go
+5
-5
spindle/xrpc/remove_secret.go
···
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
"github.com/bluesky-social/indigo/xrpc"
11
securejoin "github.com/cyphar/filepath-securejoin"
12
-
"tangled.sh/tangled.sh/core/api/tangled"
13
-
"tangled.sh/tangled.sh/core/rbac"
14
-
"tangled.sh/tangled.sh/core/spindle/secrets"
15
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
16
)
17
18
func (x *Xrpc) RemoveSecret(w http.ResponseWriter, r *http.Request) {
···
56
}
57
58
repo := resp.Value.Val.(*tangled.Repo)
59
-
didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name)
60
if err != nil {
61
fail(xrpcerr.GenericError(err))
62
return
···
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
"github.com/bluesky-social/indigo/xrpc"
11
securejoin "github.com/cyphar/filepath-securejoin"
12
+
"tangled.org/core/api/tangled"
13
+
"tangled.org/core/rbac"
14
+
"tangled.org/core/spindle/secrets"
15
+
xrpcerr "tangled.org/core/xrpc/errors"
16
)
17
18
func (x *Xrpc) RemoveSecret(w http.ResponseWriter, r *http.Request) {
···
56
}
57
58
repo := resp.Value.Val.(*tangled.Repo)
59
+
didPath, err := securejoin.SecureJoin(ident.DID.String(), repo.Name)
60
if err != nil {
61
fail(xrpcerr.GenericError(err))
62
return
+9
-9
spindle/xrpc/xrpc.go
+9
-9
spindle/xrpc/xrpc.go
···
8
9
"github.com/go-chi/chi/v5"
10
11
-
"tangled.sh/tangled.sh/core/api/tangled"
12
-
"tangled.sh/tangled.sh/core/idresolver"
13
-
"tangled.sh/tangled.sh/core/rbac"
14
-
"tangled.sh/tangled.sh/core/spindle/config"
15
-
"tangled.sh/tangled.sh/core/spindle/db"
16
-
"tangled.sh/tangled.sh/core/spindle/models"
17
-
"tangled.sh/tangled.sh/core/spindle/secrets"
18
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
19
-
"tangled.sh/tangled.sh/core/xrpc/serviceauth"
20
)
21
22
const ActorDid string = "ActorDid"
···
8
9
"github.com/go-chi/chi/v5"
10
11
+
"tangled.org/core/api/tangled"
12
+
"tangled.org/core/idresolver"
13
+
"tangled.org/core/rbac"
14
+
"tangled.org/core/spindle/config"
15
+
"tangled.org/core/spindle/db"
16
+
"tangled.org/core/spindle/models"
17
+
"tangled.org/core/spindle/secrets"
18
+
xrpcerr "tangled.org/core/xrpc/errors"
19
+
"tangled.org/core/xrpc/serviceauth"
20
)
21
22
const ActorDid string = "ActorDid"
+7
-5
types/repo.go
+7
-5
types/repo.go
···
41
}
42
43
type RepoTreeResponse struct {
44
-
Ref string `json:"ref,omitempty"`
45
-
Parent string `json:"parent,omitempty"`
46
-
Description string `json:"description,omitempty"`
47
-
DotDot string `json:"dotdot,omitempty"`
48
-
Files []NiceTree `json:"files,omitempty"`
49
}
50
51
type TagReference struct {
···
41
}
42
43
type RepoTreeResponse struct {
44
+
Ref string `json:"ref,omitempty"`
45
+
Parent string `json:"parent,omitempty"`
46
+
Description string `json:"description,omitempty"`
47
+
DotDot string `json:"dotdot,omitempty"`
48
+
Files []NiceTree `json:"files,omitempty"`
49
+
ReadmeFileName string `json:"readme_filename,omitempty"`
50
+
Readme string `json:"readme_contents,omitempty"`
51
}
52
53
type TagReference struct {
+1
-1
workflow/compile.go
+1
-1
workflow/compile.go
+1
-1
workflow/compile_test.go
+1
-1
workflow/compile_test.go
+1
-1
workflow/def.go
+1
-1
workflow/def.go
+10
xrpc/errors/errors.go
+10
xrpc/errors/errors.go
···
56
WithMessage("owner not set for this service"),
57
)
58
59
+
var RepoNotFoundError = NewXrpcError(
60
+
WithTag("RepoNotFound"),
61
+
WithMessage("failed to access repository"),
62
+
)
63
+
64
+
var RefNotFoundError = NewXrpcError(
65
+
WithTag("RefNotFound"),
66
+
WithMessage("failed to access ref"),
67
+
)
68
+
69
var AuthError = func(err error) XrpcError {
70
return NewXrpcError(
71
WithTag("Auth"),
+2
-2
xrpc/serviceauth/service_auth.go
+2
-2
xrpc/serviceauth/service_auth.go