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

Compare changes

Choose any two refs to compare.

Changed files
+12421 -4633
.air
.tangled
workflows
api
appview
cache
session
commitverify
config
db
dns
issues
knots
labels
middleware
models
notifications
notify
oauth
pages
legal
markup
repoinfo
templates
pagination
pipelines
posthog
pulls
repo
reporesolver
serververify
settings
signup
spindles
state
strings
validator
cmd
appview
combinediff
interdiff
knot
spindle
verifysig
consts
contrib
crypto
docs
eventconsumer
guard
jetstream
keyfetch
knotserver
legal
lexicons
nix
patchutil
rbac
spindle
types
workflow
xrpc
errors
serviceauth
+1 -1
.air/knotserver.toml
··· 1 [build] 2 - cmd = 'go build -ldflags "-X tangled.sh/tangled.sh/core/knotserver.version=$(git describe --tags --long)" -o .bin/knot ./cmd/knot/' 3 bin = ".bin/knot server" 4 root = "." 5
··· 1 [build] 2 + cmd = 'go build -ldflags "-X tangled.org/core/knotserver.version=$(git describe --tags --long)" -o .bin/knot ./cmd/knot/' 3 bin = ".bin/knot server" 4 root = "." 5
+6
.tangled/workflows/test.yml
··· 14 command: | 15 mkdir -p appview/pages/static; touch appview/pages/static/x 16 17 - name: run all tests 18 environment: 19 CGO_ENABLED: 1
··· 14 command: | 15 mkdir -p appview/pages/static; touch appview/pages/static/x 16 17 + - name: run linter 18 + environment: 19 + CGO_ENABLED: 1 20 + command: | 21 + go vet -v ./... 22 + 23 - name: run all tests 24 environment: 25 CGO_ENABLED: 1
+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
···
··· 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
···
··· 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
··· 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
··· 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
··· 6 "fmt" 7 "time" 8 9 - "tangled.sh/tangled.sh/core/appview/cache" 10 ) 11 12 type OAuthSession struct {
··· 6 "fmt" 7 "time" 8 9 + "tangled.org/core/appview/cache" 10 ) 11 12 type OAuthSession struct {
+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
··· 72 } 73 74 type Cloudflare struct { 75 - ApiToken string `env:"API_TOKEN"` 76 - ZoneId string `env:"ZONE_ID"` 77 } 78 79 func (cfg RedisConfig) ToURL() string {
··· 72 } 73 74 type Cloudflare struct { 75 + ApiToken string `env:"API_TOKEN"` 76 + ZoneId string `env:"ZONE_ID"` 77 + TurnstileSiteKey string `env:"TURNSTILE_SITE_KEY"` 78 + TurnstileSecretKey string `env:"TURNSTILE_SECRET_KEY"` 79 } 80 81 func (cfg RedisConfig) ToURL() string {
+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 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
··· 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
··· 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
··· 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
··· 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
···
··· 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
··· 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
···
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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(&reg.Id, &reg.Domain, &reg.ByDid, &createdAt, &registeredAt, &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(&reg.Id, &reg.Domain, &reg.ByDid, &createdAt, &registeredAt, &needsUpgrade) 48 if err != nil {
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 5 "fmt" 6 7 "github.com/cloudflare/cloudflare-go" 8 - "tangled.sh/tangled.sh/core/appview/config" 9 ) 10 11 type Record struct {
··· 5 "fmt" 6 7 "github.com/cloudflare/cloudflare-go" 8 + "tangled.org/core/appview/config" 9 ) 10 11 type Record struct {
+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
··· 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
··· 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
··· 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
···
··· 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
··· 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
···
··· 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
···
··· 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
···
··· 1 + package models 2 + 3 + import ( 4 + "time" 5 + ) 6 + 7 + type Email struct { 8 + ID int64 9 + Did string 10 + Address string 11 + Verified bool 12 + Primary bool 13 + VerificationCode string 14 + LastSent *time.Time 15 + CreatedAt time.Time 16 + }
+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
···
··· 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
···
··· 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
···
··· 1 + package models 2 + 3 + import ( 4 + "github.com/bluesky-social/indigo/atproto/syntax" 5 + ) 6 + 7 + type RepoLanguage struct { 8 + Id int64 9 + RepoAt syntax.ATURI 10 + Ref string 11 + IsDefaultRef bool 12 + Language string 13 + Bytes int64 14 + }
+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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 1 + package models 2 + 3 + import "time" 4 + 5 + type Punch struct { 6 + Did string 7 + Date time.Time 8 + Count int 9 + } 10 + 11 + type Punchcard struct { 12 + Total int 13 + Punches []Punch 14 + }
+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
···
··· 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
···
··· 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
···
··· 1 + package models 2 + 3 + import "time" 4 + 5 + type InflightSignup struct { 6 + Id int64 7 + Email string 8 + InviteCode string 9 + Created time.Time 10 + }
+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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
··· 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
··· 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
···
··· 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
··· 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
··· 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
··· 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
···
··· 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
···
··· 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
···
··· 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
··· 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
··· 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
··· 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, &params.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, &params.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 "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
+1 -1
appview/pages/templates/banner.html
··· 30 <div class="mx-6"> 31 These services may not be fully accessible until upgraded. 32 <a class="underline text-red-800 dark:text-red-200" 33 - href="https://tangled.sh/@tangled.sh/core/tree/master/docs/migrations/"> 34 Click to read the upgrade guide</a>. 35 </div> 36 </details>
··· 30 <div class="mx-6"> 31 These services may not be fully accessible until upgraded. 32 <a class="underline text-red-800 dark:text-red-200" 33 + href="https://tangled.org/@tangled.org/core/tree/master/docs/migrations.md"> 34 Click to read the upgrade guide</a>. 35 </div> 36 </details>
+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
··· 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 &mdash; 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 &mdash; 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
··· 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
···
··· 1 + {{ define "fragments/dolly/logo" }} 2 + <svg 3 + version="1.1" 4 + id="svg1" 5 + class="{{.}}" 6 + width="25" 7 + height="25" 8 + viewBox="0 0 25 25" 9 + sodipodi:docname="tangled_dolly_face_only.png" 10 + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" 11 + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" 12 + xmlns:xlink="http://www.w3.org/1999/xlink" 13 + xmlns="http://www.w3.org/2000/svg" 14 + xmlns:svg="http://www.w3.org/2000/svg"> 15 + <title>Dolly</title> 16 + <defs 17 + id="defs1" /> 18 + <sodipodi:namedview 19 + id="namedview1" 20 + pagecolor="#ffffff" 21 + bordercolor="#000000" 22 + borderopacity="0.25" 23 + inkscape:showpageshadow="2" 24 + inkscape:pageopacity="0.0" 25 + inkscape:pagecheckerboard="true" 26 + inkscape:deskcolor="#d5d5d5"> 27 + <inkscape:page 28 + x="0" 29 + y="0" 30 + width="25" 31 + height="25" 32 + id="page2" 33 + margin="0" 34 + bleed="0" /> 35 + </sodipodi:namedview> 36 + <g 37 + inkscape:groupmode="layer" 38 + inkscape:label="Image" 39 + id="g1"> 40 + <image 41 + width="252.48" 42 + height="248.96001" 43 + preserveAspectRatio="none" 44 + xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAxUAAAMKCAYAAADznWlEAAABg2lDQ1BJQ0MgcHJvZmlsZQAAKJF9&#10;kT1Iw0AcxV9TpVoqDmYQcchQneyiIo61CkWoEGqFVh1MLv2CJi1Jiouj4Fpw8GOx6uDirKuDqyAI&#10;foC4C06KLlLi/5JCixgPjvvx7t7j7h0gNCtMt3rigG7YZjqZkLK5VSn0in6EISKGsMKs2pwsp+A7&#10;vu4R4OtdjGf5n/tzDGh5iwEBiTjOaqZNvEE8s2nXOO8Ti6ykaMTnxBMmXZD4keuqx2+ciy4LPFM0&#10;M+l5YpFYKnax2sWsZOrE08RRTTcoX8h6rHHe4qxX6qx9T/7CSN5YWeY6zVEksYglyJCgoo4yKrCp&#10;rzIMUiykaT/h4x9x/TK5VHKVwcixgCp0KK4f/A9+d2sVpia9pEgC6H1xnI8xILQLtBqO833sOK0T&#10;IPgMXBkdf7UJzH6S3uho0SNgcBu4uO5o6h5wuQMMP9UUU3GlIE2hUADez+ibcsDQLRBe83pr7+P0&#10;AchQV6kb4OAQGC9S9rrPu/u6e/v3TLu/H4tGcrDFxPPTAAAABmJLR0QAAAAAAAD5Q7t/AAAACXBI&#10;WXMAAC4jAAAuIwF4pT92AAAAB3RJTUUH6QkPFQ8jl/6e6wAAABl0RVh0Q29tbWVudABDcmVhdGVk&#10;IHdpdGggR0lNUFeBDhcAACAASURBVHic7N3nc5xXmqb564UjCXoripShvKNMqVSSynWXmZ6emd39&#10;NhH7R+6HjdiN6d3emZ7uru6ukqpKXUbeUKIMJZKitwBhzn64z1uZpEiJIAGkea9fRAZIgCCTQCLz&#10;3O95nueAJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmS&#10;JEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmS&#10;JEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmSJEmS&#10;JEmSJEmSJEmSJEmSJOkONYO+A5IkjYtSSgNM0Ht9LX036vvbj7fvX+77K/pfl0vTNAVJGgGGCkmS&#10;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&#10;U2QU7On6+9OkT6L5/hwwWxRFZ13erbQJGVRIknSFpqampn7961/vuf/++/ft27fvjsnJyVvKsrxp&#10;YWFh79LS0r52u33L2NjY9vHx8ZGhoSGSyNBVWgYuAB8Dh0h50wfACRJAnCc7J+bsmZDWj1c5SZKu&#10;0muvvVa0Wq3ij3/84/D09PT2bdu23XPnnXd+7Y477nhg7969e3fv3r1lbGxsBBiqqmq4LMtxYKrd&#10;bt/QarXGi6Jwq3eUZI/EMslILJKdEvMk63AceIfsljhMMhKz9d9f4m4bad05zkKSpKv06KOPVkA1&#10;MjKy9KMf/ejM+Pj4+bIs36iqarjT6QzNzMwMTU9PD09PT08uLCzsHB0dvWPbtm1f3bVr1wOTk5P7&#10;hoeHd5CSqWbpXtME3qLu1+j5vp97Nio+2wfRvJpAYh64RHZJNFuuT5JsxCf199Ok/Gmp/nMGEtIG&#10;0q8XJ0mS+snQD3/4w5Ef//jHE/fdd9/Wffv2TW3ZsmViaGhotCzL4bIsR1qt1mir1RpvtVpbgJ3A&#10;rcC++utNwBSfDUD6Qe9kpjlSxnSW9D+crl9n69dFElhcIlmIebKUbqH+8ys2XEsbl0GFJEnrr7V/&#10;//6he++9d2R8fHyCBBA3kuBiB7ANGCdlVC2gKMuyqKqqVZZlM962KIqiaLVaFEXRvJrMRxsYrf8Z&#10;k/XXYYCyLKuyLKmqqirLsvefUbXb7SaT0DQ8T9TvZ0/93qZI1UNJt1zpEgkQLtSvi6Tv4QIpZTpf&#10;f21+b5YEHIskC2HwIPUhgwpJkvpPb5lUqyiK1k9/+tPioYceKu6++2727dvH1q1bi8nJyWJoaKhF&#10;HvzHSRAwBdxAPalqaWmJxcXFanl5uVpZWSlarVZreHiYkZGRamxsbLlufl6u/503ALcAdwN3kgzK&#10;KAkoLvKPWYhpulmIWbpZhw4JRD77H2U5k9S37KmQJKn/VEDnBz/4QTk1NcXu3bu5/fbb2b59O+Pj&#10;4wwNDdFkLGoFyRAUn3v9g38yqar3Qb8gQcRWkknZTrIgcyRDMUMChyVghbr3ge5uiQp7IaSBZKZC&#10;kiR9aVVVNVmSofpV0G267gAdgwZJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ&#10;kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ&#10;kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ&#10;kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ&#10;kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ&#10;kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJ&#10;kiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJkiRJktbC&#10;/wcO9A7eMaXQEQAAAABJRU5ErkJggg==&#10;" 45 + id="image1" 46 + x="-233.6257" 47 + y="10.383364" 48 + style="display:none" /> 49 + <path 50 + fill="currentColor" 51 + style="stroke-width:0.111183" 52 + d="m 16.348974,24.09935 -0.06485,-0.03766 -0.202005,-0.0106 -0.202008,-0.01048 -0.275736,-0.02601 -0.275734,-0.02602 v -0.02649 -0.02648 l -0.204577,-0.04019 -0.204578,-0.04019 -0.167616,-0.08035 -0.167617,-0.08035 -0.0014,-0.04137 -0.0014,-0.04137 -0.266473,-0.143735 -0.266475,-0.143735 -0.276098,-0.20335 -0.2761,-0.203347 -0.262064,-0.251949 -0.262064,-0.25195 -0.22095,-0.284628 -0.220948,-0.284629 -0.170253,-0.284631 -0.170252,-0.284628 -0.01341,-0.0144 -0.0134,-0.0144 -0.141982,0.161297 -0.14198,0.1613 -0.22313,0.21426 -0.223132,0.214264 -0.186025,0.146053 -0.186023,0.14605 -0.252501,0.163342 -0.252502,0.163342 -0.249014,0.115348 -0.249013,0.115336 0.0053,0.03241 0.0053,0.03241 -0.1716725,0.04599 -0.171669,0.046 -0.3379966,0.101058 -0.3379972,0.101058 -0.1778925,0.04506 -0.1778935,0.04508 -0.3913655,0.02601 -0.3913643,0.02603 -0.3557868,-0.03514 -0.3557863,-0.03514 -0.037426,-0.03029 -0.037427,-0.03029 -0.076924,0.02011 -0.076924,0.02011 -0.050508,-0.05051 -0.050405,-0.05056 L 6.6604532,23.110188 6.451745,23.063961 6.1546135,22.960559 5.8574835,22.857156 5.5319879,22.694039 5.2064938,22.530922 4.8793922,22.302961 4.5522905,22.075005 4.247598,21.786585 3.9429055,21.49817 3.7185335,21.208777 3.4941628,20.919385 3.3669822,20.705914 3.239803,20.492443 3.1335213,20.278969 3.0272397,20.065499 2.9015252,19.7275 2.7758105,19.389504 2.6925225,18.998139 2.6092345,18.606774 2.6096814,17.91299 2.6101284,17.219208 2.6744634,16.90029 2.7387984,16.581374 2.8474286,16.242088 2.9560588,15.9028 3.1137374,15.583492 3.2714148,15.264182 3.3415068,15.150766 3.4115988,15.03735 3.3127798,14.96945 3.2139618,14.90157 3.0360685,14.800239 2.8581753,14.698908 2.5913347,14.503228 2.3244955,14.307547 2.0621238,14.055599 1.7997507,13.803651 1.6111953,13.56878 1.4226411,13.333906 1.2632237,13.087474 1.1038089,12.841042 0.97442,12.575195 0.8450307,12.30935 0.724603,11.971351 0.6041766,11.633356 0.52150365,11.241991 0.4388285,10.850626 0.44091592,10.156842 0.44300333,9.4630594 0.54235911,9.0369608 0.6417149,8.6108622 0.7741173,8.2694368 0.9065196,7.9280115 1.0736303,7.6214262 1.2407515,7.3148397 1.45931,7.0191718 1.6778685,6.7235039 1.9300326,6.4611321 2.1821966,6.1987592 2.4134579,6.0137228 2.6447193,5.8286865 2.8759792,5.6776409 3.1072406,5.526594 3.4282004,5.3713977 3.7491603,5.2162016 3.9263009,5.1508695 4.1034416,5.0855373 4.2813348,4.7481598 4.4592292,4.4107823 4.6718,4.108422 4.8843733,3.8060618 5.198353,3.4805372 5.5123313,3.155014 5.7685095,2.9596425 6.0246877,2.7642722 6.329187,2.5851365 6.6336863,2.406002 6.9497657,2.2751596 7.2658453,2.1443184 7.4756394,2.0772947 7.6854348,2.01027 8.0825241,1.931086 8.4796139,1.851902 l 0.5870477,0.00291 0.5870469,0.00291 0.4447315,0.092455 0.444734,0.092455 0.302419,0.1105495 0.302417,0.1105495 0.329929,0.1646046 0.32993,0.1646033 0.239329,-0.2316919 0.239329,-0.2316919 0.160103,-0.1256767 0.160105,-0.1256767 0.160102,-0.1021909 0.160105,-0.1021899 0.142315,-0.082328 0.142314,-0.082328 0.231262,-0.1090091 0.231259,-0.1090091 0.26684,-0.098743 0.266839,-0.098743 0.320208,-0.073514 0.320209,-0.073527 0.355787,-0.041833 0.355785,-0.041834 0.426942,0.023827 0.426945,0.023828 0.355785,0.071179 0.355788,0.0711791 0.284627,0.09267 0.284629,0.09267 0.28514,0.1310267 0.28514,0.1310255 0.238179,0.1446969 0.238174,0.1446979 0.259413,0.1955332 0.259413,0.1955319 0.290757,0.296774 0.290758,0.2967753 0.151736,0.1941581 0.151734,0.1941594 0.135326,0.2149951 0.135327,0.2149952 0.154755,0.3202073 0.154758,0.3202085 0.09409,0.2677358 0.09409,0.267737 0.06948,0.3319087 0.06948,0.3319099 0.01111,0.00808 0.01111,0.00808 0.444734,0.2173653 0.444734,0.2173665 0.309499,0.2161102 0.309497,0.2161101 0.309694,0.2930023 0.309694,0.2930037 0.18752,0.2348726 0.187524,0.2348727 0.166516,0.2574092 0.166519,0.2574108 0.15273,0.3260252 0.152734,0.3260262 0.08972,0.2668403 0.08971,0.2668391 0.08295,0.3913655 0.08295,0.3913652 -6.21e-4,0.6582049 -6.21e-4,0.658204 -0.06362,0.315725 -0.06362,0.315725 -0.09046,0.289112 -0.09046,0.289112 -0.122759,0.281358 -0.12276,0.281356 -0.146626,0.252323 -0.146629,0.252322 -0.190443,0.258668 -0.190448,0.258671 -0.254911,0.268356 -0.254911,0.268355 -0.286872,0.223127 -0.286874,0.223127 -0.320203,0.187693 -0.320209,0.187693 -0.04347,0.03519 -0.04347,0.03521 0.0564,0.12989 0.0564,0.129892 0.08728,0.213472 0.08728,0.213471 0.189755,0.729363 0.189753,0.729362 0.0652,0.302417 0.0652,0.302419 -0.0018,0.675994 -0.0018,0.675995 -0.0801,0.373573 -0.08009,0.373577 -0.09,0.266839 -0.09,0.26684 -0.190389,0.391364 -0.19039,0.391366 -0.223169,0.320207 -0.223167,0.320209 -0.303585,0.315294 -0.303584,0.315291 -0.284631,0.220665 -0.284629,0.220663 -0.220128,0.132359 -0.220127,0.132358 -0.242395,0.106698 -0.242394,0.106699 -0.08895,0.04734 -0.08895,0.04733 -0.249052,0.07247 -0.24905,0.07247 -0.322042,0.0574 -0.322044,0.0574 -0.282794,-0.003 -0.282795,-0.003 -0.07115,-0.0031 -0.07115,-0.0031 -0.177894,-0.0033 -0.177893,-0.0033 -0.124528,0.02555 -0.124528,0.02555 z m -4.470079,-5.349839 0.214838,-0.01739 0.206601,-0.06782 0.206602,-0.06782 0.244389,-0.117874 0.244393,-0.11786 0.274473,-0.206822 0.27447,-0.20682 0.229308,-0.257201 0.229306,-0.2572 0.219161,-0.28463 0.219159,-0.284629 0.188541,-0.284628 0.188543,-0.28463 0.214594,-0.373574 0.214593,-0.373577 0.133861,-0.312006 0.133865,-0.312007 0.02861,-0.01769 0.02861,-0.01769 0.197275,0.26212 0.197278,0.262119 0.163613,0.150814 0.163614,0.150814 0.201914,0.09276 0.201914,0.09276 0.302417,0.01421 0.302418,0.01421 0.213472,-0.08025 0.213471,-0.08025 0.200606,-0.204641 0.200606,-0.204642 0.09242,-0.278887 0.09241,-0.278888 0.05765,-0.302418 0.05764,-0.302416 L 18.41327,13.768114 18.39502,13.34117 18.31849,12.915185 18.24196,12.4892 18.15595,12.168033 18.06994,11.846867 17.928869,11.444534 17.787801,11.042201 17.621278,10.73296 17.454757,10.423723 17.337388,10.263619 17.220021,10.103516 17.095645,9.9837986 16.971268,9.8640816 16.990048,9.6813736 17.008828,9.4986654 16.947568,9.249616 16.886308,9.0005655 16.752419,8.7159355 16.618521,8.4313217 16.435707,8.2294676 16.252892,8.0276114 16.079629,7.9004245 15.906366,7.773238 l -0.20429,0.1230127 -0.204289,0.1230121 -0.26702,0.059413 -0.267022,0.059413 -0.205761,-0.021508 -0.205766,-0.021508 -0.23495,-0.08844 -0.234953,-0.08844 -0.118429,-0.090334 -0.118428,-0.090333 h -0.03944 -0.03944 L 13.711268,7.8540732 13.655958,7.9706205 13.497227,8.1520709 13.338499,8.3335203 13.168394,8.4419112 12.998289,8.550301 12.777045,8.624223 12.5558,8.698155 H 12.275611 11.995429 L 11.799973,8.6309015 11.604513,8.5636472 11.491311,8.5051061 11.37811,8.446565 11.138172,8.2254579 10.898231,8.0043497 l -0.09565,-0.084618 -0.09565,-0.084613 -0.218822,0.198024 -0.218822,0.1980231 -0.165392,0.078387 -0.1653925,0.078387 -0.177894,0.047948 -0.177892,0.047948 L 9.3635263,8.4842631 9.144328,8.4846889 8.9195029,8.4147138 8.6946778,8.3447386 8.5931214,8.4414036 8.491565,8.5380686 8.3707618,8.7019598 8.2499597,8.8658478 8.0802403,8.9290726 7.9105231,8.9922974 7.7952769,9.0780061 7.6800299,9.1637148 7.5706169,9.2778257 7.4612038,9.3919481 7.1059768,9.9205267 6.7507497,10.449105 l -0.2159851,0.449834 -0.2159839,0.449834 -0.2216572,0.462522 -0.2216559,0.462523 -0.1459343,0.337996 -0.1459342,0.337998 -0.055483,0.220042 -0.055483,0.220041 -0.015885,0.206903 -0.015872,0.206901 0.034307,0.242939 0.034307,0.24294 0.096281,0.196632 0.096281,0.196634 0.143607,0.125222 0.1436071,0.125222 0.1873143,0.08737 0.1873141,0.08737 0.2752084,0.002 0.2752084,0.002 0.2312297,-0.09773 0.231231,-0.09772 0.1067615,-0.07603 0.1067614,-0.07603 0.3679062,-0.29377 0.3679065,-0.293771 0.026804,0.01656 0.026804,0.01656 0.023626,0.466819 0.023626,0.466815 0.088326,0.513195 0.088326,0.513193 0.08897,0.364413 0.08897,0.364411 0.1315362,0.302418 0.1315352,0.302418 0.1051964,0.160105 0.1051954,0.160103 0.1104741,0.11877 0.1104731,0.118769 0.2846284,0.205644 0.2846305,0.205642 0.144448,0.07312 0.144448,0.07312 0.214787,0.05566 0.214787,0.05566 0.245601,0.03075 0.245602,0.03075 0.204577,-0.0125 0.204578,-0.0125 z m 0.686342,-3.497495 -0.11281,-0.06077 -0.106155,-0.134033 -0.106155,-0.134031 -0.04406,-0.18371 -0.04406,-0.183707 0.02417,-0.553937 0.02417,-0.553936 0.03513,-0.426945 0.03513,-0.426942 0.07225,-0.373576 0.07225,-0.373575 0.05417,-0.211338 0.05417,-0.211339 0.0674,-0.132112 0.0674,-0.132112 0.132437,-0.10916 0.132437,-0.109161 0.187436,-0.04195 0.187438,-0.04195 0.170366,0.06469 0.170364,0.06469 0.114312,0.124073 0.114313,0.124086 0.04139,0.18495 0.04139,0.184951 -0.111218,0.459845 -0.111219,0.459844 -0.03383,0.26584 -0.03382,0.265841 -0.03986,0.818307 -0.03986,0.818309 -0.0378,0.15162 -0.03779,0.151621 -0.11089,0.110562 -0.110891,0.110561 -0.114489,0.04913 -0.114489,0.04913 -0.187932,-0.0016 -0.187929,-0.0016 z m -2.8087655,-0.358124 -0.146445,-0.06848 -0.088025,-0.119502 -0.088024,-0.119502 -0.038581,-0.106736 -0.038581,-0.106736 -0.02237,-0.134956 -0.02239,-0.134957 -0.031955,-0.46988 -0.031955,-0.469881 0.036203,-0.444733 0.036203,-0.444731 0.048862,-0.215257 0.048862,-0.215255 0.076082,-0.203349 0.076081,-0.203348 0.0936,-0.111244 0.0936,-0.111245 0.143787,-0.06531 0.1437865,-0.06532 h 0.142315 0.142314 l 0.142314,0.06588 0.142316,0.06588 0.093,0.102325 0.093,0.102325 0.04042,0.120942 0.04042,0.120942 v 0.152479 0.152477 l -0.03347,0.08804 -0.03347,0.08805 -0.05693,0.275653 -0.05693,0.275651 2.11e-4,0.430246 2.12e-4,0.430243 0.04294,0.392646 0.04295,0.392647 -0.09189,0.200702 -0.09189,0.200702 -0.148688,0.0984 -0.148687,0.0984 -0.20136,0.01212 -0.2013595,0.01212 z" 53 + id="path4" /> 54 + </g> 55 + </svg> 56 + {{ end }}
+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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
··· 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
···
··· 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
···
··· 1 + {{ define "labels/fragments/labelDef" }} 2 + <span class="flex items-center gap-2 font-normal normal-case"> 3 + {{ template "repo/fragments/colorBall" (dict "color" .GetColor) }} 4 + {{ .Name }} 5 + </span> 6 + {{ end }}
+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 }}
+87 -33
appview/pages/templates/layouts/fragments/footer.html
··· 1 {{ define "layouts/fragments/footer" }} 2 - <div class="w-full p-4 md:p-8 bg-white dark:bg-gray-800 rounded-t drop-shadow-sm"> 3 - <div class="container mx-auto max-w-7xl px-4"> 4 - <div class="flex flex-col lg:flex-row justify-between items-start text-gray-600 dark:text-gray-400 text-sm gap-8"> 5 - <div class="mb-4 md:mb-0"> 6 - <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic"> 7 - tangled<sub>alpha</sub> 8 - </a> 9 - </div> 10 11 - {{ $headerStyle := "text-gray-900 dark:text-gray-200 font-bold text-xs uppercase tracking-wide mb-1" }} 12 - {{ $linkStyle := "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center" }} 13 - {{ $iconStyle := "w-4 h-4 flex-shrink-0" }} 14 - <div class="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-4 sm:gap-6 md:gap-2 gap-6 flex-1"> 15 - <div class="flex flex-col gap-1"> 16 - <div class="{{ $headerStyle }}">legal</div> 17 - <a href="/terms" class="{{ $linkStyle }}">{{ i "file-text" $iconStyle }} terms of service</a> 18 - <a href="/privacy" class="{{ $linkStyle }}">{{ i "shield" $iconStyle }} privacy policy</a> 19 </div> 20 21 - <div class="flex flex-col gap-1"> 22 - <div class="{{ $headerStyle }}">resources</div> 23 - <a href="https://blog.tangled.sh" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a> 24 - <a href="https://tangled.sh/@tangled.sh/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 25 - <a href="https://tangled.sh/@tangled.sh/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a> 26 </div> 27 28 - <div class="flex flex-col gap-1"> 29 - <div class="{{ $headerStyle }}">social</div> 30 - <a href="https://chat.tangled.sh" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "message-circle" $iconStyle }} discord</a> 31 - <a href="https://web.libera.chat/#tangled" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "hash" $iconStyle }} irc</a> 32 - <a href="https://bsky.app/profile/tangled.sh" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ template "user/fragments/bluesky" $iconStyle }} bluesky</a> 33 </div> 34 35 - <div class="flex flex-col gap-1"> 36 - <div class="{{ $headerStyle }}">contact</div> 37 - <a href="mailto:team@tangled.sh" class="{{ $linkStyle }}">{{ i "mail" "w-4 h-4 flex-shrink-0" }} team@tangled.sh</a> 38 - <a href="mailto:security@tangled.sh" class="{{ $linkStyle }}">{{ i "shield-check" "w-4 h-4 flex-shrink-0" }} security@tangled.sh</a> 39 </div> 40 - </div> 41 42 - <div class="text-center lg:text-right flex-shrink-0"> 43 - <div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div> 44 </div> 45 </div> 46 </div>
··· 1 {{ define "layouts/fragments/footer" }} 2 + <div class="w-full p-8"> 3 + <div class="mx-auto px-4"> 4 + <div class="flex flex-col text-gray-600 dark:text-gray-400 gap-8"> 5 + <!-- Desktop layout: grid with 3 columns --> 6 + <div class="hidden lg:grid lg:grid-cols-[1fr_minmax(0,1024px)_1fr] lg:gap-8 lg:items-start"> 7 + <!-- Left section --> 8 + <div> 9 + <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic no-underline hover:no-underline"> 10 + {{ template "fragments/logotypeSmall" }} 11 + </a> 12 + </div> 13 14 + {{ $headerStyle := "text-gray-900 dark:text-gray-200 font-bold text-sm uppercase tracking-wide mb-1" }} 15 + {{ $linkStyle := "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center" }} 16 + {{ $iconStyle := "w-4 h-4 flex-shrink-0" }} 17 + 18 + <!-- Center section with max-width --> 19 + <div class="grid grid-cols-4 gap-2"> 20 + <div class="flex flex-col gap-1"> 21 + <div class="{{ $headerStyle }}">legal</div> 22 + <a href="/terms" class="{{ $linkStyle }}">{{ i "file-text" $iconStyle }} terms of service</a> 23 + <a href="/privacy" class="{{ $linkStyle }}">{{ i "shield" $iconStyle }} privacy policy</a> 24 + </div> 25 + 26 + <div class="flex flex-col gap-1"> 27 + <div class="{{ $headerStyle }}">resources</div> 28 + <a href="https://blog.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a> 29 + <a href="https://tangled.org/@tangled.org/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 30 + <a href="https://tangled.org/@tangled.org/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a> 31 + <a href="https://tangled.org/brand" class="{{ $linkStyle }}">{{ i "paintbrush" $iconStyle }} brand</a> 32 + </div> 33 + 34 + <div class="flex flex-col gap-1"> 35 + <div class="{{ $headerStyle }}">social</div> 36 + <a href="https://chat.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "message-circle" $iconStyle }} discord</a> 37 + <a href="https://web.libera.chat/#tangled" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "hash" $iconStyle }} irc</a> 38 + <a href="https://bsky.app/profile/tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ template "user/fragments/bluesky" $iconStyle }} bluesky</a> 39 + </div> 40 + 41 + <div class="flex flex-col gap-1"> 42 + <div class="{{ $headerStyle }}">contact</div> 43 + <a href="mailto:team@tangled.org" class="{{ $linkStyle }}">{{ i "mail" "w-4 h-4 flex-shrink-0" }} team@tangled.org</a> 44 + <a href="mailto:security@tangled.org" class="{{ $linkStyle }}">{{ i "shield-check" "w-4 h-4 flex-shrink-0" }} security@tangled.org</a> 45 + </div> 46 </div> 47 48 + <!-- Right section --> 49 + <div class="text-right"> 50 + <div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div> 51 </div> 52 + </div> 53 54 + <!-- Mobile layout: stacked --> 55 + <div class="lg:hidden flex flex-col gap-8"> 56 + {{ $headerStyle := "text-gray-900 dark:text-gray-200 font-bold text-xs uppercase tracking-wide mb-1" }} 57 + {{ $linkStyle := "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center" }} 58 + {{ $iconStyle := "w-4 h-4 flex-shrink-0" }} 59 + 60 + <div class="mb-4 md:mb-0"> 61 + <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic no-underline hover:no-underline"> 62 + {{ template "fragments/logotypeSmall" }} 63 + </a> 64 </div> 65 66 + <div class="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-4 sm:gap-6 md:gap-2 gap-6"> 67 + <div class="flex flex-col gap-1"> 68 + <div class="{{ $headerStyle }}">legal</div> 69 + <a href="/terms" class="{{ $linkStyle }}">{{ i "file-text" $iconStyle }} terms of service</a> 70 + <a href="/privacy" class="{{ $linkStyle }}">{{ i "shield" $iconStyle }} privacy policy</a> 71 + </div> 72 + 73 + <div class="flex flex-col gap-1"> 74 + <div class="{{ $headerStyle }}">resources</div> 75 + <a href="https://blog.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a> 76 + <a href="https://tangled.org/@tangled.org/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 77 + <a href="https://tangled.org/@tangled.org/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a> 78 + <a href="https://tangled.org/brand" class="{{ $linkStyle }}">{{ i "paintbrush" $iconStyle }} brand</a> 79 + </div> 80 + 81 + <div class="flex flex-col gap-1"> 82 + <div class="{{ $headerStyle }}">social</div> 83 + <a href="https://chat.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "message-circle" $iconStyle }} discord</a> 84 + <a href="https://web.libera.chat/#tangled" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "hash" $iconStyle }} irc</a> 85 + <a href="https://bsky.app/profile/tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ template "user/fragments/bluesky" $iconStyle }} bluesky</a> 86 + </div> 87 + 88 + <div class="flex flex-col gap-1"> 89 + <div class="{{ $headerStyle }}">contact</div> 90 + <a href="mailto:team@tangled.org" class="{{ $linkStyle }}">{{ i "mail" "w-4 h-4 flex-shrink-0" }} team@tangled.org</a> 91 + <a href="mailto:security@tangled.org" class="{{ $linkStyle }}">{{ i "shield-check" "w-4 h-4 flex-shrink-0" }} security@tangled.org</a> 92 + </div> 93 </div> 94 95 + <div class="text-center"> 96 + <div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div> 97 + </div> 98 </div> 99 </div> 100 </div>
+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
··· 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
··· 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
··· 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
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
··· 4 {{ template "repo/fragments/meta" . }} 5 6 {{ $title := printf "%s at %s &middot; %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 &middot; %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
··· 4 5 {{ define "extrameta" }} 6 {{ $title := printf "branches &middot; %s" .RepoInfo.FullName }} 7 - {{ $url := printf "https://tangled.sh/%s/branches" .RepoInfo.FullName }} 8 - 9 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 10 {{ end }} 11
··· 4 5 {{ define "extrameta" }} 6 {{ $title := printf "branches &middot; %s" .RepoInfo.FullName }} 7 + {{ $url := printf "https://tangled.org/%s/branches" .RepoInfo.FullName }} 8 + 9 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 10 {{ end }} 11
+2 -2
appview/pages/templates/repo/commit.html
··· 2 3 {{ define "extrameta" }} 4 {{ $title := printf "commit %s &middot; %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 &middot; %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
··· 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
··· 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
···
··· 1 + {{ define "repo/fragments/colorBall" }} 2 + <div 3 + class="size-2 rounded-full {{ .classes }}" 4 + style="background: radial-gradient(circle at 35% 35%, color-mix(in srgb, {{ .color }} 70%, white), {{ .color }} 30%, color-mix(in srgb, {{ .color }} 85%, black));" 5 + ></div> 6 + {{ end }}
+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
···
··· 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
···
··· 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
···
··· 1 + {{ define "repo/fragments/labelSectionHeaderText" }} 2 + <span class="text-sm py-1 font-bold text-gray-500 dark:text-gray-400 capitalize">{{ . }}</span> 3 + {{ end }}
-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
··· 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 {{ define "repo/fragments/og" }} 2 {{ $title := or .Title .RepoInfo.FullName }} 3 {{ $description := or .Description .RepoInfo.Description }} 4 - {{ $url := or .Url (printf "https://tangled.sh/%s" .RepoInfo.FullName) }} 5 6 7 <meta property="og:title" content="{{ unescapeHtml $title }}" />
··· 1 {{ define "repo/fragments/og" }} 2 {{ $title := or .Title .RepoInfo.FullName }} 3 {{ $description := or .Description .RepoInfo.Description }} 4 + {{ $url := or .Url (printf "https://tangled.org/%s" .RepoInfo.FullName) }} 5 6 7 <meta property="og:title" content="{{ unescapeHtml $title }}" />
+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
···
··· 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
··· 1 {{ define "repo/fragments/shortTimeAgo" }} 2 - {{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (print (. | shortRelTimeFmt) " ago")) }} 3 {{ end }} 4
··· 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
··· 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
··· 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
···
··· 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
···
··· 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
··· 3 4 {{ define "extrameta" }} 5 {{ $title := printf "%s &middot; issue #%d &middot; %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 &middot; issue #%d &middot; %s" .Issue.Title .Issue.IssueId .RepoInfo.FullName }} 6 + {{ $url := printf "https://tangled.org/%s/issues/%d" .RepoInfo.FullName .Issue.IssueId }} 7 8 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 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
··· 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
··· 2 3 {{ define "extrameta" }} 4 {{ $title := printf "commits &middot; %s" .RepoInfo.FullName }} 5 - {{ $url := printf "https://tangled.sh/%s/commits" .RepoInfo.FullName }} 6 7 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 8 {{ end }}
··· 2 3 {{ define "extrameta" }} 4 {{ $title := printf "commits &middot; %s" .RepoInfo.FullName }} 5 + {{ $url := printf "https://tangled.org/%s/commits" .RepoInfo.FullName }} 6 7 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 8 {{ end }}
+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 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
··· 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
··· 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
+2 -2
appview/pages/templates/repo/pulls/interdiff.html
··· 5 6 {{ define "extrameta" }} 7 {{ $title := printf "interdiff of %d and %d &middot; %s &middot; pull #%d &middot; %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 &middot; %s &middot; pull #%d &middot; %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
··· 5 6 {{ define "extrameta" }} 7 {{ $title := printf "patch of %s &middot; pull #%d &middot; %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 &middot; pull #%d &middot; %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
··· 4 5 {{ define "extrameta" }} 6 {{ $title := printf "%s &middot; pull #%d &middot; %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 &middot; pull #%d &middot; %s" .Pull.Title .Pull.PullId .RepoInfo.FullName }} 7 + {{ $url := printf "https://tangled.org/%s/pulls/%d" .RepoInfo.FullName .Pull.PullId }} 8 9 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 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
··· 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
···
··· 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
···
··· 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
··· 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
··· 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 -10
appview/pages/templates/repo/tags.html
··· 4 5 {{ define "extrameta" }} 6 {{ $title := printf "tags &middot; %s" .RepoInfo.FullName }} 7 - {{ $url := printf "https://tangled.sh/%s/tags" .RepoInfo.FullName }} 8 - 9 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 10 {{ end }} 11 ··· 26 27 <div class="flex items-center gap-3 text-gray-500 dark:text-gray-400 text-sm"> 28 {{ if .Tag }} 29 - <a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Tag.Target.String }}" 30 class="no-underline hover:underline text-gray-500 dark:text-gray-400"> 31 {{ slice .Tag.Target.String 0 8 }} 32 </a> ··· 48 </a> 49 <div class="flex flex-grow flex-col text-gray-500 dark:text-gray-400 text-sm"> 50 {{ if .Tag }} 51 - <a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Tag.Target.String }}" 52 class="no-underline hover:underline text-gray-500 dark:text-gray-400 flex items-center gap-2"> 53 {{ i "git-commit-horizontal" "w-4 h-4" }} 54 {{ slice .Tag.Target.String 0 8 }} ··· 132 hx-target="this" 133 class="flex items-center gap-2 px-2"> 134 <div class="flex-grow"> 135 - <input type="file" 136 - name="artifact" 137 required 138 class="block py-2 px-0 w-full border-none 139 text-black dark:text-white ··· 148 </input> 149 </div> 150 <div class="flex justify-end"> 151 - <button 152 - type="submit" 153 - class="btn gap-2" 154 id="upload-btn-{{$unique}}" 155 title="Upload artifact"> 156 {{ i "upload" "w-4 h-4" }} 157 - <span class="hidden md:inline">upload</span> 158 </button> 159 </div> 160 </form>
··· 4 5 {{ define "extrameta" }} 6 {{ $title := printf "tags &middot; %s" .RepoInfo.FullName }} 7 + {{ $url := printf "https://tangled.org/%s/tags" .RepoInfo.FullName }} 8 + 9 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 10 {{ end }} 11 ··· 26 27 <div class="flex items-center gap-3 text-gray-500 dark:text-gray-400 text-sm"> 28 {{ if .Tag }} 29 + <a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Tag.Target.String }}" 30 class="no-underline hover:underline text-gray-500 dark:text-gray-400"> 31 {{ slice .Tag.Target.String 0 8 }} 32 </a> ··· 48 </a> 49 <div class="flex flex-grow flex-col text-gray-500 dark:text-gray-400 text-sm"> 50 {{ if .Tag }} 51 + <a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Tag.Target.String }}" 52 class="no-underline hover:underline text-gray-500 dark:text-gray-400 flex items-center gap-2"> 53 {{ i "git-commit-horizontal" "w-4 h-4" }} 54 {{ slice .Tag.Target.String 0 8 }} ··· 132 hx-target="this" 133 class="flex items-center gap-2 px-2"> 134 <div class="flex-grow"> 135 + <input type="file" 136 + name="artifact" 137 required 138 class="block py-2 px-0 w-full border-none 139 text-black dark:text-white ··· 148 </input> 149 </div> 150 <div class="flex justify-end"> 151 + <button 152 + type="submit" 153 + class="btn gap-2" 154 id="upload-btn-{{$unique}}" 155 title="Upload artifact"> 156 {{ i "upload" "w-4 h-4" }} 157 + <span class="hidden md:inline">upload</span> 158 </button> 159 </div> 160 </form>
+10 -4
appview/pages/templates/repo/tree.html
··· 10 11 {{ template "repo/fragments/meta" . }} 12 {{ $title := printf "%s at %s &middot; %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 &middot; %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
··· 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
··· 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
··· 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 {{ $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
··· 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
···
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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 <img 3 src="{{ tinyAvatar . }}" 4 alt="" 5 - class="rounded-full h-6 w-6 mr-1 border border-gray-300 dark:border-gray-700" 6 /> 7 - {{ . | truncateAt30 }} 8 {{ end }}
··· 2 <img 3 src="{{ tinyAvatar . }}" 4 alt="" 5 + class="rounded-full h-6 w-6 border border-gray-300 dark:border-gray-700" 6 /> 7 + {{ . | resolve | truncateAt30 }} 8 {{ end }}
+2 -3
appview/pages/templates/user/fragments/picHandleLink.html
··· 1 {{ define "user/fragments/picHandleLink" }} 2 - {{ $resolved := resolve . }} 3 - <a href="/{{ $resolved }}" class="flex items-center"> 4 - {{ template "user/fragments/picHandle" $resolved }} 5 </a> 6 {{ end }}
··· 1 {{ define "user/fragments/picHandleLink" }} 2 + <a href="/{{ resolve . }}" class="flex items-center gap-1"> 3 + {{ template "user/fragments/picHandle" . }} 4 </a> 5 {{ end }}
+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 <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 &middot; 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 &middot; 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
··· 73 {{ with .Repo.RepoStats }} 74 {{ with .Language }} 75 <div class="flex gap-2 items-center text-xs font-mono text-gray-400 "> 76 - {{ template "repo/fragments/languageBall" . }} 77 <span>{{ . }}</span> 78 </div> 79 {{end }}
··· 73 {{ with .Repo.RepoStats }} 74 {{ with .Language }} 75 <div class="flex gap-2 items-center text-xs font-mono text-gray-400 "> 76 + {{ template "repo/fragments/colorBall" (dict "color" (langColor .)) }} 77 <span>{{ . }}</span> 78 </div> 79 {{end }}
+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
··· 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 &middot; 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 &middot; 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
··· 8 func FirstPage() Page { 9 return Page{ 10 Offset: 0, 11 - Limit: 10, 12 } 13 } 14
··· 8 func FirstPage() Page { 9 return Page{ 10 Offset: 0, 11 + Limit: 30, 12 } 13 } 14
+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
··· 4 "net/http" 5 6 "github.com/go-chi/chi/v5" 7 - "tangled.sh/tangled.sh/core/appview/middleware" 8 ) 9 10 func (p *Pipelines) Router(mw *middleware.Middleware) http.Handler {
··· 4 "net/http" 5 6 "github.com/go-chi/chi/v5" 7 + "tangled.org/core/appview/middleware" 8 ) 9 10 func (p *Pipelines) Router(mw *middleware.Middleware) http.Handler {
-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
··· 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
··· 4 "net/http" 5 6 "github.com/go-chi/chi/v5" 7 - "tangled.sh/tangled.sh/core/appview/middleware" 8 ) 9 10 func (s *Pulls) Router(mw *middleware.Middleware) http.Handler {
··· 4 "net/http" 5 6 "github.com/go-chi/chi/v5" 7 + "tangled.org/core/appview/middleware" 8 ) 9 10 func (s *Pulls) Router(mw *middleware.Middleware) http.Handler {
+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
··· 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
··· 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
··· 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
··· 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
··· 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 "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
··· 6 "fmt" 7 8 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 9 - "tangled.sh/tangled.sh/core/api/tangled" 10 - "tangled.sh/tangled.sh/core/appview/db" 11 - "tangled.sh/tangled.sh/core/appview/xrpcclient" 12 - "tangled.sh/tangled.sh/core/rbac" 13 ) 14 15 var (
··· 6 "fmt" 7 8 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 9 + "tangled.org/core/api/tangled" 10 + "tangled.org/core/appview/db" 11 + "tangled.org/core/appview/xrpcclient" 12 + "tangled.org/core/rbac" 13 ) 14 15 var (
+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
··· 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
··· 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
··· 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
···
··· 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
··· 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
··· 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
··· 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 &params, err 286 } 287 288 if len(follows) == 0 { 289 + return &params, 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 &params, 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 &params, 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 &params, 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
··· 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
··· 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
··· 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 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
··· 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
··· 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 "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
···
··· 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
···
··· 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
··· 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
··· 7 "net/http" 8 "os" 9 10 - "tangled.sh/tangled.sh/core/appview/config" 11 - "tangled.sh/tangled.sh/core/appview/state" 12 ) 13 14 func main() {
··· 7 "net/http" 8 "os" 9 10 + "tangled.org/core/appview/config" 11 + "tangled.org/core/appview/state" 12 ) 13 14 func main() {
+1 -1
cmd/combinediff/main.go
··· 5 "os" 6 7 "github.com/bluekeyes/go-gitdiff/gitdiff" 8 - "tangled.sh/tangled.sh/core/patchutil" 9 ) 10 11 func main() {
··· 5 "os" 6 7 "github.com/bluekeyes/go-gitdiff/gitdiff" 8 + "tangled.org/core/patchutil" 9 ) 10 11 func main() {
+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
··· 5 "os" 6 7 "github.com/bluekeyes/go-gitdiff/gitdiff" 8 - "tangled.sh/tangled.sh/core/patchutil" 9 ) 10 11 func main() {
··· 5 "os" 6 7 "github.com/bluekeyes/go-gitdiff/gitdiff" 8 + "tangled.org/core/patchutil" 9 ) 10 11 func main() {
+5 -5
cmd/knot/main.go
··· 5 "os" 6 7 "github.com/urfave/cli/v3" 8 - "tangled.sh/tangled.sh/core/guard" 9 - "tangled.sh/tangled.sh/core/hook" 10 - "tangled.sh/tangled.sh/core/keyfetch" 11 - "tangled.sh/tangled.sh/core/knotserver" 12 - "tangled.sh/tangled.sh/core/log" 13 ) 14 15 func main() {
··· 5 "os" 6 7 "github.com/urfave/cli/v3" 8 + "tangled.org/core/guard" 9 + "tangled.org/core/hook" 10 + "tangled.org/core/keyfetch" 11 + "tangled.org/core/knotserver" 12 + "tangled.org/core/log" 13 ) 14 15 func main() {
+3 -3
cmd/spindle/main.go
··· 4 "context" 5 "os" 6 7 - "tangled.sh/tangled.sh/core/log" 8 - "tangled.sh/tangled.sh/core/spindle" 9 - _ "tangled.sh/tangled.sh/core/tid" 10 ) 11 12 func main() {
··· 4 "context" 5 "os" 6 7 + "tangled.org/core/log" 8 + "tangled.org/core/spindle" 9 + _ "tangled.org/core/tid" 10 ) 11 12 func main() {
+1 -1
cmd/verifysig/main.go
··· 7 "os" 8 "strings" 9 10 - "tangled.sh/tangled.sh/core/crypto" 11 ) 12 13 func parseCommitObject(commitData string) (string, string, error) {
··· 7 "os" 8 "strings" 9 10 + "tangled.org/core/crypto" 11 ) 12 13 func parseCommitObject(commitData string) (string, string, error) {
+9
consts/consts.go
···
··· 1 + package consts 2 + 3 + const ( 4 + TangledDid = "did:plc:wshs7t2adsemcrrd4snkeqli" 5 + IcyDid = "did:plc:hwevmowznbiukdf6uk5dwrrq" 6 + 7 + DefaultSpindle = "spindle.tangled.sh" 8 + DefaultKnot = "knot1.tangled.sh" 9 + )
+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
··· 9 10 "github.com/hiddeco/sshsig" 11 "golang.org/x/crypto/ssh" 12 - "tangled.sh/tangled.sh/core/types" 13 ) 14 15 func VerifySignature(pubKey, signature, payload []byte) (error, bool) {
··· 9 10 "github.com/hiddeco/sshsig" 11 "golang.org/x/crypto/ssh" 12 + "tangled.org/core/types" 13 ) 14 15 func VerifySignature(pubKey, signature, payload []byte) (error, bool) {
+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
··· 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
··· 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
···
··· 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
··· 44 ### production 45 46 You would typically use a systemd service with a configuration file. Refer to 47 - [@tangled.sh/infra](https://tangled.sh/@tangled.sh/infra) for how this can be 48 achieved using Nix. 49 50 Then, initialize the bao server:
··· 44 ### production 45 46 You would typically use a systemd service with a configuration file. Refer to 47 + [@tangled.org/infra](https://tangled.org/@tangled.org/infra) for how this can be 48 achieved using Nix. 49 50 Then, initialize the bao server:
+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
··· 9 "sync" 10 "time" 11 12 - "tangled.sh/tangled.sh/core/eventconsumer/cursor" 13 - "tangled.sh/tangled.sh/core/log" 14 15 "github.com/avast/retry-go/v4" 16 "github.com/gorilla/websocket"
··· 9 "sync" 10 "time" 11 12 + "tangled.org/core/eventconsumer/cursor" 13 + "tangled.org/core/log" 14 15 "github.com/avast/retry-go/v4" 16 "github.com/gorilla/websocket"
+1 -1
eventconsumer/cursor/redis.go
··· 5 "fmt" 6 "strconv" 7 8 - "tangled.sh/tangled.sh/core/appview/cache" 9 ) 10 11 const (
··· 5 "fmt" 6 "strconv" 7 8 + "tangled.org/core/appview/cache" 9 ) 10 11 const (
+15
flake.lock
··· 1 { 2 "nodes": { 3 "flake-utils": { 4 "inputs": { 5 "systems": "systems" ··· 136 }, 137 "root": { 138 "inputs": { 139 "gomod2nix": "gomod2nix", 140 "htmx-src": "htmx-src", 141 "htmx-ws-src": "htmx-ws-src",
··· 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 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
··· 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
··· 15 "github.com/bluesky-social/indigo/atproto/identity" 16 securejoin "github.com/cyphar/filepath-securejoin" 17 "github.com/urfave/cli/v3" 18 - "tangled.sh/tangled.sh/core/idresolver" 19 - "tangled.sh/tangled.sh/core/log" 20 ) 21 22 func Command() *cli.Command {
··· 15 "github.com/bluesky-social/indigo/atproto/identity" 16 securejoin "github.com/cyphar/filepath-securejoin" 17 "github.com/urfave/cli/v3" 18 + "tangled.org/core/idresolver" 19 + "tangled.org/core/log" 20 ) 21 22 func Command() *cli.Command {
+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 - }
··· 228 } 229 /* LineHighlight */ 230 .chroma .hl { 231 + @apply bg-amber-400/30 dark:bg-amber-500/20; 232 } 233 + 234 /* LineNumbersTable */ 235 .chroma .lnt { 236 white-space: pre; ··· 865 text-decoration: underline; 866 } 867 }
+1 -1
jetstream/jetstream.go
··· 13 "github.com/bluesky-social/jetstream/pkg/client" 14 "github.com/bluesky-social/jetstream/pkg/client/schedulers/sequential" 15 "github.com/bluesky-social/jetstream/pkg/models" 16 - "tangled.sh/tangled.sh/core/log" 17 ) 18 19 type DB interface {
··· 13 "github.com/bluesky-social/jetstream/pkg/client" 14 "github.com/bluesky-social/jetstream/pkg/client/schedulers/sequential" 15 "github.com/bluesky-social/jetstream/pkg/models" 16 + "tangled.org/core/log" 17 ) 18 19 type DB interface {
+1 -1
keyfetch/keyfetch.go
··· 10 "strings" 11 12 "github.com/urfave/cli/v3" 13 - "tangled.sh/tangled.sh/core/log" 14 ) 15 16 func Command() *cli.Command {
··· 10 "strings" 11 12 "github.com/urfave/cli/v3" 13 + "tangled.org/core/log" 14 ) 15 16 func Command() *cli.Command {
+1 -1
knotserver/config/config.go
··· 41 Repo Repo `env:",prefix=KNOT_REPO_"` 42 Server Server `env:",prefix=KNOT_SERVER_"` 43 Git Git `env:",prefix=KNOT_GIT_"` 44 - AppViewEndpoint string `env:"APPVIEW_ENDPOINT, default=https://tangled.sh"` 45 } 46 47 func Load(ctx context.Context) (*Config, error) {
··· 41 Repo Repo `env:",prefix=KNOT_REPO_"` 42 Server Server `env:",prefix=KNOT_SERVER_"` 43 Git Git `env:",prefix=KNOT_GIT_"` 44 + AppViewEndpoint string `env:"APPVIEW_ENDPOINT, default=https://tangled.org"` 45 } 46 47 func Load(ctx context.Context) (*Config, error) {
+1 -1
knotserver/db/events.go
··· 4 "fmt" 5 "time" 6 7 - "tangled.sh/tangled.sh/core/notifier" 8 ) 9 10 type Event struct {
··· 4 "fmt" 5 "time" 6 7 + "tangled.org/core/notifier" 8 ) 9 10 type Event struct {
+1 -1
knotserver/db/pubkeys.go
··· 4 "strconv" 5 "time" 6 7 - "tangled.sh/tangled.sh/core/api/tangled" 8 ) 9 10 type PublicKey struct {
··· 4 "strconv" 5 "time" 6 7 + "tangled.org/core/api/tangled" 8 ) 9 10 type PublicKey struct {
+1 -1
knotserver/git/branch.go
··· 9 10 "github.com/go-git/go-git/v5/plumbing" 11 "github.com/go-git/go-git/v5/plumbing/object" 12 - "tangled.sh/tangled.sh/core/types" 13 ) 14 15 func (g *GitRepo) Branches() ([]types.Branch, error) {
··· 9 10 "github.com/go-git/go-git/v5/plumbing" 11 "github.com/go-git/go-git/v5/plumbing/object" 12 + "tangled.org/core/types" 13 ) 14 15 func (g *GitRepo) Branches() ([]types.Branch, error) {
+2 -2
knotserver/git/diff.go
··· 12 "github.com/bluekeyes/go-gitdiff/gitdiff" 13 "github.com/go-git/go-git/v5/plumbing" 14 "github.com/go-git/go-git/v5/plumbing/object" 15 - "tangled.sh/tangled.sh/core/patchutil" 16 - "tangled.sh/tangled.sh/core/types" 17 ) 18 19 func (g *GitRepo) Diff() (*types.NiceDiff, error) {
··· 12 "github.com/bluekeyes/go-gitdiff/gitdiff" 13 "github.com/go-git/go-git/v5/plumbing" 14 "github.com/go-git/go-git/v5/plumbing/object" 15 + "tangled.org/core/patchutil" 16 + "tangled.org/core/types" 17 ) 18 19 func (g *GitRepo) Diff() (*types.NiceDiff, error) {
-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
··· 3 import ( 4 "context" 5 "path" 6 7 "github.com/go-enry/go-enry/v2" 8 "github.com/go-git/go-git/v5/plumbing/object" ··· 20 return nil 21 } 22 23 - if enry.IsGenerated(filepath, content) { 24 return nil 25 } 26
··· 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
··· 9 "strings" 10 "time" 11 12 - "tangled.sh/tangled.sh/core/api/tangled" 13 14 "github.com/go-git/go-git/v5/plumbing" 15 )
··· 9 "strings" 10 "time" 11 12 + "tangled.org/core/api/tangled" 13 14 "github.com/go-git/go-git/v5/plumbing" 15 )
+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
··· 8 "time" 9 10 "github.com/go-git/go-git/v5/plumbing/object" 11 - "tangled.sh/tangled.sh/core/types" 12 ) 13 14 func (g *GitRepo) FileTree(ctx context.Context, path string) ([]types.NiceTree, error) {
··· 8 "time" 9 10 "github.com/go-git/go-git/v5/plumbing/object" 11 + "tangled.org/core/types" 12 ) 13 14 func (g *GitRepo) FileTree(ctx context.Context, path string) ([]types.NiceTree, error) {
+1 -1
knotserver/git.go
··· 10 11 securejoin "github.com/cyphar/filepath-securejoin" 12 "github.com/go-chi/chi/v5" 13 - "tangled.sh/tangled.sh/core/knotserver/git/service" 14 ) 15 16 func (d *Knot) InfoRefs(w http.ResponseWriter, r *http.Request) {
··· 10 11 securejoin "github.com/cyphar/filepath-securejoin" 12 "github.com/go-chi/chi/v5" 13 + "tangled.org/core/knotserver/git/service" 14 ) 15 16 func (d *Knot) InfoRefs(w http.ResponseWriter, r *http.Request) {
-4
knotserver/http_util.go
··· 16 w.WriteHeader(status) 17 json.NewEncoder(w).Encode(map[string]string{"error": msg}) 18 } 19 - 20 - func notFound(w http.ResponseWriter) { 21 - writeError(w, "not found", http.StatusNotFound) 22 - }
··· 16 w.WriteHeader(status) 17 json.NewEncoder(w).Encode(map[string]string{"error": msg}) 18 }
+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
··· 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
··· 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
··· 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
··· 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
··· 1 package knotserver 2 3 import ( 4 "github.com/bluesky-social/indigo/atproto/syntax" 5 ) 6 7 var TIDClock = syntax.NewTIDClock(0) 8
+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
··· 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
··· 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
··· 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) {
+4 -4
knotserver/xrpc/hidden_ref.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 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 16 ) 17 18 func (x *Xrpc) HiddenRef(w http.ResponseWriter, r *http.Request) {
··· 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 + xrpcerr "tangled.org/core/xrpc/errors" 16 ) 17 18 func (x *Xrpc) HiddenRef(w http.ResponseWriter, r *http.Request) {
+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 }
··· 1 package xrpc 2 3 import ( 4 "net/http" 5 "strconv" 6 7 + "tangled.org/core/api/tangled" 8 + xrpcerr "tangled.org/core/xrpc/errors" 9 ) 10 11 func (x *Xrpc) ListKeys(w http.ResponseWriter, r *http.Request) { ··· 45 response.Cursor = &nextCursor 46 } 47 48 + writeJson(w, response) 49 }
+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
··· 7 "net/http" 8 9 securejoin "github.com/cyphar/filepath-securejoin" 10 - "tangled.sh/tangled.sh/core/api/tangled" 11 - "tangled.sh/tangled.sh/core/knotserver/git" 12 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 13 ) 14 15 func (x *Xrpc) MergeCheck(w http.ResponseWriter, r *http.Request) {
··· 7 "net/http" 8 9 securejoin "github.com/cyphar/filepath-securejoin" 10 + "tangled.org/core/api/tangled" 11 + "tangled.org/core/knotserver/git" 12 + xrpcerr "tangled.org/core/xrpc/errors" 13 ) 14 15 func (x *Xrpc) MergeCheck(w http.ResponseWriter, r *http.Request) {
+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 }
··· 1 package xrpc 2 3 import ( 4 "net/http" 5 6 + "tangled.org/core/api/tangled" 7 + xrpcerr "tangled.org/core/xrpc/errors" 8 ) 9 10 func (x *Xrpc) Owner(w http.ResponseWriter, r *http.Request) { ··· 18 Owner: owner, 19 } 20 21 + writeJson(w, response) 22 }
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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 }
+6 -19
knotserver/xrpc/repo_tags.go
··· 1 package xrpc 2 3 import ( 4 - "encoding/json" 5 "net/http" 6 "strconv" 7 8 "github.com/go-git/go-git/v5/plumbing" 9 "github.com/go-git/go-git/v5/plumbing/object" 10 11 - "tangled.sh/tangled.sh/core/knotserver/git" 12 - "tangled.sh/tangled.sh/core/types" 13 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 14 ) 15 16 func (x *Xrpc) RepoTags(w http.ResponseWriter, r *http.Request) { ··· 30 } 31 } 32 33 - gr, err := git.Open(repoPath, "") 34 if err != nil { 35 x.Logger.Error("failed to open", "error", err) 36 - writeError(w, xrpcerr.NewXrpcError( 37 - xrpcerr.WithTag("RepoNotFound"), 38 - xrpcerr.WithMessage("repository not found"), 39 - ), http.StatusNotFound) 40 return 41 } 42 ··· 86 Tags: paginatedTags, 87 } 88 89 - // Write JSON response directly 90 - w.Header().Set("Content-Type", "application/json") 91 - if err := json.NewEncoder(w).Encode(response); err != nil { 92 - x.Logger.Error("failed to encode response", "error", err) 93 - writeError(w, xrpcerr.NewXrpcError( 94 - xrpcerr.WithTag("InternalServerError"), 95 - xrpcerr.WithMessage("failed to encode response"), 96 - ), http.StatusInternalServerError) 97 - return 98 - } 99 }
··· 1 package xrpc 2 3 import ( 4 "net/http" 5 "strconv" 6 7 "github.com/go-git/go-git/v5/plumbing" 8 "github.com/go-git/go-git/v5/plumbing/object" 9 10 + "tangled.org/core/knotserver/git" 11 + "tangled.org/core/types" 12 + xrpcerr "tangled.org/core/xrpc/errors" 13 ) 14 15 func (x *Xrpc) RepoTags(w http.ResponseWriter, r *http.Request) { ··· 29 } 30 } 31 32 + gr, err := git.PlainOpen(repoPath) 33 if err != nil { 34 x.Logger.Error("failed to open", "error", err) 35 + writeError(w, xrpcerr.RepoNotFoundError, http.StatusNoContent) 36 return 37 } 38 ··· 82 Tags: paginatedTags, 83 } 84 85 + writeJson(w, response) 86 }
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 3 "package": "tangled", 4 "prefix": "sh.tangled", 5 "outdir": "api/tangled", 6 - "import": "tangled.sh/tangled.sh/core/api/tangled", 7 "gen-server": true 8 } 9 ]
··· 3 "package": "tangled", 4 "prefix": "sh.tangled", 5 "outdir": "api/tangled", 6 + "import": "tangled.org/core/api/tangled", 7 "gen-server": true 8 } 9 ]
+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
···
··· 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
··· 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
··· 41 "type": "string", 42 "description": "Parent directory path" 43 }, 44 "files": { 45 "type": "array", 46 "items": { ··· 69 "description": "Invalid request parameters" 70 } 71 ] 72 }, 73 "treeEntry": { 74 "type": "object",
··· 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
··· 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
··· 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
··· 5 "strings" 6 7 "github.com/bluekeyes/go-gitdiff/gitdiff" 8 - "tangled.sh/tangled.sh/core/types" 9 ) 10 11 type InterdiffResult struct {
··· 5 "strings" 6 7 "github.com/bluekeyes/go-gitdiff/gitdiff" 8 + "tangled.org/core/types" 9 ) 10 11 type InterdiffResult struct {
+1 -1
patchutil/patchutil.go
··· 10 "strings" 11 12 "github.com/bluekeyes/go-gitdiff/gitdiff" 13 - "tangled.sh/tangled.sh/core/types" 14 ) 15 16 func ExtractPatches(formatPatch string) ([]types.FormatPatch, error) {
··· 10 "strings" 11 12 "github.com/bluekeyes/go-gitdiff/gitdiff" 13 + "tangled.org/core/types" 14 ) 15 16 func ExtractPatches(formatPatch string) ([]types.FormatPatch, error) {
+1 -1
rbac/rbac_test.go
··· 4 "database/sql" 5 "testing" 6 7 - "tangled.sh/tangled.sh/core/rbac" 8 9 adapter "github.com/Blank-Xu/sql-adapter" 10 "github.com/casbin/casbin/v2"
··· 4 "database/sql" 5 "testing" 6 7 + "tangled.org/core/rbac" 8 9 adapter "github.com/Blank-Xu/sql-adapter" 10 "github.com/casbin/casbin/v2"
+4 -4
readme.md
··· 1 # tangled 2 3 Hello Tanglers! This is the codebase for 4 - [Tangled](https://tangled.sh)&mdash;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)&mdash;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
··· 5 "fmt" 6 "time" 7 8 - "tangled.sh/tangled.sh/core/api/tangled" 9 - "tangled.sh/tangled.sh/core/notifier" 10 - "tangled.sh/tangled.sh/core/spindle/models" 11 - "tangled.sh/tangled.sh/core/tid" 12 ) 13 14 type Event struct {
··· 5 "fmt" 6 "time" 7 8 + "tangled.org/core/api/tangled" 9 + "tangled.org/core/notifier" 10 + "tangled.org/core/spindle/models" 11 + "tangled.org/core/tid" 12 ) 13 14 type Event struct {
+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 (
··· 8 9 securejoin "github.com/cyphar/filepath-securejoin" 10 "golang.org/x/sync/errgroup" 11 + "tangled.org/core/notifier" 12 + "tangled.org/core/spindle/config" 13 + "tangled.org/core/spindle/db" 14 + "tangled.org/core/spindle/models" 15 + "tangled.org/core/spindle/secrets" 16 ) 17 18 var (
+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
··· 5 "path" 6 "strings" 7 8 - "tangled.sh/tangled.sh/core/api/tangled" 9 - "tangled.sh/tangled.sh/core/workflow" 10 ) 11 12 func nixConfStep() Step {
··· 5 "path" 6 "strings" 7 8 + "tangled.org/core/api/tangled" 9 + "tangled.org/core/workflow" 10 ) 11 12 func nixConfStep() Step {
+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
··· 4 "context" 5 "time" 6 7 - "tangled.sh/tangled.sh/core/api/tangled" 8 - "tangled.sh/tangled.sh/core/spindle/secrets" 9 ) 10 11 type Engine interface {
··· 4 "context" 5 "time" 6 7 + "tangled.org/core/api/tangled" 8 + "tangled.org/core/spindle/secrets" 9 ) 10 11 type Engine interface {
+1 -1
spindle/models/models.go
··· 5 "regexp" 6 "slices" 7 8 - "tangled.sh/tangled.sh/core/api/tangled" 9 10 "github.com/bluesky-social/indigo/atproto/syntax" 11 )
··· 5 "regexp" 6 "slices" 7 8 + "tangled.org/core/api/tangled" 9 10 "github.com/bluesky-social/indigo/atproto/syntax" 11 )
+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
··· 10 "strconv" 11 "time" 12 13 - "tangled.sh/tangled.sh/core/spindle/models" 14 15 "github.com/go-chi/chi/v5" 16 "github.com/gorilla/websocket"
··· 10 "strconv" 11 "time" 12 13 + "tangled.org/core/spindle/models" 14 15 "github.com/go-chi/chi/v5" 16 "github.com/gorilla/websocket"
+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
··· 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
··· 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) {
··· 4 "encoding/json" 5 "net/http" 6 7 + "tangled.org/core/api/tangled" 8 + xrpcerr "tangled.org/core/xrpc/errors" 9 ) 10 11 func (x *Xrpc) Owner(w http.ResponseWriter, r *http.Request) {
+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
··· 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
··· 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
··· 4 "errors" 5 "fmt" 6 7 - "tangled.sh/tangled.sh/core/api/tangled" 8 ) 9 10 type RawWorkflow struct {
··· 4 "errors" 5 "fmt" 6 7 + "tangled.org/core/api/tangled" 8 ) 9 10 type RawWorkflow struct {
+1 -1
workflow/compile_test.go
··· 5 "testing" 6 7 "github.com/stretchr/testify/assert" 8 - "tangled.sh/tangled.sh/core/api/tangled" 9 ) 10 11 var trigger = tangled.Pipeline_TriggerMetadata{
··· 5 "testing" 6 7 "github.com/stretchr/testify/assert" 8 + "tangled.org/core/api/tangled" 9 ) 10 11 var trigger = tangled.Pipeline_TriggerMetadata{
+1 -1
workflow/def.go
··· 6 "slices" 7 "strings" 8 9 - "tangled.sh/tangled.sh/core/api/tangled" 10 11 "github.com/go-git/go-git/v5/plumbing" 12 "gopkg.in/yaml.v3"
··· 6 "slices" 7 "strings" 8 9 + "tangled.org/core/api/tangled" 10 11 "github.com/go-git/go-git/v5/plumbing" 12 "gopkg.in/yaml.v3"
+10
xrpc/errors/errors.go
··· 56 WithMessage("owner not set for this service"), 57 ) 58 59 var AuthError = func(err error) XrpcError { 60 return NewXrpcError( 61 WithTag("Auth"),
··· 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
··· 8 "strings" 9 10 "github.com/bluesky-social/indigo/atproto/auth" 11 - "tangled.sh/tangled.sh/core/idresolver" 12 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 13 ) 14 15 const ActorDid string = "ActorDid"
··· 8 "strings" 9 10 "github.com/bluesky-social/indigo/atproto/auth" 11 + "tangled.org/core/idresolver" 12 + xrpcerr "tangled.org/core/xrpc/errors" 13 ) 14 15 const ActorDid string = "ActorDid"