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

Compare changes

Choose any two refs to compare.

Changed files
+12043 -4102
.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
serviceauth
+1 -1
.air/knotserver.toml
··· 1 1 [build] 2 - cmd = 'go build -ldflags "-X tangled.sh/tangled.sh/core/knotserver.version=$(git describe --tags --long)" -o .bin/knot ./cmd/knot/' 2 + cmd = 'go build -ldflags "-X tangled.org/core/knotserver.version=$(git describe --tags --long)" -o .bin/knot ./cmd/knot/' 3 3 bin = ".bin/knot server" 4 4 root = "." 5 5
+6
.tangled/workflows/test.yml
··· 14 14 command: | 15 15 mkdir -p appview/pages/static; touch appview/pages/static/x 16 16 17 + - name: run linter 18 + environment: 19 + CGO_ENABLED: 1 20 + command: | 21 + go vet -v ./... 22 + 17 23 - name: run all tests 18 24 environment: 19 25 CGO_ENABLED: 1
+1272 -170
api/tangled/cbor_gen.go
··· 1499 1499 1500 1500 return nil 1501 1501 } 1502 - func (t *GitRefUpdate_LangBreakdown) MarshalCBOR(w io.Writer) error { 1502 + func (t *GitRefUpdate_IndividualLanguageSize) MarshalCBOR(w io.Writer) error { 1503 1503 if t == nil { 1504 1504 _, err := w.Write(cbg.CborNull) 1505 1505 return err 1506 1506 } 1507 1507 1508 1508 cw := cbg.NewCborWriter(w) 1509 - fieldCount := 1 1510 1509 1511 - if t.Inputs == nil { 1512 - fieldCount-- 1510 + if _, err := cw.Write([]byte{162}); err != nil { 1511 + return err 1513 1512 } 1514 1513 1515 - if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 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 { 1516 1523 return err 1517 1524 } 1518 1525 1519 - // t.Inputs ([]*tangled.GitRefUpdate_IndividualLanguageSize) (slice) 1520 - if t.Inputs != nil { 1526 + if len(t.Lang) > 1000000 { 1527 + return xerrors.Errorf("Value in field t.Lang was too long") 1528 + } 1521 1529 1522 - if len("inputs") > 1000000 { 1523 - return xerrors.Errorf("Value in field \"inputs\" was too long") 1524 - } 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 + } 1525 1536 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 - } 1537 + // t.Size (int64) (int64) 1538 + if len("size") > 1000000 { 1539 + return xerrors.Errorf("Value in field \"size\" was too long") 1540 + } 1532 1541 1533 - if len(t.Inputs) > 8192 { 1534 - return xerrors.Errorf("Slice value in field t.Inputs was too long") 1535 - } 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 + } 1536 1548 1537 - if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Inputs))); err != nil { 1549 + if t.Size >= 0 { 1550 + if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.Size)); err != nil { 1538 1551 return err 1539 1552 } 1540 - for _, v := range t.Inputs { 1541 - if err := v.MarshalCBOR(cw); err != nil { 1542 - return err 1543 - } 1544 - 1553 + } else { 1554 + if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.Size-1)); err != nil { 1555 + return err 1545 1556 } 1546 1557 } 1558 + 1547 1559 return nil 1548 1560 } 1549 1561 1550 - func (t *GitRefUpdate_LangBreakdown) UnmarshalCBOR(r io.Reader) (err error) { 1551 - *t = GitRefUpdate_LangBreakdown{} 1562 + func (t *GitRefUpdate_IndividualLanguageSize) UnmarshalCBOR(r io.Reader) (err error) { 1563 + *t = GitRefUpdate_IndividualLanguageSize{} 1552 1564 1553 1565 cr := cbg.NewCborReader(r) 1554 1566 ··· 1567 1579 } 1568 1580 1569 1581 if extra > cbg.MaxLength { 1570 - return fmt.Errorf("GitRefUpdate_LangBreakdown: map struct too large (%d)", extra) 1582 + return fmt.Errorf("GitRefUpdate_IndividualLanguageSize: map struct too large (%d)", extra) 1571 1583 } 1572 1584 1573 1585 n := extra 1574 1586 1575 - nameBuf := make([]byte, 6) 1587 + nameBuf := make([]byte, 4) 1576 1588 for i := uint64(0); i < n; i++ { 1577 1589 nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 1578 1590 if err != nil { ··· 1588 1600 } 1589 1601 1590 1602 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 - } 1603 + // t.Lang (string) (string) 1604 + case "lang": 1602 1605 1603 - if maj != cbg.MajArray { 1604 - return fmt.Errorf("expected cbor array") 1605 - } 1606 + { 1607 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 1608 + if err != nil { 1609 + return err 1610 + } 1606 1611 1607 - if extra > 0 { 1608 - t.Inputs = make([]*GitRefUpdate_IndividualLanguageSize, extra) 1612 + t.Lang = string(sval) 1609 1613 } 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 - 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") 1636 1627 } 1637 - 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) 1638 1636 } 1637 + 1638 + t.Size = int64(extraI) 1639 1639 } 1640 1640 1641 1641 default: ··· 1648 1648 1649 1649 return nil 1650 1650 } 1651 - func (t *GitRefUpdate_IndividualLanguageSize) MarshalCBOR(w io.Writer) error { 1651 + func (t *GitRefUpdate_LangBreakdown) MarshalCBOR(w io.Writer) error { 1652 1652 if t == nil { 1653 1653 _, err := w.Write(cbg.CborNull) 1654 1654 return err 1655 1655 } 1656 1656 1657 1657 cw := cbg.NewCborWriter(w) 1658 + fieldCount := 1 1658 1659 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") 1660 + if t.Inputs == nil { 1661 + fieldCount-- 1666 1662 } 1667 1663 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 { 1664 + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 1672 1665 return err 1673 1666 } 1674 1667 1675 - if len(t.Lang) > 1000000 { 1676 - return xerrors.Errorf("Value in field t.Lang was too long") 1677 - } 1668 + // t.Inputs ([]*tangled.GitRefUpdate_IndividualLanguageSize) (slice) 1669 + if t.Inputs != nil { 1678 1670 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 - } 1671 + if len("inputs") > 1000000 { 1672 + return xerrors.Errorf("Value in field \"inputs\" was too long") 1673 + } 1685 1674 1686 - // t.Size (int64) (int64) 1687 - if len("size") > 1000000 { 1688 - return xerrors.Errorf("Value in field \"size\" was too long") 1689 - } 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 + } 1690 1681 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 - } 1682 + if len(t.Inputs) > 8192 { 1683 + return xerrors.Errorf("Slice value in field t.Inputs was too long") 1684 + } 1697 1685 1698 - if t.Size >= 0 { 1699 - if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.Size)); err != nil { 1686 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Inputs))); err != nil { 1700 1687 return err 1701 1688 } 1702 - } else { 1703 - if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.Size-1)); err != nil { 1704 - return err 1689 + for _, v := range t.Inputs { 1690 + if err := v.MarshalCBOR(cw); err != nil { 1691 + return err 1692 + } 1693 + 1705 1694 } 1706 1695 } 1707 - 1708 1696 return nil 1709 1697 } 1710 1698 1711 - func (t *GitRefUpdate_IndividualLanguageSize) UnmarshalCBOR(r io.Reader) (err error) { 1712 - *t = GitRefUpdate_IndividualLanguageSize{} 1699 + func (t *GitRefUpdate_LangBreakdown) UnmarshalCBOR(r io.Reader) (err error) { 1700 + *t = GitRefUpdate_LangBreakdown{} 1713 1701 1714 1702 cr := cbg.NewCborReader(r) 1715 1703 ··· 1728 1716 } 1729 1717 1730 1718 if extra > cbg.MaxLength { 1731 - return fmt.Errorf("GitRefUpdate_IndividualLanguageSize: map struct too large (%d)", extra) 1719 + return fmt.Errorf("GitRefUpdate_LangBreakdown: map struct too large (%d)", extra) 1732 1720 } 1733 1721 1734 1722 n := extra 1735 1723 1736 - nameBuf := make([]byte, 4) 1724 + nameBuf := make([]byte, 6) 1737 1725 for i := uint64(0); i < n; i++ { 1738 1726 nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 1739 1727 if err != nil { ··· 1749 1737 } 1750 1738 1751 1739 switch string(nameBuf[:nameLen]) { 1752 - // t.Lang (string) (string) 1753 - case "lang": 1740 + // t.Inputs ([]*tangled.GitRefUpdate_IndividualLanguageSize) (slice) 1741 + case "inputs": 1754 1742 1755 - { 1756 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 1757 - if err != nil { 1758 - return err 1759 - } 1743 + maj, extra, err = cr.ReadHeader() 1744 + if err != nil { 1745 + return err 1746 + } 1760 1747 1761 - t.Lang = string(sval) 1748 + if extra > 8192 { 1749 + return fmt.Errorf("t.Inputs: array too large (%d)", extra) 1762 1750 } 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") 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 + 1781 1785 } 1782 - extraI = -1 - extraI 1783 - default: 1784 - return fmt.Errorf("wrong type for int64 field: %d", maj) 1786 + 1785 1787 } 1786 - 1787 - t.Size = int64(extraI) 1788 1788 } 1789 1789 1790 1790 default: ··· 2469 2469 2470 2470 return nil 2471 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 + } 2472 3528 func (t *Pipeline) MarshalCBOR(w io.Writer) error { 2473 3529 if t == nil { 2474 3530 _, err := w.Write(cbg.CborNull) ··· 4756 5812 fieldCount-- 4757 5813 } 4758 5814 5815 + if t.Labels == nil { 5816 + fieldCount-- 5817 + } 5818 + 4759 5819 if t.Source == nil { 4760 5820 fieldCount-- 4761 5821 } ··· 4833 5893 return err 4834 5894 } 4835 5895 4836 - // t.Owner (string) (string) 4837 - if len("owner") > 1000000 { 4838 - return xerrors.Errorf("Value in field \"owner\" was too long") 4839 - } 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 + } 4840 5909 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 - } 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 + } 4847 5921 4848 - if len(t.Owner) > 1000000 { 4849 - return xerrors.Errorf("Value in field t.Owner was too long") 4850 - } 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 + } 4851 5928 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 5929 + } 4857 5930 } 4858 5931 4859 5932 // t.Source (string) (string) ··· 5051 6124 5052 6125 t.LexiconTypeID = string(sval) 5053 6126 } 5054 - // t.Owner (string) (string) 5055 - case "owner": 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 5056 6155 5057 - { 5058 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 5059 - if err != nil { 5060 - return err 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 + 5061 6165 } 5062 - 5063 - t.Owner = string(sval) 5064 6166 } 5065 6167 // t.Source (string) (string) 5066 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 31 Files []*RepoTree_TreeEntry `json:"files" cborgen:"files"` 32 32 // parent: The parent path in the tree 33 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"` 34 36 // ref: The git reference used 35 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"` 36 46 } 37 47 38 48 // RepoTree_TreeEntry is a "treeEntry" in the sh.tangled.repo.tree schema.
+3 -2
api/tangled/tangledrepo.go
··· 22 22 Description *string `json:"description,omitempty" cborgen:"description,omitempty"` 23 23 // knot: knot where the repo was created 24 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"` 25 27 // name: name of the repo 26 - Name string `json:"name" cborgen:"name"` 27 - Owner string `json:"owner" cborgen:"owner"` 28 + Name string `json:"name" cborgen:"name"` 28 29 // source: source of the repo 29 30 Source *string `json:"source,omitempty" cborgen:"source,omitempty"` 30 31 // spindle: CI runner to send jobs to and receive results from
+1 -1
appview/cache/session/store.go
··· 6 6 "fmt" 7 7 "time" 8 8 9 - "tangled.sh/tangled.sh/core/appview/cache" 9 + "tangled.org/core/appview/cache" 10 10 ) 11 11 12 12 type OAuthSession struct {
+5 -4
appview/commitverify/verify.go
··· 4 4 "log" 5 5 6 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" 7 + "tangled.org/core/appview/db" 8 + "tangled.org/core/appview/models" 9 + "tangled.org/core/crypto" 10 + "tangled.org/core/types" 10 11 ) 11 12 12 13 type verifiedCommit struct { ··· 45 46 func GetVerifiedCommits(e db.Execer, emailToDid map[string]string, ndCommits []types.NiceDiff) (VerifiedCommits, error) { 46 47 vcs := VerifiedCommits{} 47 48 48 - didPubkeyCache := make(map[string][]db.PublicKey) 49 + didPubkeyCache := make(map[string][]models.PublicKey) 49 50 50 51 for _, commit := range ndCommits { 51 52 c := commit.Commit
+4 -2
appview/config/config.go
··· 72 72 } 73 73 74 74 type Cloudflare struct { 75 - ApiToken string `env:"API_TOKEN"` 76 - ZoneId string `env:"ZONE_ID"` 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"` 77 79 } 78 80 79 81 func (cfg RedisConfig) ToURL() string {
+5 -25
appview/db/artifact.go
··· 5 5 "strings" 6 6 "time" 7 7 8 - "github.com/bluesky-social/indigo/atproto/syntax" 9 8 "github.com/go-git/go-git/v5/plumbing" 10 9 "github.com/ipfs/go-cid" 11 - "tangled.sh/tangled.sh/core/api/tangled" 10 + "tangled.org/core/appview/models" 12 11 ) 13 12 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 { 13 + func AddArtifact(e Execer, artifact models.Artifact) error { 34 14 _, err := e.Exec( 35 15 `insert or ignore into artifacts ( 36 16 did, ··· 57 37 return err 58 38 } 59 39 60 - func GetArtifact(e Execer, filters ...filter) ([]Artifact, error) { 61 - var artifacts []Artifact 40 + func GetArtifact(e Execer, filters ...filter) ([]models.Artifact, error) { 41 + var artifacts []models.Artifact 62 42 63 43 var conditions []string 64 44 var args []any ··· 94 74 defer rows.Close() 95 75 96 76 for rows.Next() { 97 - var artifact Artifact 77 + var artifact models.Artifact 98 78 var createdAt string 99 79 var tag []byte 100 80 var blobCid string
+3 -18
appview/db/collaborators.go
··· 3 3 import ( 4 4 "fmt" 5 5 "strings" 6 - "time" 7 6 8 - "github.com/bluesky-social/indigo/atproto/syntax" 7 + "tangled.org/core/appview/models" 9 8 ) 10 9 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 { 10 + func AddCollaborator(e Execer, c models.Collaborator) error { 26 11 _, err := e.Exec( 27 12 `insert into collaborators (did, rkey, subject_did, repo_at) values (?, ?, ?, ?);`, 28 13 c.Did, c.Rkey, c.SubjectDid, c.RepoAt, ··· 49 34 return err 50 35 } 51 36 52 - func CollaboratingIn(e Execer, collaborator string) ([]Repo, error) { 37 + func CollaboratingIn(e Execer, collaborator string) ([]models.Repo, error) { 53 38 rows, err := e.Query(`select repo_at from collaborators where subject_did = ?`, collaborator) 54 39 if err != nil { 55 40 return nil, err
+247 -16
appview/db/db.go
··· 466 466 primary key (did, rkey) 467 467 ); 468 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 + 469 561 create table if not exists migrations ( 470 562 id integer primary key autoincrement, 471 563 name text unique 472 564 ); 473 565 474 - -- indexes for better star query performance 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); 475 569 create index if not exists idx_stars_created on stars(created); 476 570 create index if not exists idx_stars_repo_at_created on stars(repo_at, created); 477 571 `) ··· 604 698 }) 605 699 conn.ExecContext(ctx, "pragma foreign_keys = on;") 606 700 607 - // run migrations 608 701 runMigration(conn, "add-spindle-to-repos", func(tx *sql.Tx) error { 609 702 tx.Exec(` 610 703 alter table repos add column spindle text; ··· 724 817 _, err := tx.Exec(` 725 818 alter table spindles add column needs_upgrade integer not null default 0; 726 819 `) 727 - if err != nil { 728 - return err 729 - } 730 - 731 - _, err = tx.Exec(` 732 - update spindles set needs_upgrade = 1; 733 - `) 734 820 return err 735 821 }) 736 822 ··· 868 954 return err 869 955 }) 870 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 + 871 1097 return &DB{db}, nil 872 1098 } 873 1099 ··· 932 1158 } 933 1159 } 934 1160 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) } 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 + } 942 1173 943 1174 func (f filter) Condition() string { 944 1175 rv := reflect.ValueOf(f.arg)
+29 -34
appview/db/email.go
··· 3 3 import ( 4 4 "strings" 5 5 "time" 6 - ) 7 6 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 - } 7 + "tangled.org/core/appview/models" 8 + ) 18 9 19 - func GetPrimaryEmail(e Execer, did string) (Email, error) { 10 + func GetPrimaryEmail(e Execer, did string) (models.Email, error) { 20 11 query := ` 21 12 select id, did, email, verified, is_primary, verification_code, last_sent, created 22 13 from emails 23 14 where did = ? and is_primary = true 24 15 ` 25 - var email Email 16 + var email models.Email 26 17 var createdStr string 27 18 var lastSent string 28 19 err := e.QueryRow(query, did).Scan(&email.ID, &email.Did, &email.Address, &email.Verified, &email.Primary, &email.VerificationCode, &lastSent, &createdStr) 29 20 if err != nil { 30 - return Email{}, err 21 + return models.Email{}, err 31 22 } 32 23 email.CreatedAt, err = time.Parse(time.RFC3339, createdStr) 33 24 if err != nil { 34 - return Email{}, err 25 + return models.Email{}, err 35 26 } 36 27 parsedTime, err := time.Parse(time.RFC3339, lastSent) 37 28 if err != nil { 38 - return Email{}, err 29 + return models.Email{}, err 39 30 } 40 31 email.LastSent = &parsedTime 41 32 return email, nil 42 33 } 43 34 44 - func GetEmail(e Execer, did string, em string) (Email, error) { 35 + func GetEmail(e Execer, did string, em string) (models.Email, error) { 45 36 query := ` 46 37 select id, did, email, verified, is_primary, verification_code, last_sent, created 47 38 from emails 48 39 where did = ? and email = ? 49 40 ` 50 - var email Email 41 + var email models.Email 51 42 var createdStr string 52 43 var lastSent string 53 44 err := e.QueryRow(query, did, em).Scan(&email.ID, &email.Did, &email.Address, &email.Verified, &email.Primary, &email.VerificationCode, &lastSent, &createdStr) 54 45 if err != nil { 55 - return Email{}, err 46 + return models.Email{}, err 56 47 } 57 48 email.CreatedAt, err = time.Parse(time.RFC3339, createdStr) 58 49 if err != nil { 59 - return Email{}, err 50 + return models.Email{}, err 60 51 } 61 52 parsedTime, err := time.Parse(time.RFC3339, lastSent) 62 53 if err != nil { 63 - return Email{}, err 54 + return models.Email{}, err 64 55 } 65 56 email.LastSent = &parsedTime 66 57 return email, nil ··· 80 71 return did, nil 81 72 } 82 73 83 - func GetEmailToDid(e Execer, ems []string, isVerifiedFilter bool) (map[string]string, error) { 84 - if len(ems) == 0 { 74 + func GetEmailToDid(e Execer, emails []string, isVerifiedFilter bool) (map[string]string, error) { 75 + if len(emails) == 0 { 85 76 return make(map[string]string), nil 86 77 } 87 78 ··· 90 81 verifiedFilter = 1 91 82 } 92 83 84 + assoc := make(map[string]string) 85 + 93 86 // Create placeholders for the IN clause 94 - placeholders := make([]string, len(ems)) 95 - args := make([]any, len(ems)+1) 87 + placeholders := make([]string, 0, len(emails)) 88 + args := make([]any, 1, len(emails)+1) 96 89 97 90 args[0] = verifiedFilter 98 - for i, em := range ems { 99 - placeholders[i] = "?" 100 - args[i+1] = em 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) 101 98 } 102 99 103 100 query := ` ··· 113 110 return nil, err 114 111 } 115 112 defer rows.Close() 116 - 117 - assoc := make(map[string]string) 118 113 119 114 for rows.Next() { 120 115 var email, did string ··· 187 182 return count > 0, nil 188 183 } 189 184 190 - func AddEmail(e Execer, email Email) error { 185 + func AddEmail(e Execer, email models.Email) error { 191 186 // Check if this is the first email for this DID 192 187 countQuery := ` 193 188 select count(*) ··· 254 249 return err 255 250 } 256 251 257 - func GetAllEmails(e Execer, did string) ([]Email, error) { 252 + func GetAllEmails(e Execer, did string) ([]models.Email, error) { 258 253 query := ` 259 254 select did, email, verified, is_primary, verification_code, last_sent, created 260 255 from emails ··· 266 261 } 267 262 defer rows.Close() 268 263 269 - var emails []Email 264 + var emails []models.Email 270 265 for rows.Next() { 271 - var email Email 266 + var email models.Email 272 267 var createdStr string 273 268 var lastSent string 274 269 err := rows.Scan(&email.Did, &email.Address, &email.Verified, &email.Primary, &email.VerificationCode, &lastSent, &createdStr)
+79 -50
appview/db/follow.go
··· 5 5 "log" 6 6 "strings" 7 7 "time" 8 + 9 + "tangled.org/core/appview/models" 8 10 ) 9 11 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 { 12 + func AddFollow(e Execer, follow *models.Follow) error { 18 13 query := `insert or ignore into follows (user_did, subject_did, rkey) values (?, ?, ?)` 19 14 _, err := e.Exec(query, follow.UserDid, follow.SubjectDid, follow.Rkey) 20 15 return err 21 16 } 22 17 23 18 // Get a follow record 24 - func GetFollow(e Execer, userDid, subjectDid string) (*Follow, error) { 19 + func GetFollow(e Execer, userDid, subjectDid string) (*models.Follow, error) { 25 20 query := `select user_did, subject_did, followed_at, rkey from follows where user_did = ? and subject_did = ?` 26 21 row := e.QueryRow(query, userDid, subjectDid) 27 22 28 - var follow Follow 23 + var follow models.Follow 29 24 var followedAt string 30 25 err := row.Scan(&follow.UserDid, &follow.SubjectDid, &followedAt, &follow.Rkey) 31 26 if err != nil { ··· 55 50 return err 56 51 } 57 52 58 - type FollowStats struct { 59 - Followers int64 60 - Following int64 61 - } 62 - 63 - func GetFollowerFollowingCount(e Execer, did string) (FollowStats, error) { 53 + func GetFollowerFollowingCount(e Execer, did string) (models.FollowStats, error) { 64 54 var followers, following int64 65 55 err := e.QueryRow( 66 56 `SELECT ··· 68 58 COUNT(CASE WHEN user_did = ? THEN 1 END) AS following 69 59 FROM follows;`, did, did).Scan(&followers, &following) 70 60 if err != nil { 71 - return FollowStats{}, err 61 + return models.FollowStats{}, err 72 62 } 73 - return FollowStats{ 63 + return models.FollowStats{ 74 64 Followers: followers, 75 65 Following: following, 76 66 }, nil 77 67 } 78 68 79 - func GetFollowerFollowingCounts(e Execer, dids []string) (map[string]FollowStats, error) { 69 + func GetFollowerFollowingCounts(e Execer, dids []string) (map[string]models.FollowStats, error) { 80 70 if len(dids) == 0 { 81 71 return nil, nil 82 72 } ··· 112 102 ) g on f.did = g.did`, 113 103 placeholderStr, placeholderStr) 114 104 115 - result := make(map[string]FollowStats) 105 + result := make(map[string]models.FollowStats) 116 106 117 107 rows, err := e.Query(query, args...) 118 108 if err != nil { ··· 126 116 if err := rows.Scan(&did, &followers, &following); err != nil { 127 117 return nil, err 128 118 } 129 - result[did] = FollowStats{ 119 + result[did] = models.FollowStats{ 130 120 Followers: followers, 131 121 Following: following, 132 122 } ··· 134 124 135 125 for _, did := range dids { 136 126 if _, exists := result[did]; !exists { 137 - result[did] = FollowStats{ 127 + result[did] = models.FollowStats{ 138 128 Followers: 0, 139 129 Following: 0, 140 130 } ··· 144 134 return result, nil 145 135 } 146 136 147 - func GetFollows(e Execer, limit int, filters ...filter) ([]Follow, error) { 148 - var follows []Follow 137 + func GetFollows(e Execer, limit int, filters ...filter) ([]models.Follow, error) { 138 + var follows []models.Follow 149 139 150 140 var conditions []string 151 141 var args []any ··· 177 167 return nil, err 178 168 } 179 169 for rows.Next() { 180 - var follow Follow 170 + var follow models.Follow 181 171 var followedAt string 182 172 err := rows.Scan( 183 173 &follow.UserDid, ··· 200 190 return follows, nil 201 191 } 202 192 203 - func GetFollowers(e Execer, did string) ([]Follow, error) { 193 + func GetFollowers(e Execer, did string) ([]models.Follow, error) { 204 194 return GetFollows(e, 0, FilterEq("subject_did", did)) 205 195 } 206 196 207 - func GetFollowing(e Execer, did string) ([]Follow, error) { 197 + func GetFollowing(e Execer, did string) ([]models.Follow, error) { 208 198 return GetFollows(e, 0, FilterEq("user_did", did)) 209 199 } 210 200 211 - type FollowStatus int 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 + } 212 222 213 - const ( 214 - IsNotFollowing FollowStatus = iota 215 - IsFollowing 216 - IsSelf 217 - ) 223 + if len(querySubjects) == 0 { 224 + return result, nil 225 + } 218 226 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" 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 229 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 230 257 } 231 258 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 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 239 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) 240 269 }
+35 -192
appview/db/issues.go
··· 10 10 "time" 11 11 12 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 - "tangled.sh/tangled.sh/core/api/tangled" 14 - "tangled.sh/tangled.sh/core/appview/pagination" 13 + "tangled.org/core/appview/models" 14 + "tangled.org/core/appview/pagination" 15 15 ) 16 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 { 17 + func PutIssue(tx *sql.Tx, issue *models.Issue) error { 181 18 // ensure sequence exists 182 19 _, err := tx.Exec(` 183 20 insert or ignore into repo_issue_seqs (repo_at, next_issue_id) ··· 212 49 } 213 50 } 214 51 215 - func createNewIssue(tx *sql.Tx, issue *Issue) error { 52 + func createNewIssue(tx *sql.Tx, issue *models.Issue) error { 216 53 // get next issue_id 217 54 var newIssueId int 218 55 err := tx.QueryRow(` 219 - update repo_issue_seqs 220 - set next_issue_id = next_issue_id + 1 221 - where repo_at = ? 56 + update repo_issue_seqs 57 + set next_issue_id = next_issue_id + 1 58 + where repo_at = ? 222 59 returning next_issue_id - 1 223 60 `, issue.RepoAt).Scan(&newIssueId) 224 61 if err != nil { ··· 235 72 return row.Scan(&issue.Id, &issue.IssueId) 236 73 } 237 74 238 - func updateIssue(tx *sql.Tx, issue *Issue) error { 75 + func updateIssue(tx *sql.Tx, issue *models.Issue) error { 239 76 // update existing issue 240 77 _, err := tx.Exec(` 241 78 update issues ··· 245 82 return err 246 83 } 247 84 248 - func GetIssuesPaginated(e Execer, page pagination.Page, filters ...filter) ([]Issue, error) { 249 - issueMap := make(map[string]*Issue) // at-uri -> issue 85 + func GetIssuesPaginated(e Execer, page pagination.Page, filters ...filter) ([]models.Issue, error) { 86 + issueMap := make(map[string]*models.Issue) // at-uri -> issue 250 87 251 88 var conditions []string 252 89 var args []any ··· 301 138 defer rows.Close() 302 139 303 140 for rows.Next() { 304 - var issue Issue 141 + var issue models.Issue 305 142 var createdAt string 306 143 var editedAt, deletedAt sql.Null[string] 307 144 var rowNum int64 ··· 354 191 return nil, fmt.Errorf("failed to build repo mappings: %w", err) 355 192 } 356 193 357 - repoMap := make(map[string]*Repo) 194 + repoMap := make(map[string]*models.Repo) 358 195 for i := range repos { 359 196 repoMap[string(repos[i].RepoAt())] = &repos[i] 360 197 } ··· 371 208 372 209 // collect comments 373 210 issueAts := slices.Collect(maps.Keys(issueMap)) 211 + 374 212 comments, err := GetIssueComments(e, FilterIn("issue_at", issueAts)) 375 213 if err != nil { 376 214 return nil, fmt.Errorf("failed to query comments: %w", err) 377 215 } 378 - 379 216 for i := range comments { 380 217 issueAt := comments[i].IssueAt 381 218 if issue, ok := issueMap[issueAt]; ok { ··· 383 220 } 384 221 } 385 222 386 - var issues []Issue 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 387 235 for _, i := range issueMap { 388 236 issues = append(issues, *i) 389 237 } ··· 395 243 return issues, nil 396 244 } 397 245 398 - func GetIssues(e Execer, filters ...filter) ([]Issue, error) { 246 + func GetIssues(e Execer, filters ...filter) ([]models.Issue, error) { 399 247 return GetIssuesPaginated(e, pagination.FirstPage(), filters...) 400 248 } 401 249 402 - func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, error) { 250 + func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*models.Issue, error) { 403 251 query := `select id, owner_did, rkey, created, title, body, open from issues where repo_at = ? and issue_id = ?` 404 252 row := e.QueryRow(query, repoAt, issueId) 405 253 406 - var issue Issue 254 + var issue models.Issue 407 255 var createdAt string 408 256 err := row.Scan(&issue.Id, &issue.Did, &issue.Rkey, &createdAt, &issue.Title, &issue.Body, &issue.Open) 409 257 if err != nil { ··· 419 267 return &issue, nil 420 268 } 421 269 422 - func AddIssueComment(e Execer, c IssueComment) (int64, error) { 270 + func AddIssueComment(e Execer, c models.IssueComment) (int64, error) { 423 271 result, err := e.Exec( 424 272 `insert into issue_comments ( 425 273 did, ··· 481 329 return err 482 330 } 483 331 484 - func GetIssueComments(e Execer, filters ...filter) ([]IssueComment, error) { 485 - var comments []IssueComment 332 + func GetIssueComments(e Execer, filters ...filter) ([]models.IssueComment, error) { 333 + var comments []models.IssueComment 486 334 487 335 var conditions []string 488 336 var args []any ··· 518 366 } 519 367 520 368 for rows.Next() { 521 - var comment IssueComment 369 + var comment models.IssueComment 522 370 var created string 523 371 var rkey, edited, deleted, replyTo sql.Null[string] 524 372 err := rows.Scan( ··· 625 473 return err 626 474 } 627 475 628 - type IssueCount struct { 629 - Open int 630 - Closed int 631 - } 632 - 633 - func GetIssueCount(e Execer, repoAt syntax.ATURI) (IssueCount, error) { 476 + func GetIssueCount(e Execer, repoAt syntax.ATURI) (models.IssueCount, error) { 634 477 row := e.QueryRow(` 635 478 select 636 479 count(case when open = 1 then 1 end) as open_count, ··· 640 483 repoAt, 641 484 ) 642 485 643 - var count IssueCount 486 + var count models.IssueCount 644 487 if err := row.Scan(&count.Open, &count.Closed); err != nil { 645 - return IssueCount{0, 0}, err 488 + return models.IssueCount{}, err 646 489 } 647 490 648 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 1 package db 2 2 3 3 import ( 4 + "database/sql" 4 5 "fmt" 5 6 "strings" 6 7 7 8 "github.com/bluesky-social/indigo/atproto/syntax" 9 + "tangled.org/core/appview/models" 8 10 ) 9 11 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) { 12 + func GetRepoLanguages(e Execer, filters ...filter) ([]models.RepoLanguage, error) { 20 13 var conditions []string 21 14 var args []any 22 15 for _, filter := range filters { ··· 39 32 return nil, fmt.Errorf("failed to execute query: %w ", err) 40 33 } 41 34 42 - var langs []RepoLanguage 35 + var langs []models.RepoLanguage 43 36 for rows.Next() { 44 - var rl RepoLanguage 37 + var rl models.RepoLanguage 45 38 var isDefaultRef int 46 39 47 40 err := rows.Scan( ··· 69 62 return langs, nil 70 63 } 71 64 72 - func InsertRepoLanguages(e Execer, langs []RepoLanguage) error { 65 + func InsertRepoLanguages(e Execer, langs []models.RepoLanguage) error { 73 66 stmt, err := e.Prepare( 74 67 "insert or replace into repo_languages (repo_at, ref, is_default_ref, language, bytes) values (?, ?, ?, ?, ?)", 75 68 ) ··· 91 84 92 85 return nil 93 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 6 "strings" 7 7 "time" 8 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" 9 + "tangled.org/core/appview/models" 13 10 ) 14 11 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 12 + func GetPipelines(e Execer, filters ...filter) ([]models.Pipeline, error) { 13 + var pipelines []models.Pipeline 136 14 137 15 var conditions []string 138 16 var args []any ··· 156 34 defer rows.Close() 157 35 158 36 for rows.Next() { 159 - var pipeline Pipeline 37 + var pipeline models.Pipeline 160 38 var createdAt string 161 39 err = rows.Scan( 162 40 &pipeline.Id, ··· 185 63 return pipelines, nil 186 64 } 187 65 188 - func AddPipeline(e Execer, pipeline Pipeline) error { 66 + func AddPipeline(e Execer, pipeline models.Pipeline) error { 189 67 args := []any{ 190 68 pipeline.Rkey, 191 69 pipeline.Knot, ··· 216 94 return err 217 95 } 218 96 219 - func AddTrigger(e Execer, trigger Trigger) (int64, error) { 97 + func AddTrigger(e Execer, trigger models.Trigger) (int64, error) { 220 98 args := []any{ 221 99 trigger.Kind, 222 100 trigger.PushRef, ··· 252 130 return res.LastInsertId() 253 131 } 254 132 255 - func AddPipelineStatus(e Execer, status PipelineStatus) error { 133 + func AddPipelineStatus(e Execer, status models.PipelineStatus) error { 256 134 args := []any{ 257 135 status.Spindle, 258 136 status.Rkey, ··· 290 168 291 169 // this is a mega query, but the most useful one: 292 170 // get N pipelines, for each one get the latest status of its N workflows 293 - func GetPipelineStatuses(e Execer, filters ...filter) ([]Pipeline, error) { 171 + func GetPipelineStatuses(e Execer, filters ...filter) ([]models.Pipeline, error) { 294 172 var conditions []string 295 173 var args []any 296 174 for _, filter := range filters { ··· 335 213 } 336 214 defer rows.Close() 337 215 338 - pipelines := make(map[string]Pipeline) 216 + pipelines := make(map[string]models.Pipeline) 339 217 for rows.Next() { 340 - var p Pipeline 341 - var t Trigger 218 + var p models.Pipeline 219 + var t models.Trigger 342 220 var created string 343 221 344 222 err := rows.Scan( ··· 370 248 371 249 t.Id = p.TriggerId 372 250 p.Trigger = &t 373 - p.Statuses = make(map[string]WorkflowStatus) 251 + p.Statuses = make(map[string]models.WorkflowStatus) 374 252 375 253 k := fmt.Sprintf("%s/%s", p.Knot, p.Rkey) 376 254 pipelines[k] = p ··· 409 287 defer rows.Close() 410 288 411 289 for rows.Next() { 412 - var ps PipelineStatus 290 + var ps models.PipelineStatus 413 291 var created string 414 292 415 293 err := rows.Scan( ··· 442 320 } 443 321 statuses, _ := pipeline.Statuses[ps.Workflow] 444 322 if !ok { 445 - pipeline.Statuses[ps.Workflow] = WorkflowStatus{} 323 + pipeline.Statuses[ps.Workflow] = models.WorkflowStatus{} 446 324 } 447 325 448 326 // append ··· 453 331 pipelines[key] = pipeline 454 332 } 455 333 456 - var all []Pipeline 334 + var all []models.Pipeline 457 335 for _, p := range pipelines { 458 336 for _, s := range p.Statuses { 459 - slices.SortFunc(s.Data, func(a, b PipelineStatus) int { 337 + slices.SortFunc(s.Data, func(a, b models.PipelineStatus) int { 460 338 if a.Created.After(b.Created) { 461 339 return 1 462 340 } ··· 476 354 } 477 355 478 356 // sort pipelines by date 479 - slices.SortFunc(all, func(a, b Pipeline) int { 357 + slices.SortFunc(all, func(a, b models.Pipeline) int { 480 358 if a.Created.After(b.Created) { 481 359 return -1 482 360 }
+25 -194
appview/db/profile.go
··· 10 10 "time" 11 11 12 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 - "tangled.sh/tangled.sh/core/api/tangled" 13 + "tangled.org/core/appview/models" 14 14 ) 15 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 16 const TimeframeMonths = 7 107 17 108 - func MakeProfileTimeline(e Execer, forDid string) (*ProfileTimeline, error) { 109 - timeline := ProfileTimeline{ 110 - ByMonth: make([]ByMonth, TimeframeMonths), 18 + func MakeProfileTimeline(e Execer, forDid string) (*models.ProfileTimeline, error) { 19 + timeline := models.ProfileTimeline{ 20 + ByMonth: make([]models.ByMonth, TimeframeMonths), 111 21 } 112 22 currentMonth := time.Now().Month() 113 23 timeframe := fmt.Sprintf("-%d months", TimeframeMonths) ··· 162 72 163 73 for _, repo := range repos { 164 74 // TODO: get this in the original query; requires COALESCE because nullable 165 - var sourceRepo *Repo 75 + var sourceRepo *models.Repo 166 76 if repo.Source != "" { 167 77 sourceRepo, err = GetRepoByAtUri(e, repo.Source) 168 78 if err != nil { ··· 180 90 idx := currentMonth - repoMonth 181 91 182 92 items := &timeline.ByMonth[idx].RepoEvents 183 - *items = append(*items, RepoEvent{ 93 + *items = append(*items, models.RepoEvent{ 184 94 Repo: &repo, 185 95 Source: sourceRepo, 186 96 }) ··· 189 99 return &timeline, nil 190 100 } 191 101 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 { 102 + func UpsertProfile(tx *sql.Tx, profile *models.Profile) error { 272 103 defer tx.Rollback() 273 104 274 105 // update links ··· 366 197 return tx.Commit() 367 198 } 368 199 369 - func GetProfiles(e Execer, filters ...filter) (map[string]*Profile, error) { 200 + func GetProfiles(e Execer, filters ...filter) (map[string]*models.Profile, error) { 370 201 var conditions []string 371 202 var args []any 372 203 for _, filter := range filters { ··· 396 227 return nil, err 397 228 } 398 229 399 - profileMap := make(map[string]*Profile) 230 + profileMap := make(map[string]*models.Profile) 400 231 for rows.Next() { 401 - var profile Profile 232 + var profile models.Profile 402 233 var includeBluesky int 403 234 404 235 err = rows.Scan(&profile.ID, &profile.Did, &profile.Description, &includeBluesky, &profile.Location) ··· 469 300 return profileMap, nil 470 301 } 471 302 472 - func GetProfile(e Execer, did string) (*Profile, error) { 473 - var profile Profile 303 + func GetProfile(e Execer, did string) (*models.Profile, error) { 304 + var profile models.Profile 474 305 profile.Did = did 475 306 476 307 includeBluesky := 0 ··· 479 310 did, 480 311 ).Scan(&profile.Description, &includeBluesky, &profile.Location) 481 312 if err == sql.ErrNoRows { 482 - profile := Profile{} 313 + profile := models.Profile{} 483 314 profile.Did = did 484 315 return &profile, nil 485 316 } ··· 539 370 return &profile, nil 540 371 } 541 372 542 - func GetVanityStat(e Execer, did string, stat VanityStatKind) (uint64, error) { 373 + func GetVanityStat(e Execer, did string, stat models.VanityStatKind) (uint64, error) { 543 374 query := "" 544 375 var args []any 545 376 switch stat { 546 - case VanityStatMergedPRCount: 377 + case models.VanityStatMergedPRCount: 547 378 query = `select count(id) from pulls where owner_did = ? and state = ?` 548 - args = append(args, did, PullMerged) 549 - case VanityStatClosedPRCount: 379 + args = append(args, did, models.PullMerged) 380 + case models.VanityStatClosedPRCount: 550 381 query = `select count(id) from pulls where owner_did = ? and state = ?` 551 - args = append(args, did, PullClosed) 552 - case VanityStatOpenPRCount: 382 + args = append(args, did, models.PullClosed) 383 + case models.VanityStatOpenPRCount: 553 384 query = `select count(id) from pulls where owner_did = ? and state = ?` 554 - args = append(args, did, PullOpen) 555 - case VanityStatOpenIssueCount: 385 + args = append(args, did, models.PullOpen) 386 + case models.VanityStatOpenIssueCount: 556 387 query = `select count(id) from issues where did = ? and open = 1` 557 388 args = append(args, did) 558 - case VanityStatClosedIssueCount: 389 + case models.VanityStatClosedIssueCount: 559 390 query = `select count(id) from issues where did = ? and open = 0` 560 391 args = append(args, did) 561 - case VanityStatRepositoryCount: 392 + case models.VanityStatRepositoryCount: 562 393 query = `select count(id) from repos where did = ?` 563 394 args = append(args, did) 564 395 } ··· 572 403 return result, nil 573 404 } 574 405 575 - func ValidateProfile(e Execer, profile *Profile) error { 406 + func ValidateProfile(e Execer, profile *models.Profile) error { 576 407 // ensure description is not too long 577 408 if len(profile.Description) > 256 { 578 409 return fmt.Errorf("Entered bio is too long.") ··· 620 451 return nil 621 452 } 622 453 623 - func validateLinks(profile *Profile) error { 454 + func validateLinks(profile *models.Profile) error { 624 455 for i, link := range profile.Links { 625 456 if link == "" { 626 457 continue
+7 -26
appview/db/pubkeys.go
··· 1 1 package db 2 2 3 3 import ( 4 - "encoding/json" 4 + "tangled.org/core/appview/models" 5 5 "time" 6 6 ) 7 7 ··· 29 29 return err 30 30 } 31 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 32 + func GetAllPublicKeys(e Execer) ([]models.PublicKey, error) { 33 + var keys []models.PublicKey 53 34 54 35 rows, err := e.Query(`select key, name, did, rkey, created from public_keys`) 55 36 if err != nil { ··· 58 39 defer rows.Close() 59 40 60 41 for rows.Next() { 61 - var publicKey PublicKey 42 + var publicKey models.PublicKey 62 43 var createdAt string 63 44 if err := rows.Scan(&publicKey.Key, &publicKey.Name, &publicKey.Did, &publicKey.Rkey, &createdAt); err != nil { 64 45 return nil, err ··· 75 56 return keys, nil 76 57 } 77 58 78 - func GetPublicKeysForDid(e Execer, did string) ([]PublicKey, error) { 79 - var keys []PublicKey 59 + func GetPublicKeysForDid(e Execer, did string) ([]models.PublicKey, error) { 60 + var keys []models.PublicKey 80 61 81 62 rows, err := e.Query(`select did, key, name, rkey, created from public_keys where did = ?`, did) 82 63 if err != nil { ··· 85 66 defer rows.Close() 86 67 87 68 for rows.Next() { 88 - var publicKey PublicKey 69 + var publicKey models.PublicKey 89 70 var createdAt string 90 71 if err := rows.Scan(&publicKey.Did, &publicKey.Key, &publicKey.Name, &publicKey.Rkey, &createdAt); err != nil { 91 72 return nil, err
+193 -572
appview/db/pulls.go
··· 1 1 package db 2 2 3 3 import ( 4 + "cmp" 4 5 "database/sql" 6 + "errors" 5 7 "fmt" 6 - "log" 8 + "maps" 7 9 "slices" 8 10 "sort" 9 11 "strings" 10 12 "time" 11 13 12 14 "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 15 + "tangled.org/core/appview/models" 25 16 ) 26 17 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 { 18 + func NewPull(tx *sql.Tx, pull *models.Pull) error { 227 19 _, err := tx.Exec(` 228 20 insert or ignore into repo_pull_seqs (repo_at, next_pull_id) 229 21 values (?, 1) ··· 244 36 } 245 37 246 38 pull.PullId = nextId 247 - pull.State = PullOpen 39 + pull.State = models.PullOpen 248 40 249 41 var sourceBranch, sourceRepoAt *string 250 42 if pull.PullSource != nil { ··· 266 58 parentChangeId = &pull.ParentChangeId 267 59 } 268 60 269 - _, err = tx.Exec( 61 + result, err := tx.Exec( 270 62 ` 271 63 insert into pulls ( 272 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 ··· 290 82 return err 291 83 } 292 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 + 293 92 _, 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) 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) 297 96 return err 298 97 } 299 98 ··· 311 110 return pullId - 1, err 312 111 } 313 112 314 - func GetPullsWithLimit(e Execer, limit int, filters ...filter) ([]*Pull, error) { 315 - pulls := make(map[int]*Pull) 113 + func GetPullsWithLimit(e Execer, limit int, filters ...filter) ([]*models.Pull, error) { 114 + pulls := make(map[syntax.ATURI]*models.Pull) 316 115 317 116 var conditions []string 318 117 var args []any ··· 332 131 333 132 query := fmt.Sprintf(` 334 133 select 134 + id, 335 135 owner_did, 336 136 repo_at, 337 137 pull_id, ··· 361 161 defer rows.Close() 362 162 363 163 for rows.Next() { 364 - var pull Pull 164 + var pull models.Pull 365 165 var createdAt string 366 166 var sourceBranch, sourceRepoAt, stackId, changeId, parentChangeId sql.NullString 367 167 err := rows.Scan( 168 + &pull.ID, 368 169 &pull.OwnerDid, 369 170 &pull.RepoAt, 370 171 &pull.PullId, ··· 391 192 pull.Created = createdTime 392 193 393 194 if sourceBranch.Valid { 394 - pull.PullSource = &PullSource{ 195 + pull.PullSource = &models.PullSource{ 395 196 Branch: sourceBranch.String, 396 197 } 397 198 if sourceRepoAt.Valid { ··· 413 214 pull.ParentChangeId = parentChangeId.String 414 215 } 415 216 416 - pulls[pull.PullId] = &pull 217 + pulls[pull.PullAt()] = &pull 417 218 } 418 219 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 - } 220 + var pullAts []syntax.ATURI 436 221 for _, p := range pulls { 437 - args[idx] = p.PullId 438 - idx += 1 222 + pullAts = append(pullAts, p.PullAt()) 439 223 } 440 - submissionsRows, err := e.Query(submissionsQuery, args...) 224 + submissionsMap, err := GetPullSubmissions(e, FilterIn("pull_at", pullAts)) 441 225 if err != nil { 442 - return nil, err 226 + return nil, fmt.Errorf("failed to get submissions: %w", err) 443 227 } 444 - defer submissionsRows.Close() 445 228 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 229 + for pullAt, submissions := range submissionsMap { 230 + if p, ok := pulls[pullAt]; ok { 231 + p.Submissions = submissions 460 232 } 461 - 462 - createdTime, err := time.Parse(time.RFC3339, createdAt) 463 - if err != nil { 464 - return nil, err 465 - } 466 - s.Created = createdTime 233 + } 467 234 468 - if sourceRev.Valid { 469 - s.SourceRev = sourceRev.String 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 470 243 } 244 + } 471 245 472 - if p, ok := pulls[s.PullId]; ok { 473 - p.Submissions = make([]*PullSubmission, s.RoundNumber+1) 474 - p.Submissions[s.RoundNumber] = &s 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) 475 251 } 476 252 } 477 - if err := rows.Err(); err != nil { 478 - return nil, err 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) 479 256 } 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{} 257 + sourceRepoMap := make(map[syntax.ATURI]*models.Repo) 258 + for _, r := range sourceRepos { 259 + sourceRepoMap[r.RepoAt()] = &r 260 + } 495 261 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 262 + if p.PullSource != nil && p.PullSource.RepoAt != nil { 263 + if sourceRepo, ok := sourceRepoMap[*p.PullSource.RepoAt]; ok { 264 + p.PullSource.Repo = sourceRepo 265 + } 512 266 } 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 267 } 520 268 521 - orderedByPullId := []*Pull{} 269 + orderedByPullId := []*models.Pull{} 522 270 for _, p := range pulls { 523 271 orderedByPullId = append(orderedByPullId, p) 524 272 } ··· 529 277 return orderedByPullId, nil 530 278 } 531 279 532 - func GetPulls(e Execer, filters ...filter) ([]*Pull, error) { 280 + func GetPulls(e Execer, filters ...filter) ([]*models.Pull, error) { 533 281 return GetPullsWithLimit(e, 0, filters...) 534 282 } 535 283 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 - ) 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)) 579 286 if err != nil { 580 287 return nil, err 581 288 } 582 - 583 - createdTime, err := time.Parse(time.RFC3339, createdAt) 584 - if err != nil { 585 - return nil, err 289 + if pulls == nil { 290 + return nil, sql.ErrNoRows 586 291 } 587 - pull.Created = createdTime 588 292 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 - } 293 + return pulls[0], nil 294 + } 602 295 603 - if stackId.Valid { 604 - pull.StackId = stackId.String 605 - } 606 - if changeId.Valid { 607 - pull.ChangeId = changeId.String 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()...) 608 303 } 609 - if parentChangeId.Valid { 610 - pull.ParentChangeId = parentChangeId.String 304 + 305 + whereClause := "" 306 + if conditions != nil { 307 + whereClause = " where " + strings.Join(conditions, " and ") 611 308 } 612 309 613 - submissionsQuery := ` 310 + query := fmt.Sprintf(` 614 311 select 615 - id, pull_id, repo_at, round_number, patch, created, source_rev 312 + id, 313 + pull_at, 314 + round_number, 315 + patch, 316 + created, 317 + source_rev 616 318 from 617 319 pull_submissions 618 - where 619 - repo_at = ? and pull_id = ? 620 - ` 621 - submissionsRows, err := e.Query(submissionsQuery, repoAt, pullId) 320 + %s 321 + order by 322 + round_number asc 323 + `, whereClause) 324 + 325 + rows, err := e.Query(query, args...) 622 326 if err != nil { 623 327 return nil, err 624 328 } 625 - defer submissionsRows.Close() 329 + defer rows.Close() 626 330 627 - submissionsMap := make(map[int]*PullSubmission) 331 + submissionMap := make(map[int]*models.PullSubmission) 628 332 629 - for submissionsRows.Next() { 630 - var submission PullSubmission 631 - var submissionCreatedStr string 632 - var submissionSourceRev sql.NullString 633 - err := submissionsRows.Scan( 333 + for rows.Next() { 334 + var submission models.PullSubmission 335 + var createdAt string 336 + var sourceRev sql.NullString 337 + err := rows.Scan( 634 338 &submission.ID, 635 - &submission.PullId, 636 - &submission.RepoAt, 339 + &submission.PullAt, 637 340 &submission.RoundNumber, 638 341 &submission.Patch, 639 - &submissionCreatedStr, 640 - &submissionSourceRev, 342 + &createdAt, 343 + &sourceRev, 641 344 ) 642 345 if err != nil { 643 346 return nil, err 644 347 } 645 348 646 - submissionCreatedTime, err := time.Parse(time.RFC3339, submissionCreatedStr) 349 + createdTime, err := time.Parse(time.RFC3339, createdAt) 647 350 if err != nil { 648 351 return nil, err 649 352 } 650 - submission.Created = submissionCreatedTime 353 + submission.Created = createdTime 651 354 652 - if submissionSourceRev.Valid { 653 - submission.SourceRev = submissionSourceRev.String 355 + if sourceRev.Valid { 356 + submission.SourceRev = sourceRev.String 654 357 } 655 358 656 - submissionsMap[submission.ID] = &submission 359 + submissionMap[submission.ID] = &submission 657 360 } 658 - if err = submissionsRows.Close(); err != nil { 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 { 659 370 return nil, err 660 371 } 661 - if len(submissionsMap) == 0 { 662 - return &pull, nil 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 + }) 663 389 } 664 390 391 + return m, nil 392 + } 393 + 394 + func GetPullComments(e Execer, filters ...filter) ([]models.PullComment, error) { 395 + var conditions []string 665 396 var args []any 666 - for k := range submissionsMap { 667 - args = append(args, k) 397 + for _, filter := range filters { 398 + conditions = append(conditions, filter.Condition()) 399 + args = append(args, filter.Arg()...) 668 400 } 669 - inClause := strings.TrimSuffix(strings.Repeat("?, ", len(submissionsMap)), ", ") 670 - commentsQuery := fmt.Sprintf(` 401 + 402 + whereClause := "" 403 + if conditions != nil { 404 + whereClause = " where " + strings.Join(conditions, " and ") 405 + } 406 + 407 + query := fmt.Sprintf(` 671 408 select 672 409 id, 673 410 pull_id, ··· 679 416 created 680 417 from 681 418 pull_comments 682 - where 683 - submission_id IN (%s) 419 + %s 684 420 order by 685 421 created asc 686 - `, inClause) 687 - commentsRows, err := e.Query(commentsQuery, args...) 422 + `, whereClause) 423 + 424 + rows, err := e.Query(query, args...) 688 425 if err != nil { 689 426 return nil, err 690 427 } 691 - defer commentsRows.Close() 428 + defer rows.Close() 692 429 693 - for commentsRows.Next() { 694 - var comment PullComment 695 - var commentCreatedStr string 696 - err := commentsRows.Scan( 430 + var comments []models.PullComment 431 + for rows.Next() { 432 + var comment models.PullComment 433 + var createdAt string 434 + err := rows.Scan( 697 435 &comment.ID, 698 436 &comment.PullId, 699 437 &comment.SubmissionId, ··· 701 439 &comment.OwnerDid, 702 440 &comment.CommentAt, 703 441 &comment.Body, 704 - &commentCreatedStr, 442 + &createdAt, 705 443 ) 706 444 if err != nil { 707 445 return nil, err 708 446 } 709 447 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) 448 + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 449 + comment.Created = t 719 450 } 720 451 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 - } 452 + comments = append(comments, comment) 736 453 } 737 454 738 - pull.Submissions = make([]*PullSubmission, len(submissionsMap)) 739 - for _, submission := range submissionsMap { 740 - pull.Submissions[submission.RoundNumber] = submission 455 + if err := rows.Err(); err != nil { 456 + return nil, err 741 457 } 742 458 743 - return &pull, nil 459 + return comments, nil 744 460 } 745 461 746 462 // timeframe here is directly passed into the sql query filter, and any 747 463 // 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 464 + func GetPullsByOwnerDid(e Execer, did, timeframe string) ([]models.Pull, error) { 465 + var pulls []models.Pull 750 466 751 467 rows, err := e.Query(` 752 468 select ··· 775 491 defer rows.Close() 776 492 777 493 for rows.Next() { 778 - var pull Pull 779 - var repo Repo 494 + var pull models.Pull 495 + var repo models.Repo 780 496 var pullCreatedAt, repoCreatedAt string 781 497 err := rows.Scan( 782 498 &pull.OwnerDid, ··· 819 535 return pulls, nil 820 536 } 821 537 822 - func NewPullComment(e Execer, comment *PullComment) (int64, error) { 538 + func NewPullComment(e Execer, comment *models.PullComment) (int64, error) { 823 539 query := `insert into pull_comments (owner_did, repo_at, submission_id, comment_at, pull_id, body) values (?, ?, ?, ?, ?, ?)` 824 540 res, err := e.Exec( 825 541 query, ··· 842 558 return i, nil 843 559 } 844 560 845 - func SetPullState(e Execer, repoAt syntax.ATURI, pullId int, pullState PullState) error { 561 + func SetPullState(e Execer, repoAt syntax.ATURI, pullId int, pullState models.PullState) error { 846 562 _, err := e.Exec( 847 563 `update pulls set state = ? where repo_at = ? and pull_id = ? and (state <> ? or state <> ?)`, 848 564 pullState, 849 565 repoAt, 850 566 pullId, 851 - PullDeleted, // only update state of non-deleted pulls 852 - PullMerged, // only update state of non-merged pulls 567 + models.PullDeleted, // only update state of non-deleted pulls 568 + models.PullMerged, // only update state of non-merged pulls 853 569 ) 854 570 return err 855 571 } 856 572 857 573 func ClosePull(e Execer, repoAt syntax.ATURI, pullId int) error { 858 - err := SetPullState(e, repoAt, pullId, PullClosed) 574 + err := SetPullState(e, repoAt, pullId, models.PullClosed) 859 575 return err 860 576 } 861 577 862 578 func ReopenPull(e Execer, repoAt syntax.ATURI, pullId int) error { 863 - err := SetPullState(e, repoAt, pullId, PullOpen) 579 + err := SetPullState(e, repoAt, pullId, models.PullOpen) 864 580 return err 865 581 } 866 582 867 583 func MergePull(e Execer, repoAt syntax.ATURI, pullId int) error { 868 - err := SetPullState(e, repoAt, pullId, PullMerged) 584 + err := SetPullState(e, repoAt, pullId, models.PullMerged) 869 585 return err 870 586 } 871 587 872 588 func DeletePull(e Execer, repoAt syntax.ATURI, pullId int) error { 873 - err := SetPullState(e, repoAt, pullId, PullDeleted) 589 + err := SetPullState(e, repoAt, pullId, models.PullDeleted) 874 590 return err 875 591 } 876 592 877 - func ResubmitPull(e Execer, pull *Pull, newPatch, sourceRev string) error { 593 + func ResubmitPull(e Execer, pull *models.Pull, newPatch, sourceRev string) error { 878 594 newRoundNumber := len(pull.Submissions) 879 595 _, 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) 596 + insert into pull_submissions (pull_at, round_number, patch, source_rev) 597 + values (?, ?, ?, ?) 598 + `, pull.PullAt(), newRoundNumber, newPatch, sourceRev) 883 599 884 600 return err 885 601 } ··· 931 647 return err 932 648 } 933 649 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) { 650 + func GetPullCount(e Execer, repoAt syntax.ATURI) (models.PullCount, error) { 942 651 row := e.QueryRow(` 943 652 select 944 653 count(case when state = ? then 1 end) as open_count, ··· 947 656 count(case when state = ? then 1 end) as deleted_count 948 657 from pulls 949 658 where repo_at = ?`, 950 - PullOpen, 951 - PullMerged, 952 - PullClosed, 953 - PullDeleted, 659 + models.PullOpen, 660 + models.PullMerged, 661 + models.PullClosed, 662 + models.PullDeleted, 954 663 repoAt, 955 664 ) 956 665 957 - var count PullCount 666 + var count models.PullCount 958 667 if err := row.Scan(&count.Open, &count.Merged, &count.Closed, &count.Deleted); err != nil { 959 - return PullCount{0, 0, 0, 0}, err 668 + return models.PullCount{Open: 0, Merged: 0, Closed: 0, Deleted: 0}, err 960 669 } 961 670 962 671 return count, nil 963 672 } 964 - 965 - type Stack []*Pull 966 673 967 674 // change-id parent-change-id 968 675 // ··· 972 679 // 1 x <------' nil (BOT) 973 680 // 974 681 // `w` is parent of none, so it is the top of the stack 975 - func GetStack(e Execer, stackId string) (Stack, error) { 682 + func GetStack(e Execer, stackId string) (models.Stack, error) { 976 683 unorderedPulls, err := GetPulls( 977 684 e, 978 685 FilterEq("stack_id", stackId), 979 - FilterNotEq("state", PullDeleted), 686 + FilterNotEq("state", models.PullDeleted), 980 687 ) 981 688 if err != nil { 982 689 return nil, err 983 690 } 984 691 // map of parent-change-id to pull 985 - changeIdMap := make(map[string]*Pull, len(unorderedPulls)) 986 - parentMap := make(map[string]*Pull, len(unorderedPulls)) 692 + changeIdMap := make(map[string]*models.Pull, len(unorderedPulls)) 693 + parentMap := make(map[string]*models.Pull, len(unorderedPulls)) 987 694 for _, p := range unorderedPulls { 988 695 changeIdMap[p.ChangeId] = p 989 696 if p.ParentChangeId != "" { ··· 992 699 } 993 700 994 701 // the top of the stack is the pull that is not a parent of any pull 995 - var topPull *Pull 702 + var topPull *models.Pull 996 703 for _, maybeTop := range unorderedPulls { 997 704 if _, ok := parentMap[maybeTop.ChangeId]; !ok { 998 705 topPull = maybeTop ··· 1000 707 } 1001 708 } 1002 709 1003 - pulls := []*Pull{} 710 + pulls := []*models.Pull{} 1004 711 for { 1005 712 pulls = append(pulls, topPull) 1006 713 if topPull.ParentChangeId != "" { ··· 1017 724 return pulls, nil 1018 725 } 1019 726 1020 - func GetAbandonedPulls(e Execer, stackId string) ([]*Pull, error) { 727 + func GetAbandonedPulls(e Execer, stackId string) ([]*models.Pull, error) { 1021 728 pulls, err := GetPulls( 1022 729 e, 1023 730 FilterEq("stack_id", stackId), 1024 - FilterEq("state", PullDeleted), 731 + FilterEq("state", models.PullDeleted), 1025 732 ) 1026 733 if err != nil { 1027 734 return nil, err ··· 1029 736 1030 737 return pulls, nil 1031 738 } 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 - }
+7 -16
appview/db/punchcard.go
··· 5 5 "fmt" 6 6 "strings" 7 7 "time" 8 + 9 + "tangled.org/core/appview/models" 8 10 ) 9 11 10 - type Punch struct { 11 - Did string 12 - Date time.Time 13 - Count int 14 - } 15 - 16 12 // this adds to the existing count 17 - func AddPunch(e Execer, punch Punch) error { 13 + func AddPunch(e Execer, punch models.Punch) error { 18 14 _, err := e.Exec(` 19 15 insert into punchcard (did, date, count) 20 16 values (?, ?, ?) ··· 24 20 return err 25 21 } 26 22 27 - type Punchcard struct { 28 - Total int 29 - Punches []Punch 30 - } 31 - 32 - func MakePunchcard(e Execer, filters ...filter) (*Punchcard, error) { 33 - punchcard := &Punchcard{} 23 + func MakePunchcard(e Execer, filters ...filter) (*models.Punchcard, error) { 24 + punchcard := &models.Punchcard{} 34 25 now := time.Now() 35 26 startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC) 36 27 endOfYear := time.Date(now.Year(), 12, 31, 0, 0, 0, 0, time.UTC) 37 28 for d := startOfYear; d.Before(endOfYear) || d.Equal(endOfYear); d = d.AddDate(0, 0, 1) { 38 - punchcard.Punches = append(punchcard.Punches, Punch{ 29 + punchcard.Punches = append(punchcard.Punches, models.Punch{ 39 30 Date: d, 40 31 Count: 0, 41 32 }) ··· 68 59 defer rows.Close() 69 60 70 61 for rows.Next() { 71 - var punch Punch 62 + var punch models.Punch 72 63 var date string 73 64 var count sql.NullInt64 74 65 if err := rows.Scan(&date, &count); err != nil {
+14 -63
appview/db/reaction.go
··· 5 5 "time" 6 6 7 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 = "👀" 8 + "tangled.org/core/appview/models" 21 9 ) 22 10 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 { 11 + func AddReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind models.ReactionKind, rkey string) error { 61 12 query := `insert or ignore into reactions (reacted_by_did, thread_at, kind, rkey) values (?, ?, ?, ?)` 62 13 _, err := e.Exec(query, reactedByDid, threadAt, kind, rkey) 63 14 return err 64 15 } 65 16 66 17 // Get a reaction record 67 - func GetReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind ReactionKind) (*Reaction, error) { 18 + func GetReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind models.ReactionKind) (*models.Reaction, error) { 68 19 query := ` 69 20 select reacted_by_did, thread_at, created, rkey 70 21 from reactions 71 22 where reacted_by_did = ? and thread_at = ? and kind = ?` 72 23 row := e.QueryRow(query, reactedByDid, threadAt, kind) 73 24 74 - var reaction Reaction 25 + var reaction models.Reaction 75 26 var created string 76 27 err := row.Scan(&reaction.ReactedByDid, &reaction.ThreadAt, &created, &reaction.Rkey) 77 28 if err != nil { ··· 90 41 } 91 42 92 43 // Remove a reaction 93 - func DeleteReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind ReactionKind) error { 44 + func DeleteReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind models.ReactionKind) error { 94 45 _, err := e.Exec(`delete from reactions where reacted_by_did = ? and thread_at = ? and kind = ?`, reactedByDid, threadAt, kind) 95 46 return err 96 47 } ··· 101 52 return err 102 53 } 103 54 104 - func GetReactionCount(e Execer, threadAt syntax.ATURI, kind ReactionKind) (int, error) { 55 + func GetReactionCount(e Execer, threadAt syntax.ATURI, kind models.ReactionKind) (int, error) { 105 56 count := 0 106 57 err := e.QueryRow( 107 58 `select count(reacted_by_did) from reactions where thread_at = ? and kind = ?`, threadAt, kind).Scan(&count) ··· 111 62 return count, nil 112 63 } 113 64 114 - func GetReactionCountMap(e Execer, threadAt syntax.ATURI) (map[ReactionKind]int, error) { 115 - countMap := map[ReactionKind]int{} 116 - for _, kind := range OrderedReactionKinds { 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 { 117 68 count, err := GetReactionCount(e, threadAt, kind) 118 69 if err != nil { 119 - return map[ReactionKind]int{}, nil 70 + return map[models.ReactionKind]int{}, nil 120 71 } 121 72 countMap[kind] = count 122 73 } 123 74 return countMap, nil 124 75 } 125 76 126 - func GetReactionStatus(e Execer, userDid string, threadAt syntax.ATURI, kind ReactionKind) bool { 77 + func GetReactionStatus(e Execer, userDid string, threadAt syntax.ATURI, kind models.ReactionKind) bool { 127 78 if _, err := GetReaction(e, userDid, threadAt, kind); err != nil { 128 79 return false 129 80 } else { ··· 131 82 } 132 83 } 133 84 134 - func GetReactionStatusMap(e Execer, userDid string, threadAt syntax.ATURI) map[ReactionKind]bool { 135 - statusMap := map[ReactionKind]bool{} 136 - for _, kind := range OrderedReactionKinds { 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 { 137 88 count := GetReactionStatus(e, userDid, threadAt, kind) 138 89 statusMap[kind] = count 139 90 }
+4 -43
appview/db/registration.go
··· 5 5 "fmt" 6 6 "strings" 7 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 8 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 9 + "tangled.org/core/appview/models" 49 10 ) 50 11 51 - func GetRegistrations(e Execer, filters ...filter) ([]Registration, error) { 52 - var registrations []Registration 12 + func GetRegistrations(e Execer, filters ...filter) ([]models.Registration, error) { 13 + var registrations []models.Registration 53 14 54 15 var conditions []string 55 16 var args []any ··· 81 42 var createdAt string 82 43 var registeredAt sql.Null[string] 83 44 var needsUpgrade int 84 - var reg Registration 45 + var reg models.Registration 85 46 86 47 err = rows.Scan(&reg.Id, &reg.Domain, &reg.ByDid, &createdAt, &registeredAt, &needsUpgrade) 87 48 if err != nil {
+162 -78
appview/db/repos.go
··· 11 11 12 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 13 securejoin "github.com/cyphar/filepath-securejoin" 14 - "tangled.sh/tangled.sh/core/api/tangled" 14 + "tangled.org/core/api/tangled" 15 + "tangled.org/core/appview/models" 15 16 ) 16 17 17 18 type Repo struct { 19 + Id int64 18 20 Did string 19 21 Name string 20 22 Knot string ··· 24 26 Spindle string 25 27 26 28 // optionally, populate this when querying for reverse mappings 27 - RepoStats *RepoStats 29 + RepoStats *models.RepoStats 28 30 29 31 // optional 30 32 Source string ··· 39 41 return p 40 42 } 41 43 42 - func GetRepos(e Execer, limit int, filters ...filter) ([]Repo, error) { 43 - repoMap := make(map[syntax.ATURI]*Repo) 44 + func GetRepos(e Execer, limit int, filters ...filter) ([]models.Repo, error) { 45 + repoMap := make(map[syntax.ATURI]*models.Repo) 44 46 45 47 var conditions []string 46 48 var args []any ··· 61 63 62 64 repoQuery := fmt.Sprintf( 63 65 `select 66 + id, 64 67 did, 65 68 name, 66 69 knot, ··· 84 87 } 85 88 86 89 for rows.Next() { 87 - var repo Repo 90 + var repo models.Repo 88 91 var createdAt string 89 92 var description, source, spindle sql.NullString 90 93 91 94 err := rows.Scan( 95 + &repo.Id, 92 96 &repo.Did, 93 97 &repo.Name, 94 98 &repo.Knot, ··· 115 119 repo.Spindle = spindle.String 116 120 } 117 121 118 - repo.RepoStats = &RepoStats{} 122 + repo.RepoStats = &models.RepoStats{} 119 123 repoMap[repo.RepoAt()] = &repo 120 124 } 121 125 ··· 132 136 i++ 133 137 } 134 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 + 135 162 languageQuery := fmt.Sprintf( 136 163 ` 137 - select 138 - repo_at, language 139 - from 140 - repo_languages r1 141 - where 142 - repo_at IN (%s) 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) 143 175 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 - ); 176 + ) 177 + where rn = 1 152 178 `, 153 179 inClause, 154 180 ) ··· 240 266 inClause, 241 267 ) 242 268 args = append([]any{ 243 - PullOpen, 244 - PullMerged, 245 - PullClosed, 246 - PullDeleted, 269 + models.PullOpen, 270 + models.PullMerged, 271 + models.PullClosed, 272 + models.PullDeleted, 247 273 }, args...) 248 274 rows, err = e.Query( 249 275 pullCountQuery, ··· 270 296 return nil, fmt.Errorf("failed to execute pulls-count query: %w ", err) 271 297 } 272 298 273 - var repos []Repo 299 + var repos []models.Repo 274 300 for _, r := range repoMap { 275 301 repos = append(repos, *r) 276 302 } 277 303 278 - slices.SortFunc(repos, func(a, b Repo) int { 304 + slices.SortFunc(repos, func(a, b models.Repo) int { 279 305 if a.Created.After(b.Created) { 280 306 return -1 281 307 } ··· 285 311 return repos, nil 286 312 } 287 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 + 288 332 func CountRepos(e Execer, filters ...filter) (int64, error) { 289 333 var conditions []string 290 334 var args []any ··· 309 353 return count, nil 310 354 } 311 355 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 356 + func GetRepoByAtUri(e Execer, atUri string) (*models.Repo, error) { 357 + var repo models.Repo 345 358 var nullableDescription sql.NullString 346 359 347 - row := e.QueryRow(`select did, name, knot, created, rkey, description from repos where at_uri = ?`, atUri) 360 + row := e.QueryRow(`select id, did, name, knot, created, rkey, description from repos where at_uri = ?`, atUri) 348 361 349 362 var createdAt string 350 - if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription); err != nil { 363 + if err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription); err != nil { 351 364 return nil, err 352 365 } 353 366 createdAtTime, _ := time.Parse(time.RFC3339, createdAt) ··· 362 375 return &repo, nil 363 376 } 364 377 365 - func AddRepo(e Execer, repo *Repo) error { 366 - _, err := e.Exec( 378 + func AddRepo(tx *sql.Tx, repo *models.Repo) error { 379 + _, err := tx.Exec( 367 380 `insert into repos 368 381 (did, name, knot, rkey, at_uri, description, source) 369 382 values (?, ?, ?, ?, ?, ?, ?)`, 370 383 repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.RepoAt().String(), repo.Description, repo.Source, 371 384 ) 372 - return err 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 373 399 } 374 400 375 401 func RemoveRepo(e Execer, did, name string) error { ··· 386 412 return nullableSource.String, nil 387 413 } 388 414 389 - func GetForksByDid(e Execer, did string) ([]Repo, error) { 390 - var repos []Repo 415 + func GetForksByDid(e Execer, did string) ([]models.Repo, error) { 416 + var repos []models.Repo 391 417 392 418 rows, err := e.Query( 393 - `select distinct r.did, r.name, r.knot, r.rkey, r.description, r.created, r.source 419 + `select distinct r.id, r.did, r.name, r.knot, r.rkey, r.description, r.created, r.source 394 420 from repos r 395 421 left join collaborators c on r.at_uri = c.repo_at 396 422 where (r.did = ? or c.subject_did = ?) ··· 405 431 defer rows.Close() 406 432 407 433 for rows.Next() { 408 - var repo Repo 434 + var repo models.Repo 409 435 var createdAt string 410 436 var nullableDescription sql.NullString 411 437 var nullableSource sql.NullString 412 438 413 - err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 439 + err := rows.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 414 440 if err != nil { 415 441 return nil, err 416 442 } ··· 440 466 return repos, nil 441 467 } 442 468 443 - func GetForkByDid(e Execer, did string, name string) (*Repo, error) { 444 - var repo Repo 469 + func GetForkByDid(e Execer, did string, name string) (*models.Repo, error) { 470 + var repo models.Repo 445 471 var createdAt string 446 472 var nullableDescription sql.NullString 447 473 var nullableSource sql.NullString 448 474 449 475 row := e.QueryRow( 450 - `select did, name, knot, rkey, description, created, source 476 + `select id, did, name, knot, rkey, description, created, source 451 477 from repos 452 478 where did = ? and name = ? and source is not null and source != ''`, 453 479 did, name, 454 480 ) 455 481 456 - err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 482 + err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 457 483 if err != nil { 458 484 return nil, err 459 485 } ··· 488 514 return err 489 515 } 490 516 491 - type RepoStats struct { 492 - Language string 493 - StarCount int 494 - IssueCount IssueCount 495 - PullCount PullCount 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 496 580 }
+4 -9
appview/db/signup.go
··· 1 1 package db 2 2 3 - import "time" 3 + import ( 4 + "tangled.org/core/appview/models" 5 + ) 4 6 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 { 7 + func AddInflightSignup(e Execer, signup models.InflightSignup) error { 13 8 query := `insert into signups_inflight (email, invite_code) values (?, ?)` 14 9 _, err := e.Exec(query, signup.Email, signup.InviteCode) 15 10 return err
+9 -27
appview/db/spindle.go
··· 6 6 "strings" 7 7 "time" 8 8 9 - "github.com/bluesky-social/indigo/atproto/syntax" 9 + "tangled.org/core/appview/models" 10 10 ) 11 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 12 + func GetSpindles(e Execer, filters ...filter) ([]models.Spindle, error) { 13 + var spindles []models.Spindle 32 14 33 15 var conditions []string 34 16 var args []any ··· 59 41 defer rows.Close() 60 42 61 43 for rows.Next() { 62 - var spindle Spindle 44 + var spindle models.Spindle 63 45 var createdAt string 64 46 var verified sql.NullString 65 47 var needsUpgrade int ··· 100 82 } 101 83 102 84 // if there is an existing spindle with the same instance, this returns an error 103 - func AddSpindle(e Execer, spindle Spindle) error { 85 + func AddSpindle(e Execer, spindle models.Spindle) error { 104 86 _, err := e.Exec( 105 87 `insert into spindles (owner, instance) values (?, ?)`, 106 88 spindle.Owner, ··· 151 133 return err 152 134 } 153 135 154 - func AddSpindleMember(e Execer, member SpindleMember) error { 136 + func AddSpindleMember(e Execer, member models.SpindleMember) error { 155 137 _, err := e.Exec( 156 138 `insert or ignore into spindle_members (did, rkey, instance, subject) values (?, ?, ?, ?)`, 157 139 member.Did, ··· 181 163 return err 182 164 } 183 165 184 - func GetSpindleMembers(e Execer, filters ...filter) ([]SpindleMember, error) { 185 - var members []SpindleMember 166 + func GetSpindleMembers(e Execer, filters ...filter) ([]models.SpindleMember, error) { 167 + var members []models.SpindleMember 186 168 187 169 var conditions []string 188 170 var args []any ··· 213 195 defer rows.Close() 214 196 215 197 for rows.Next() { 216 - var member SpindleMember 198 + var member models.SpindleMember 217 199 var createdAt string 218 200 219 201 if err := rows.Scan(
+80 -42
appview/db/star.go
··· 5 5 "errors" 6 6 "fmt" 7 7 "log" 8 + "slices" 8 9 "strings" 9 10 "time" 10 11 11 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 + "tangled.org/core/appview/models" 12 14 ) 13 15 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 { 16 + func AddStar(e Execer, star *models.Star) error { 39 17 query := `insert or ignore into stars (starred_by_did, repo_at, rkey) values (?, ?, ?)` 40 18 _, err := e.Exec( 41 19 query, ··· 47 25 } 48 26 49 27 // Get a star record 50 - func GetStar(e Execer, starredByDid string, repoAt syntax.ATURI) (*Star, error) { 28 + func GetStar(e Execer, starredByDid string, repoAt syntax.ATURI) (*models.Star, error) { 51 29 query := ` 52 30 select starred_by_did, repo_at, created, rkey 53 31 from stars 54 32 where starred_by_did = ? and repo_at = ?` 55 33 row := e.QueryRow(query, starredByDid, repoAt) 56 34 57 - var star Star 35 + var star models.Star 58 36 var created string 59 37 err := row.Scan(&star.StarredByDid, &star.RepoAt, &created, &star.Rkey) 60 38 if err != nil { ··· 94 72 return stars, nil 95 73 } 96 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 + 97 121 func GetStarStatus(e Execer, userDid string, repoAt syntax.ATURI) bool { 98 - if _, err := GetStar(e, userDid, repoAt); err != nil { 122 + statuses, err := getStarStatuses(e, userDid, []syntax.ATURI{repoAt}) 123 + if err != nil { 99 124 return false 100 - } else { 101 - return true 102 125 } 126 + return statuses[repoAt.String()] 103 127 } 104 128 105 - func GetStars(e Execer, limit int, filters ...filter) ([]Star, error) { 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) { 106 134 var conditions []string 107 135 var args []any 108 136 for _, filter := range filters { ··· 134 162 return nil, err 135 163 } 136 164 137 - starMap := make(map[string][]Star) 165 + starMap := make(map[string][]models.Star) 138 166 for rows.Next() { 139 - var star Star 167 + var star models.Star 140 168 var created string 141 169 err := rows.Scan(&star.StarredByDid, &star.RepoAt, &created, &star.Rkey) 142 170 if err != nil { ··· 177 205 } 178 206 } 179 207 180 - var stars []Star 208 + var stars []models.Star 181 209 for _, s := range starMap { 182 210 stars = append(stars, s...) 183 211 } 184 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 + 185 223 return stars, nil 186 224 } 187 225 ··· 209 247 return count, nil 210 248 } 211 249 212 - func GetAllStars(e Execer, limit int) ([]Star, error) { 213 - var stars []Star 250 + func GetAllStars(e Execer, limit int) ([]models.Star, error) { 251 + var stars []models.Star 214 252 215 253 rows, err := e.Query(` 216 254 select ··· 233 271 defer rows.Close() 234 272 235 273 for rows.Next() { 236 - var star Star 237 - var repo Repo 274 + var star models.Star 275 + var repo models.Repo 238 276 var starCreatedAt, repoCreatedAt string 239 277 240 278 if err := rows.Scan( ··· 272 310 } 273 311 274 312 // GetTopStarredReposLastWeek returns the top 8 most starred repositories from the last week 275 - func GetTopStarredReposLastWeek(e Execer) ([]Repo, error) { 313 + func GetTopStarredReposLastWeek(e Execer) ([]models.Repo, error) { 276 314 // first, get the top repo URIs by star count from the last week 277 315 query := ` 278 316 with recent_starred_repos as ( ··· 316 354 } 317 355 318 356 if len(repoUris) == 0 { 319 - return []Repo{}, nil 357 + return []models.Repo{}, nil 320 358 } 321 359 322 360 // get full repo data ··· 326 364 } 327 365 328 366 // sort repos by the original trending order 329 - repoMap := make(map[string]Repo) 367 + repoMap := make(map[string]models.Repo) 330 368 for _, repo := range repos { 331 369 repoMap[repo.RepoAt().String()] = repo 332 370 } 333 371 334 - orderedRepos := make([]Repo, 0, len(repoUris)) 372 + orderedRepos := make([]models.Repo, 0, len(repoUris)) 335 373 for _, uri := range repoUris { 336 374 if repo, exists := repoMap[uri]; exists { 337 375 orderedRepos = append(orderedRepos, repo)
+5 -110
appview/db/strings.go
··· 1 1 package db 2 2 3 3 import ( 4 - "bytes" 5 4 "database/sql" 6 5 "errors" 7 6 "fmt" 8 - "io" 9 7 "strings" 10 8 "time" 11 - "unicode/utf8" 12 9 13 - "github.com/bluesky-social/indigo/atproto/syntax" 14 - "tangled.sh/tangled.sh/core/api/tangled" 10 + "tangled.org/core/appview/models" 15 11 ) 16 12 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 { 13 + func AddString(e Execer, s models.String) error { 93 14 _, err := e.Exec( 94 15 `insert into strings ( 95 16 did, ··· 123 44 return err 124 45 } 125 46 126 - func GetStrings(e Execer, limit int, filters ...filter) ([]String, error) { 127 - var all []String 47 + func GetStrings(e Execer, limit int, filters ...filter) ([]models.String, error) { 48 + var all []models.String 128 49 129 50 var conditions []string 130 51 var args []any ··· 167 88 defer rows.Close() 168 89 169 90 for rows.Next() { 170 - var s String 91 + var s models.String 171 92 var createdAt string 172 93 var editedAt sql.NullString 173 94 ··· 248 169 _, err := e.Exec(query, args...) 249 170 return err 250 171 } 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 - }
+93 -42
appview/db/timeline.go
··· 2 2 3 3 import ( 4 4 "sort" 5 - "time" 6 - ) 7 5 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 - } 6 + "github.com/bluesky-social/indigo/atproto/syntax" 7 + "tangled.org/core/appview/models" 8 + ) 22 9 23 10 // TODO: this gathers heterogenous events from different sources and aggregates 24 11 // 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 12 + func MakeTimeline(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) { 13 + var events []models.TimelineEvent 27 14 28 - repos, err := getTimelineRepos(e, limit) 15 + repos, err := getTimelineRepos(e, limit, loggedInUserDid) 29 16 if err != nil { 30 17 return nil, err 31 18 } 32 19 33 - stars, err := getTimelineStars(e, limit) 20 + stars, err := getTimelineStars(e, limit, loggedInUserDid) 34 21 if err != nil { 35 22 return nil, err 36 23 } 37 24 38 - follows, err := getTimelineFollows(e, limit) 25 + follows, err := getTimelineFollows(e, limit, loggedInUserDid) 39 26 if err != nil { 40 27 return nil, err 41 28 } ··· 56 43 return events, nil 57 44 } 58 45 59 - func getTimelineRepos(e Execer, limit int) ([]TimelineEvent, error) { 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) { 60 74 repos, err := GetRepos(e, limit) 61 75 if err != nil { 62 76 return nil, err ··· 70 84 } 71 85 } 72 86 73 - var origRepos []Repo 87 + var origRepos []models.Repo 74 88 if args != nil { 75 89 origRepos, err = GetRepos(e, 0, FilterIn("at_uri", args)) 76 90 } ··· 78 92 return nil, err 79 93 } 80 94 81 - uriToRepo := make(map[string]Repo) 95 + uriToRepo := make(map[string]models.Repo) 82 96 for _, r := range origRepos { 83 97 uriToRepo[r.RepoAt().String()] = r 84 98 } 85 99 86 - var events []TimelineEvent 100 + starStatuses, err := fetchStarStatuses(e, loggedInUserDid, repos) 101 + if err != nil { 102 + return nil, err 103 + } 104 + 105 + var events []models.TimelineEvent 87 106 for _, r := range repos { 88 - var source *Repo 107 + var source *models.Repo 89 108 if r.Source != "" { 90 109 if origRepo, ok := uriToRepo[r.Source]; ok { 91 110 source = &origRepo 92 111 } 93 112 } 94 113 95 - events = append(events, TimelineEvent{ 96 - Repo: &r, 97 - EventAt: r.Created, 98 - Source: source, 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, 99 122 }) 100 123 } 101 124 102 125 return events, nil 103 126 } 104 127 105 - func getTimelineStars(e Execer, limit int) ([]TimelineEvent, error) { 128 + func getTimelineStars(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) { 106 129 stars, err := GetStars(e, limit) 107 130 if err != nil { 108 131 return nil, err ··· 118 141 } 119 142 stars = stars[:n] 120 143 121 - var events []TimelineEvent 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 122 155 for _, s := range stars { 123 - events = append(events, TimelineEvent{ 124 - Star: &s, 125 - EventAt: s.Created, 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, 126 163 }) 127 164 } 128 165 129 166 return events, nil 130 167 } 131 168 132 - func getTimelineFollows(e Execer, limit int) ([]TimelineEvent, error) { 169 + func getTimelineFollows(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) { 133 170 follows, err := GetFollows(e, limit) 134 171 if err != nil { 135 172 return nil, err ··· 154 191 return nil, err 155 192 } 156 193 157 - var events []TimelineEvent 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 158 203 for _, f := range follows { 159 204 profile, _ := profiles[f.SubjectDid] 160 205 followStatMap, _ := followStatMap[f.SubjectDid] 161 206 162 - events = append(events, TimelineEvent{ 163 - Follow: &f, 164 - Profile: profile, 165 - FollowStats: &followStatMap, 166 - EventAt: f.FollowedAt, 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, 167 218 }) 168 219 } 169 220
+1 -1
appview/dns/cloudflare.go
··· 5 5 "fmt" 6 6 7 7 "github.com/cloudflare/cloudflare-go" 8 - "tangled.sh/tangled.sh/core/appview/config" 8 + "tangled.org/core/appview/config" 9 9 ) 10 10 11 11 type Record struct {
+198 -61
appview/ingester.go
··· 5 5 "encoding/json" 6 6 "fmt" 7 7 "log/slog" 8 + "maps" 9 + "slices" 8 10 9 11 "time" 10 12 11 13 "github.com/bluesky-social/indigo/atproto/syntax" 12 - "github.com/bluesky-social/jetstream/pkg/models" 14 + jmodels "github.com/bluesky-social/jetstream/pkg/models" 13 15 "github.com/go-git/go-git/v5/plumbing" 14 16 "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" 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" 22 25 ) 23 26 24 27 type Ingester struct { ··· 30 33 Validator *validator.Validator 31 34 } 32 35 33 - type processFunc func(ctx context.Context, e *models.Event) error 36 + type processFunc func(ctx context.Context, e *jmodels.Event) error 34 37 35 38 func (i *Ingester) Ingest() processFunc { 36 - return func(ctx context.Context, e *models.Event) error { 39 + return func(ctx context.Context, e *jmodels.Event) error { 37 40 var err error 38 41 defer func() { 39 42 eventTime := e.TimeUS ··· 45 48 46 49 l := i.Logger.With("kind", e.Kind) 47 50 switch e.Kind { 48 - case models.EventKindAccount: 51 + case jmodels.EventKindAccount: 49 52 if !e.Account.Active && *e.Account.Status == "deactivated" { 50 53 err = i.IdResolver.InvalidateIdent(ctx, e.Account.Did) 51 54 } 52 - case models.EventKindIdentity: 55 + case jmodels.EventKindIdentity: 53 56 err = i.IdResolver.InvalidateIdent(ctx, e.Identity.Did) 54 - case models.EventKindCommit: 57 + case jmodels.EventKindCommit: 55 58 switch e.Commit.Collection { 56 59 case tangled.GraphFollowNSID: 57 60 err = i.ingestFollow(e) ··· 77 80 err = i.ingestIssue(ctx, e) 78 81 case tangled.RepoIssueCommentNSID: 79 82 err = i.ingestIssueComment(e) 83 + case tangled.LabelDefinitionNSID: 84 + err = i.ingestLabelDefinition(e) 85 + case tangled.LabelOpNSID: 86 + err = i.ingestLabelOp(e) 80 87 } 81 88 l = i.Logger.With("nsid", e.Commit.Collection) 82 89 } ··· 89 96 } 90 97 } 91 98 92 - func (i *Ingester) ingestStar(e *models.Event) error { 99 + func (i *Ingester) ingestStar(e *jmodels.Event) error { 93 100 var err error 94 101 did := e.Did 95 102 ··· 97 104 l = l.With("nsid", e.Commit.Collection) 98 105 99 106 switch e.Commit.Operation { 100 - case models.CommitOperationCreate, models.CommitOperationUpdate: 107 + case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: 101 108 var subjectUri syntax.ATURI 102 109 103 110 raw := json.RawMessage(e.Commit.Record) ··· 113 120 l.Error("invalid record", "err", err) 114 121 return err 115 122 } 116 - err = db.AddStar(i.Db, &db.Star{ 123 + err = db.AddStar(i.Db, &models.Star{ 117 124 StarredByDid: did, 118 125 RepoAt: subjectUri, 119 126 Rkey: e.Commit.RKey, 120 127 }) 121 - case models.CommitOperationDelete: 128 + case jmodels.CommitOperationDelete: 122 129 err = db.DeleteStarByRkey(i.Db, did, e.Commit.RKey) 123 130 } 124 131 ··· 129 136 return nil 130 137 } 131 138 132 - func (i *Ingester) ingestFollow(e *models.Event) error { 139 + func (i *Ingester) ingestFollow(e *jmodels.Event) error { 133 140 var err error 134 141 did := e.Did 135 142 ··· 137 144 l = l.With("nsid", e.Commit.Collection) 138 145 139 146 switch e.Commit.Operation { 140 - case models.CommitOperationCreate, models.CommitOperationUpdate: 147 + case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: 141 148 raw := json.RawMessage(e.Commit.Record) 142 149 record := tangled.GraphFollow{} 143 150 err = json.Unmarshal(raw, &record) ··· 146 153 return err 147 154 } 148 155 149 - err = db.AddFollow(i.Db, &db.Follow{ 156 + err = db.AddFollow(i.Db, &models.Follow{ 150 157 UserDid: did, 151 158 SubjectDid: record.Subject, 152 159 Rkey: e.Commit.RKey, 153 160 }) 154 - case models.CommitOperationDelete: 161 + case jmodels.CommitOperationDelete: 155 162 err = db.DeleteFollowByRkey(i.Db, did, e.Commit.RKey) 156 163 } 157 164 ··· 162 169 return nil 163 170 } 164 171 165 - func (i *Ingester) ingestPublicKey(e *models.Event) error { 172 + func (i *Ingester) ingestPublicKey(e *jmodels.Event) error { 166 173 did := e.Did 167 174 var err error 168 175 ··· 170 177 l = l.With("nsid", e.Commit.Collection) 171 178 172 179 switch e.Commit.Operation { 173 - case models.CommitOperationCreate, models.CommitOperationUpdate: 180 + case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: 174 181 l.Debug("processing add of pubkey") 175 182 raw := json.RawMessage(e.Commit.Record) 176 183 record := tangled.PublicKey{} ··· 183 190 name := record.Name 184 191 key := record.Key 185 192 err = db.AddPublicKey(i.Db, did, name, key, e.Commit.RKey) 186 - case models.CommitOperationDelete: 193 + case jmodels.CommitOperationDelete: 187 194 l.Debug("processing delete of pubkey") 188 195 err = db.DeletePublicKeyByRkey(i.Db, did, e.Commit.RKey) 189 196 } ··· 195 202 return nil 196 203 } 197 204 198 - func (i *Ingester) ingestArtifact(e *models.Event) error { 205 + func (i *Ingester) ingestArtifact(e *jmodels.Event) error { 199 206 did := e.Did 200 207 var err error 201 208 ··· 203 210 l = l.With("nsid", e.Commit.Collection) 204 211 205 212 switch e.Commit.Operation { 206 - case models.CommitOperationCreate, models.CommitOperationUpdate: 213 + case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: 207 214 raw := json.RawMessage(e.Commit.Record) 208 215 record := tangled.RepoArtifact{} 209 216 err = json.Unmarshal(raw, &record) ··· 232 239 createdAt = time.Now() 233 240 } 234 241 235 - artifact := db.Artifact{ 242 + artifact := models.Artifact{ 236 243 Did: did, 237 244 Rkey: e.Commit.RKey, 238 245 RepoAt: repoAt, ··· 245 252 } 246 253 247 254 err = db.AddArtifact(i.Db, artifact) 248 - case models.CommitOperationDelete: 255 + case jmodels.CommitOperationDelete: 249 256 err = db.DeleteArtifact(i.Db, db.FilterEq("did", did), db.FilterEq("rkey", e.Commit.RKey)) 250 257 } 251 258 ··· 256 263 return nil 257 264 } 258 265 259 - func (i *Ingester) ingestProfile(e *models.Event) error { 266 + func (i *Ingester) ingestProfile(e *jmodels.Event) error { 260 267 did := e.Did 261 268 var err error 262 269 ··· 268 275 } 269 276 270 277 switch e.Commit.Operation { 271 - case models.CommitOperationCreate, models.CommitOperationUpdate: 278 + case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: 272 279 raw := json.RawMessage(e.Commit.Record) 273 280 record := tangled.ActorProfile{} 274 281 err = json.Unmarshal(raw, &record) ··· 296 303 } 297 304 } 298 305 299 - var stats [2]db.VanityStat 306 + var stats [2]models.VanityStat 300 307 for i, s := range record.Stats { 301 308 if i < 2 { 302 - stats[i].Kind = db.VanityStatKind(s) 309 + stats[i].Kind = models.VanityStatKind(s) 303 310 } 304 311 } 305 312 ··· 310 317 } 311 318 } 312 319 313 - profile := db.Profile{ 320 + profile := models.Profile{ 314 321 Did: did, 315 322 Description: description, 316 323 IncludeBluesky: includeBluesky, ··· 336 343 } 337 344 338 345 err = db.UpsertProfile(tx, &profile) 339 - case models.CommitOperationDelete: 346 + case jmodels.CommitOperationDelete: 340 347 err = db.DeleteArtifact(i.Db, db.FilterEq("did", did), db.FilterEq("rkey", e.Commit.RKey)) 341 348 } 342 349 ··· 347 354 return nil 348 355 } 349 356 350 - func (i *Ingester) ingestSpindleMember(ctx context.Context, e *models.Event) error { 357 + func (i *Ingester) ingestSpindleMember(ctx context.Context, e *jmodels.Event) error { 351 358 did := e.Did 352 359 var err error 353 360 ··· 355 362 l = l.With("nsid", e.Commit.Collection) 356 363 357 364 switch e.Commit.Operation { 358 - case models.CommitOperationCreate: 365 + case jmodels.CommitOperationCreate: 359 366 raw := json.RawMessage(e.Commit.Record) 360 367 record := tangled.SpindleMember{} 361 368 err = json.Unmarshal(raw, &record) ··· 384 391 return fmt.Errorf("failed to index profile record, invalid db cast") 385 392 } 386 393 387 - err = db.AddSpindleMember(ddb, db.SpindleMember{ 394 + err = db.AddSpindleMember(ddb, models.SpindleMember{ 388 395 Did: syntax.DID(did), 389 396 Rkey: e.Commit.RKey, 390 397 Instance: record.Instance, ··· 400 407 } 401 408 402 409 l.Info("added spindle member") 403 - case models.CommitOperationDelete: 410 + case jmodels.CommitOperationDelete: 404 411 rkey := e.Commit.RKey 405 412 406 413 ddb, ok := i.Db.Execer.(*db.DB) ··· 453 460 return nil 454 461 } 455 462 456 - func (i *Ingester) ingestSpindle(ctx context.Context, e *models.Event) error { 463 + func (i *Ingester) ingestSpindle(ctx context.Context, e *jmodels.Event) error { 457 464 did := e.Did 458 465 var err error 459 466 ··· 461 468 l = l.With("nsid", e.Commit.Collection) 462 469 463 470 switch e.Commit.Operation { 464 - case models.CommitOperationCreate: 471 + case jmodels.CommitOperationCreate: 465 472 raw := json.RawMessage(e.Commit.Record) 466 473 record := tangled.Spindle{} 467 474 err = json.Unmarshal(raw, &record) ··· 477 484 return fmt.Errorf("failed to index profile record, invalid db cast") 478 485 } 479 486 480 - err := db.AddSpindle(ddb, db.Spindle{ 487 + err := db.AddSpindle(ddb, models.Spindle{ 481 488 Owner: syntax.DID(did), 482 489 Instance: instance, 483 490 }) ··· 499 506 500 507 return nil 501 508 502 - case models.CommitOperationDelete: 509 + case jmodels.CommitOperationDelete: 503 510 instance := e.Commit.RKey 504 511 505 512 ddb, ok := i.Db.Execer.(*db.DB) ··· 567 574 return nil 568 575 } 569 576 570 - func (i *Ingester) ingestString(e *models.Event) error { 577 + func (i *Ingester) ingestString(e *jmodels.Event) error { 571 578 did := e.Did 572 579 rkey := e.Commit.RKey 573 580 ··· 582 589 } 583 590 584 591 switch e.Commit.Operation { 585 - case models.CommitOperationCreate, models.CommitOperationUpdate: 592 + case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: 586 593 raw := json.RawMessage(e.Commit.Record) 587 594 record := tangled.String{} 588 595 err = json.Unmarshal(raw, &record) ··· 591 598 return err 592 599 } 593 600 594 - string := db.StringFromRecord(did, rkey, record) 601 + string := models.StringFromRecord(did, rkey, record) 595 602 596 - if err = string.Validate(); err != nil { 603 + if err = i.Validator.ValidateString(&string); err != nil { 597 604 l.Error("invalid record", "err", err) 598 605 return err 599 606 } ··· 605 612 606 613 return nil 607 614 608 - case models.CommitOperationDelete: 615 + case jmodels.CommitOperationDelete: 609 616 if err := db.DeleteString( 610 617 ddb, 611 618 db.FilterEq("did", did), ··· 621 628 return nil 622 629 } 623 630 624 - func (i *Ingester) ingestKnotMember(e *models.Event) error { 631 + func (i *Ingester) ingestKnotMember(e *jmodels.Event) error { 625 632 did := e.Did 626 633 var err error 627 634 ··· 629 636 l = l.With("nsid", e.Commit.Collection) 630 637 631 638 switch e.Commit.Operation { 632 - case models.CommitOperationCreate: 639 + case jmodels.CommitOperationCreate: 633 640 raw := json.RawMessage(e.Commit.Record) 634 641 record := tangled.KnotMember{} 635 642 err = json.Unmarshal(raw, &record) ··· 659 666 } 660 667 661 668 l.Info("added knot member") 662 - case models.CommitOperationDelete: 669 + case jmodels.CommitOperationDelete: 663 670 // we don't store knot members in a table (like we do for spindle) 664 671 // and we can't remove this just yet. possibly fixed if we switch 665 672 // to either: ··· 673 680 return nil 674 681 } 675 682 676 - func (i *Ingester) ingestKnot(e *models.Event) error { 683 + func (i *Ingester) ingestKnot(e *jmodels.Event) error { 677 684 did := e.Did 678 685 var err error 679 686 ··· 681 688 l = l.With("nsid", e.Commit.Collection) 682 689 683 690 switch e.Commit.Operation { 684 - case models.CommitOperationCreate: 691 + case jmodels.CommitOperationCreate: 685 692 raw := json.RawMessage(e.Commit.Record) 686 693 record := tangled.Knot{} 687 694 err = json.Unmarshal(raw, &record) ··· 716 723 717 724 return nil 718 725 719 - case models.CommitOperationDelete: 726 + case jmodels.CommitOperationDelete: 720 727 domain := e.Commit.RKey 721 728 722 729 ddb, ok := i.Db.Execer.(*db.DB) ··· 776 783 777 784 return nil 778 785 } 779 - func (i *Ingester) ingestIssue(ctx context.Context, e *models.Event) error { 786 + func (i *Ingester) ingestIssue(ctx context.Context, e *jmodels.Event) error { 780 787 did := e.Did 781 788 rkey := e.Commit.RKey 782 789 ··· 791 798 } 792 799 793 800 switch e.Commit.Operation { 794 - case models.CommitOperationCreate, models.CommitOperationUpdate: 801 + case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: 795 802 raw := json.RawMessage(e.Commit.Record) 796 803 record := tangled.RepoIssue{} 797 804 err = json.Unmarshal(raw, &record) ··· 800 807 return err 801 808 } 802 809 803 - issue := db.IssueFromRecord(did, rkey, record) 810 + issue := models.IssueFromRecord(did, rkey, record) 804 811 805 812 if err := i.Validator.ValidateIssue(&issue); err != nil { 806 813 return fmt.Errorf("failed to validate issue: %w", err) ··· 827 834 828 835 return nil 829 836 830 - case models.CommitOperationDelete: 837 + case jmodels.CommitOperationDelete: 831 838 if err := db.DeleteIssues( 832 839 ddb, 833 840 db.FilterEq("did", did), ··· 843 850 return nil 844 851 } 845 852 846 - func (i *Ingester) ingestIssueComment(e *models.Event) error { 853 + func (i *Ingester) ingestIssueComment(e *jmodels.Event) error { 847 854 did := e.Did 848 855 rkey := e.Commit.RKey 849 856 ··· 858 865 } 859 866 860 867 switch e.Commit.Operation { 861 - case models.CommitOperationCreate, models.CommitOperationUpdate: 868 + case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: 862 869 raw := json.RawMessage(e.Commit.Record) 863 870 record := tangled.RepoIssueComment{} 864 871 err = json.Unmarshal(raw, &record) ··· 866 873 return fmt.Errorf("invalid record: %w", err) 867 874 } 868 875 869 - comment, err := db.IssueCommentFromRecord(ddb, did, rkey, record) 876 + comment, err := models.IssueCommentFromRecord(did, rkey, record) 870 877 if err != nil { 871 878 return fmt.Errorf("failed to parse comment from record: %w", err) 872 879 } ··· 882 889 883 890 return nil 884 891 885 - case models.CommitOperationDelete: 892 + case jmodels.CommitOperationDelete: 886 893 if err := db.DeleteIssueComments( 887 894 ddb, 888 895 db.FilterEq("did", did), ··· 896 903 897 904 return nil 898 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 16 lexutil "github.com/bluesky-social/indigo/lex/util" 17 17 "github.com/go-chi/chi/v5" 18 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" 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" 32 33 ) 33 34 34 35 type Issues struct { ··· 75 76 return 76 77 } 77 78 78 - issue, ok := r.Context().Value("issue").(*db.Issue) 79 + issue, ok := r.Context().Value("issue").(*models.Issue) 79 80 if !ok { 80 81 l.Error("failed to get issue") 81 82 rp.pages.Error404(w) ··· 87 88 l.Error("failed to get issue reactions", "err", err) 88 89 } 89 90 90 - userReactions := map[db.ReactionKind]bool{} 91 + userReactions := map[models.ReactionKind]bool{} 91 92 if user != nil { 92 93 userReactions = db.GetReactionStatusMap(rp.db, user.Did, issue.AtUri()) 93 94 } 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 + 95 112 rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{ 96 113 LoggedInUser: user, 97 114 RepoInfo: f.RepoInfo(user), 98 115 Issue: issue, 99 116 CommentList: issue.CommentList(), 100 - OrderedReactionKinds: db.OrderedReactionKinds, 117 + OrderedReactionKinds: models.OrderedReactionKinds, 101 118 Reactions: reactionCountMap, 102 119 UserReacted: userReactions, 120 + LabelDefs: defs, 103 121 }) 104 122 } 105 123 ··· 112 130 return 113 131 } 114 132 115 - issue, ok := r.Context().Value("issue").(*db.Issue) 133 + issue, ok := r.Context().Value("issue").(*models.Issue) 116 134 if !ok { 117 135 l.Error("failed to get issue") 118 136 rp.pages.Error404(w) ··· 208 226 return 209 227 } 210 228 211 - issue, ok := r.Context().Value("issue").(*db.Issue) 229 + issue, ok := r.Context().Value("issue").(*models.Issue) 212 230 if !ok { 213 231 l.Error("failed to get issue") 214 232 rp.pages.Notice(w, noticeId, "Failed to delete issue.") ··· 255 273 return 256 274 } 257 275 258 - issue, ok := r.Context().Value("issue").(*db.Issue) 276 + issue, ok := r.Context().Value("issue").(*models.Issue) 259 277 if !ok { 260 278 l.Error("failed to get issue") 261 279 rp.pages.Error404(w) ··· 283 301 return 284 302 } 285 303 304 + // notify about the issue closure 305 + rp.notifier.NewIssueClosed(r.Context(), issue) 306 + 286 307 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 287 308 return 288 309 } else { ··· 301 322 return 302 323 } 303 324 304 - issue, ok := r.Context().Value("issue").(*db.Issue) 325 + issue, ok := r.Context().Value("issue").(*models.Issue) 305 326 if !ok { 306 327 l.Error("failed to get issue") 307 328 rp.pages.Error404(w) ··· 345 366 return 346 367 } 347 368 348 - issue, ok := r.Context().Value("issue").(*db.Issue) 369 + issue, ok := r.Context().Value("issue").(*models.Issue) 349 370 if !ok { 350 371 l.Error("failed to get issue") 351 372 rp.pages.Error404(w) ··· 364 385 replyTo = &replyToUri 365 386 } 366 387 367 - comment := db.IssueComment{ 388 + comment := models.IssueComment{ 368 389 Did: user.Did, 369 390 Rkey: tid.TID(), 370 391 IssueAt: issue.AtUri().String(), ··· 416 437 417 438 // reset atUri to make rollback a no-op 418 439 atUri = "" 440 + 441 + // notify about the new comment 442 + comment.Id = commentId 443 + rp.notifier.NewIssueComment(r.Context(), &comment) 444 + 419 445 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issue.IssueId, commentId)) 420 446 } 421 447 ··· 428 454 return 429 455 } 430 456 431 - issue, ok := r.Context().Value("issue").(*db.Issue) 457 + issue, ok := r.Context().Value("issue").(*models.Issue) 432 458 if !ok { 433 459 l.Error("failed to get issue") 434 460 rp.pages.Error404(w) ··· 469 495 return 470 496 } 471 497 472 - issue, ok := r.Context().Value("issue").(*db.Issue) 498 + issue, ok := r.Context().Value("issue").(*models.Issue) 473 499 if !ok { 474 500 l.Error("failed to get issue") 475 501 rp.pages.Error404(w) ··· 573 599 return 574 600 } 575 601 576 - issue, ok := r.Context().Value("issue").(*db.Issue) 602 + issue, ok := r.Context().Value("issue").(*models.Issue) 577 603 if !ok { 578 604 l.Error("failed to get issue") 579 605 rp.pages.Error404(w) ··· 614 640 return 615 641 } 616 642 617 - issue, ok := r.Context().Value("issue").(*db.Issue) 643 + issue, ok := r.Context().Value("issue").(*models.Issue) 618 644 if !ok { 619 645 l.Error("failed to get issue") 620 646 rp.pages.Error404(w) ··· 655 681 return 656 682 } 657 683 658 - issue, ok := r.Context().Value("issue").(*db.Issue) 684 + issue, ok := r.Context().Value("issue").(*models.Issue) 659 685 if !ok { 660 686 l.Error("failed to get issue") 661 687 rp.pages.Error404(w) ··· 772 798 return 773 799 } 774 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 + 775 817 rp.pages.RepoIssues(w, pages.RepoIssuesParams{ 776 818 LoggedInUser: rp.oauth.GetUser(r), 777 819 RepoInfo: f.RepoInfo(user), 778 820 Issues: issues, 821 + LabelDefs: defs, 779 822 FilteringByOpen: isOpen, 780 823 Page: page, 781 824 }) ··· 798 841 RepoInfo: f.RepoInfo(user), 799 842 }) 800 843 case http.MethodPost: 801 - issue := &db.Issue{ 844 + issue := &models.Issue{ 802 845 RepoAt: f.RepoAt(), 803 846 Rkey: tid.TID(), 804 847 Title: r.FormValue("title"),
+2 -2
appview/issues/router.go
··· 4 4 "net/http" 5 5 6 6 "github.com/go-chi/chi/v5" 7 - "tangled.sh/tangled.sh/core/appview/middleware" 7 + "tangled.org/core/appview/middleware" 8 8 ) 9 9 10 10 func (i *Issues) Router(mw *middleware.Middleware) http.Handler { ··· 14 14 r.With(middleware.Paginate).Get("/", i.RepoIssues) 15 15 16 16 r.Route("/{issue}", func(r chi.Router) { 17 - r.Use(mw.ResolveIssue()) 17 + r.Use(mw.ResolveIssue) 18 18 r.Get("/", i.RepoSingleIssue) 19 19 20 20 // authenticated routes
+14 -13
appview/knots/knots.go
··· 9 9 "time" 10 10 11 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" 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" 24 25 25 26 comatproto "github.com/bluesky-social/indigo/api/atproto" 26 27 lexutil "github.com/bluesky-social/indigo/lex/util" ··· 119 120 } 120 121 121 122 // organize repos by did 122 - repoMap := make(map[string][]db.Repo) 123 + repoMap := make(map[string][]models.Repo) 123 124 for _, r := range repos { 124 125 repoMap[r.Did] = append(repoMap[r.Did], r) 125 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 12 13 13 "github.com/bluesky-social/indigo/atproto/identity" 14 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" 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 22 ) 23 23 24 24 type Middleware struct { ··· 42 42 } 43 43 44 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 + } 45 54 46 55 func AuthMiddleware(a *oauth.OAuth) middlewareFunc { 47 56 return func(next http.Handler) http.Handler { ··· 213 222 return 214 223 } 215 224 216 - repo, err := db.GetRepo(mw.db, id.DID.String(), repoName) 225 + repo, err := db.GetRepo( 226 + mw.db, 227 + db.FilterEq("did", id.DID.String()), 228 + db.FilterEq("name", repoName), 229 + ) 217 230 if err != nil { 218 - // invalid did or handle 219 - log.Println("failed to resolve repo") 231 + log.Println("failed to resolve repo", "err", err) 220 232 mw.pages.ErrorKnot404(w) 221 233 return 222 234 } ··· 276 288 } 277 289 278 290 // 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 - } 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 + } 288 299 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 - } 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 + } 296 307 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] 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] 311 322 312 - ctx := context.WithValue(r.Context(), "issue", &issue) 313 - next.ServeHTTP(w, r.WithContext(ctx)) 314 - }) 315 - } 323 + ctx := context.WithValue(r.Context(), "issue", &issue) 324 + next.ServeHTTP(w, r.WithContext(ctx)) 325 + }) 316 326 } 317 327 318 328 // this should serve the go-import meta tag even if the path is technically 319 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 320 334 func (mw Middleware) GoImport() middlewareFunc { 321 335 return func(next http.Handler) http.Handler { 322 336 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ··· 332 346 if r.Header.Get("User-Agent") == "Go-http-client/1.1" { 333 347 if r.URL.Query().Get("go-get") == "1" { 334 348 html := fmt.Sprintf( 335 - `<meta name="go-import" content="tangled.sh/%s git https://tangled.sh/%s"/>`, 336 - fullName, 337 - fullName, 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, 338 353 ) 339 354 w.Header().Set("Content-Type", "text/html") 340 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 3 import ( 4 4 "context" 5 5 6 - "tangled.sh/tangled.sh/core/appview/db" 6 + "tangled.org/core/appview/models" 7 7 ) 8 8 9 9 type mergedNotifier struct { ··· 16 16 17 17 var _ Notifier = &mergedNotifier{} 18 18 19 - func (m *mergedNotifier) NewRepo(ctx context.Context, repo *db.Repo) { 19 + func (m *mergedNotifier) NewRepo(ctx context.Context, repo *models.Repo) { 20 20 for _, notifier := range m.notifiers { 21 21 notifier.NewRepo(ctx, repo) 22 22 } 23 23 } 24 24 25 - func (m *mergedNotifier) NewStar(ctx context.Context, star *db.Star) { 25 + func (m *mergedNotifier) NewStar(ctx context.Context, star *models.Star) { 26 26 for _, notifier := range m.notifiers { 27 27 notifier.NewStar(ctx, star) 28 28 } 29 29 } 30 - func (m *mergedNotifier) DeleteStar(ctx context.Context, star *db.Star) { 30 + func (m *mergedNotifier) DeleteStar(ctx context.Context, star *models.Star) { 31 31 for _, notifier := range m.notifiers { 32 32 notifier.DeleteStar(ctx, star) 33 33 } 34 34 } 35 35 36 - func (m *mergedNotifier) NewIssue(ctx context.Context, issue *db.Issue) { 36 + func (m *mergedNotifier) NewIssue(ctx context.Context, issue *models.Issue) { 37 37 for _, notifier := range m.notifiers { 38 38 notifier.NewIssue(ctx, issue) 39 39 } 40 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 + } 41 46 42 - func (m *mergedNotifier) NewFollow(ctx context.Context, follow *db.Follow) { 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) { 43 54 for _, notifier := range m.notifiers { 44 55 notifier.NewFollow(ctx, follow) 45 56 } 46 57 } 47 - func (m *mergedNotifier) DeleteFollow(ctx context.Context, follow *db.Follow) { 58 + func (m *mergedNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) { 48 59 for _, notifier := range m.notifiers { 49 60 notifier.DeleteFollow(ctx, follow) 50 61 } 51 62 } 52 63 53 - func (m *mergedNotifier) NewPull(ctx context.Context, pull *db.Pull) { 64 + func (m *mergedNotifier) NewPull(ctx context.Context, pull *models.Pull) { 54 65 for _, notifier := range m.notifiers { 55 66 notifier.NewPull(ctx, pull) 56 67 } 57 68 } 58 - func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *db.PullComment) { 69 + func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) { 59 70 for _, notifier := range m.notifiers { 60 71 notifier.NewPullComment(ctx, comment) 61 72 } 62 73 } 63 74 64 - func (m *mergedNotifier) UpdateProfile(ctx context.Context, profile *db.Profile) { 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) { 65 88 for _, notifier := range m.notifiers { 66 89 notifier.UpdateProfile(ctx, profile) 67 90 } 68 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 3 import ( 4 4 "context" 5 5 6 - "tangled.sh/tangled.sh/core/appview/db" 6 + "tangled.org/core/appview/models" 7 7 ) 8 8 9 9 type Notifier interface { 10 - NewRepo(ctx context.Context, repo *db.Repo) 10 + NewRepo(ctx context.Context, repo *models.Repo) 11 11 12 - NewStar(ctx context.Context, star *db.Star) 13 - DeleteStar(ctx context.Context, star *db.Star) 12 + NewStar(ctx context.Context, star *models.Star) 13 + DeleteStar(ctx context.Context, star *models.Star) 14 14 15 - NewIssue(ctx context.Context, issue *db.Issue) 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) 16 21 17 - NewFollow(ctx context.Context, follow *db.Follow) 18 - DeleteFollow(ctx context.Context, follow *db.Follow) 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) 19 26 20 - NewPull(ctx context.Context, pull *db.Pull) 21 - NewPullComment(ctx context.Context, comment *db.PullComment) 27 + UpdateProfile(ctx context.Context, profile *models.Profile) 22 28 23 - UpdateProfile(ctx context.Context, profile *db.Profile) 29 + NewString(ctx context.Context, s *models.String) 30 + EditString(ctx context.Context, s *models.String) 31 + DeleteString(ctx context.Context, did, rkey string) 24 32 } 25 33 26 34 // BaseNotifier is a listener that does nothing ··· 28 36 29 37 var _ Notifier = &BaseNotifier{} 30 38 31 - func (m *BaseNotifier) NewRepo(ctx context.Context, repo *db.Repo) {} 39 + func (m *BaseNotifier) NewRepo(ctx context.Context, repo *models.Repo) {} 32 40 33 - func (m *BaseNotifier) NewStar(ctx context.Context, star *db.Star) {} 34 - func (m *BaseNotifier) DeleteStar(ctx context.Context, star *db.Star) {} 41 + func (m *BaseNotifier) NewStar(ctx context.Context, star *models.Star) {} 42 + func (m *BaseNotifier) DeleteStar(ctx context.Context, star *models.Star) {} 35 43 36 - func (m *BaseNotifier) NewIssue(ctx context.Context, issue *db.Issue) {} 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) {} 37 50 38 - func (m *BaseNotifier) NewFollow(ctx context.Context, follow *db.Follow) {} 39 - func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *db.Follow) {} 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) {} 40 55 41 - func (m *BaseNotifier) NewPull(ctx context.Context, pull *db.Pull) {} 42 - func (m *BaseNotifier) NewPullComment(ctx context.Context, comment *db.PullComment) {} 56 + func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {} 43 57 44 - func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *db.Profile) {} 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 16 "github.com/gorilla/sessions" 17 17 "github.com/lestrrat-go/jwx/v2/jwk" 18 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" 19 31 "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 ) 32 33 33 34 const ( ··· 353 354 return pubKey, nil 354 355 } 355 356 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 357 func (o *OAuthHandler) addToDefaultSpindle(did string) { 365 358 // use the tangled.sh app password to get an accessJwt 366 359 // and create an sh.tangled.spindle.member record with that ··· 380 373 } 381 374 382 375 log.Printf("adding %s to default spindle", did) 383 - session, err := o.createAppPasswordSession(o.config.Core.AppPassword, tangledDid) 376 + session, err := o.createAppPasswordSession(o.config.Core.AppPassword, consts.TangledDid) 384 377 if err != nil { 385 378 log.Printf("failed to create session: %s", err) 386 379 return ··· 389 382 record := tangled.SpindleMember{ 390 383 LexiconTypeID: "sh.tangled.spindle.member", 391 384 Subject: did, 392 - Instance: defaultSpindle, 385 + Instance: consts.DefaultSpindle, 393 386 CreatedAt: time.Now().Format(time.RFC3339), 394 387 } 395 388 ··· 411 404 return 412 405 } 413 406 414 - if slices.Contains(allKnots, defaultKnot) { 407 + if slices.Contains(allKnots, consts.DefaultKnot) { 415 408 log.Printf("did %s is already a member of the default knot", did) 416 409 return 417 410 } 418 411 419 412 log.Printf("adding %s to default knot", did) 420 - session, err := o.createAppPasswordSession(o.config.Core.TmpAltAppPassword, icyDid) 413 + session, err := o.createAppPasswordSession(o.config.Core.TmpAltAppPassword, consts.IcyDid) 421 414 if err != nil { 422 415 log.Printf("failed to create session: %s", err) 423 416 return ··· 426 419 record := tangled.KnotMember{ 427 420 LexiconTypeID: "sh.tangled.knot.member", 428 421 Subject: did, 429 - Domain: defaultKnot, 422 + Domain: consts.DefaultKnot, 430 423 CreatedAt: time.Now().Format(time.RFC3339), 431 424 } 432 425 ··· 435 428 return 436 429 } 437 430 438 - if err := o.enforcer.AddKnotMember(defaultKnot, did); err != nil { 431 + if err := o.enforcer.AddKnotMember(consts.DefaultKnot, did); err != nil { 439 432 log.Printf("failed to set up enforcer rules: %s", err) 440 433 return 441 434 }
+4 -4
appview/oauth/oauth.go
··· 9 9 10 10 indigo_xrpc "github.com/bluesky-social/indigo/xrpc" 11 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" 12 16 oauth "tangled.sh/icyphox.sh/atproto-oauth" 13 17 "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 18 ) 19 19 20 20 type OAuth struct {
+32 -18
appview/pages/funcmap.go
··· 19 19 20 20 "github.com/dustin/go-humanize" 21 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" 22 + "tangled.org/core/appview/filetree" 23 + "tangled.org/core/appview/pages/markup" 24 + "tangled.org/core/crypto" 25 25 ) 26 26 27 27 func (p *Pages) funcMap() template.FuncMap { ··· 29 29 "split": func(s string) []string { 30 30 return strings.Split(s, "\n") 31 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 + }, 32 38 "contains": func(s string, target string) bool { 33 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() 34 48 }, 35 49 "resolve": func(s string) string { 36 50 identity, err := p.resolver.ResolveIdent(context.Background(), s) ··· 127 141 "relTimeFmt": humanize.Time, 128 142 "shortRelTimeFmt": func(t time.Time) string { 129 143 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}, 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}, 145 159 }) 146 160 }, 147 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 1 package markup 2 2 3 - import "strings" 3 + import ( 4 + "regexp" 5 + ) 4 6 5 7 type Format string 6 8 ··· 10 12 ) 11 13 12 14 var FileTypes map[Format][]string = map[Format][]string{ 13 - FormatMarkdown: []string{".md", ".markdown", ".mdown", ".mkdn", ".mkd"}, 15 + FormatMarkdown: {".md", ".markdown", ".mdown", ".mkdn", ".mkd"}, 14 16 } 15 17 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", 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 26 } 27 27 28 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 - } 29 + for format, pattern := range FileTypePatterns { 30 + if pattern.MatchString(filename) { 31 + return format 34 32 } 35 33 } 36 34 // default format
+2 -2
appview/pages/markup/markdown.go
··· 22 22 "github.com/yuin/goldmark/util" 23 23 htmlparse "golang.org/x/net/html" 24 24 25 - "tangled.sh/tangled.sh/core/api/tangled" 26 - "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 25 + "tangled.org/core/api/tangled" 26 + "tangled.org/core/appview/pages/repoinfo" 27 27 ) 28 28 29 29 // RendererType defines the type of renderer to use based on context
+243 -115
appview/pages/pages.go
··· 16 16 "strings" 17 17 "sync" 18 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" 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 30 31 31 "github.com/alecthomas/chroma/v2" 32 32 chromahtml "github.com/alecthomas/chroma/v2/formatters/html" ··· 38 38 "github.com/go-git/go-git/v5/plumbing/object" 39 39 ) 40 40 41 - //go:embed templates/* static 41 + //go:embed templates/* static legal 42 42 var Files embed.FS 43 43 44 44 type Pages struct { ··· 81 81 } 82 82 83 83 return p 84 - } 85 - 86 - func (p *Pages) pathToName(s string) string { 87 - return strings.TrimSuffix(strings.TrimPrefix(s, "templates/"), ".html") 88 84 } 89 85 90 86 // reverse of pathToName ··· 219 215 } 220 216 221 217 func (p *Pages) Favicon(w io.Writer) error { 222 - return p.executePlain("favicon", w, nil) 218 + return p.executePlain("fragments/dolly/silhouette", w, nil) 223 219 } 224 220 225 221 type LoginParams struct { ··· 230 226 return p.executePlain("user/login", w, params) 231 227 } 232 228 233 - func (p *Pages) Signup(w io.Writer) error { 234 - return p.executePlain("user/signup", w, nil) 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 235 } 236 236 237 237 func (p *Pages) CompleteSignup(w io.Writer) error { ··· 246 246 func (p *Pages) TermsOfService(w io.Writer, params TermsOfServiceParams) error { 247 247 filename := "terms.md" 248 248 filePath := filepath.Join("legal", filename) 249 - markdownBytes, err := os.ReadFile(filePath) 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) 250 257 if err != nil { 251 258 return fmt.Errorf("failed to read %s: %w", filename, err) 252 259 } ··· 267 274 func (p *Pages) PrivacyPolicy(w io.Writer, params PrivacyPolicyParams) error { 268 275 filename := "privacy.md" 269 276 filePath := filepath.Join("legal", filename) 270 - markdownBytes, err := os.ReadFile(filePath) 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) 271 285 if err != nil { 272 286 return fmt.Errorf("failed to read %s: %w", filename, err) 273 287 } ··· 280 294 return p.execute("legal/privacy", w, params) 281 295 } 282 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 + 283 305 type TimelineParams struct { 284 306 LoggedInUser *oauth.User 285 - Timeline []db.TimelineEvent 286 - Repos []db.Repo 307 + Timeline []models.TimelineEvent 308 + Repos []models.Repo 309 + GfiLabel *models.LabelDefinition 287 310 } 288 311 289 312 func (p *Pages) Timeline(w io.Writer, params TimelineParams) error { 290 313 return p.execute("timeline/timeline", w, params) 291 314 } 292 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 + 293 329 type UserProfileSettingsParams struct { 294 330 LoggedInUser *oauth.User 295 331 Tabs []map[string]any ··· 300 336 return p.execute("user/settings/profile", w, params) 301 337 } 302 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 + 303 367 type UserKeysSettingsParams struct { 304 368 LoggedInUser *oauth.User 305 - PubKeys []db.PublicKey 369 + PubKeys []models.PublicKey 306 370 Tabs []map[string]any 307 371 Tab string 308 372 } ··· 313 377 314 378 type UserEmailsSettingsParams struct { 315 379 LoggedInUser *oauth.User 316 - Emails []db.Email 380 + Emails []models.Email 317 381 Tabs []map[string]any 318 382 Tab string 319 383 } ··· 322 386 return p.execute("user/settings/emails", w, params) 323 387 } 324 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 + 325 400 type UpgradeBannerParams struct { 326 - Registrations []db.Registration 327 - Spindles []db.Spindle 401 + Registrations []models.Registration 402 + Spindles []models.Spindle 328 403 } 329 404 330 405 func (p *Pages) UpgradeBanner(w io.Writer, params UpgradeBannerParams) error { ··· 333 408 334 409 type KnotsParams struct { 335 410 LoggedInUser *oauth.User 336 - Registrations []db.Registration 411 + Registrations []models.Registration 337 412 } 338 413 339 414 func (p *Pages) Knots(w io.Writer, params KnotsParams) error { ··· 342 417 343 418 type KnotParams struct { 344 419 LoggedInUser *oauth.User 345 - Registration *db.Registration 420 + Registration *models.Registration 346 421 Members []string 347 - Repos map[string][]db.Repo 422 + Repos map[string][]models.Repo 348 423 IsOwner bool 349 424 } 350 425 ··· 353 428 } 354 429 355 430 type KnotListingParams struct { 356 - *db.Registration 431 + *models.Registration 357 432 } 358 433 359 434 func (p *Pages) KnotListing(w io.Writer, params KnotListingParams) error { ··· 362 437 363 438 type SpindlesParams struct { 364 439 LoggedInUser *oauth.User 365 - Spindles []db.Spindle 440 + Spindles []models.Spindle 366 441 } 367 442 368 443 func (p *Pages) Spindles(w io.Writer, params SpindlesParams) error { ··· 370 445 } 371 446 372 447 type SpindleListingParams struct { 373 - db.Spindle 448 + models.Spindle 374 449 } 375 450 376 451 func (p *Pages) SpindleListing(w io.Writer, params SpindleListingParams) error { ··· 379 454 380 455 type SpindleDashboardParams struct { 381 456 LoggedInUser *oauth.User 382 - Spindle db.Spindle 457 + Spindle models.Spindle 383 458 Members []string 384 - Repos map[string][]db.Repo 459 + Repos map[string][]models.Repo 385 460 } 386 461 387 462 func (p *Pages) SpindleDashboard(w io.Writer, params SpindleDashboardParams) error { ··· 410 485 type ProfileCard struct { 411 486 UserDid string 412 487 UserHandle string 413 - FollowStatus db.FollowStatus 414 - Punchcard *db.Punchcard 415 - Profile *db.Profile 488 + FollowStatus models.FollowStatus 489 + Punchcard *models.Punchcard 490 + Profile *models.Profile 416 491 Stats ProfileStats 417 492 Active string 418 493 } ··· 438 513 439 514 type ProfileOverviewParams struct { 440 515 LoggedInUser *oauth.User 441 - Repos []db.Repo 442 - CollaboratingRepos []db.Repo 443 - ProfileTimeline *db.ProfileTimeline 516 + Repos []models.Repo 517 + CollaboratingRepos []models.Repo 518 + ProfileTimeline *models.ProfileTimeline 444 519 Card *ProfileCard 445 520 Active string 446 521 } ··· 452 527 453 528 type ProfileReposParams struct { 454 529 LoggedInUser *oauth.User 455 - Repos []db.Repo 530 + Repos []models.Repo 456 531 Card *ProfileCard 457 532 Active string 458 533 } ··· 464 539 465 540 type ProfileStarredParams struct { 466 541 LoggedInUser *oauth.User 467 - Repos []db.Repo 542 + Repos []models.Repo 468 543 Card *ProfileCard 469 544 Active string 470 545 } ··· 476 551 477 552 type ProfileStringsParams struct { 478 553 LoggedInUser *oauth.User 479 - Strings []db.String 554 + Strings []models.String 480 555 Card *ProfileCard 481 556 Active string 482 557 } ··· 488 563 489 564 type FollowCard struct { 490 565 UserDid string 491 - FollowStatus db.FollowStatus 566 + LoggedInUser *oauth.User 567 + FollowStatus models.FollowStatus 492 568 FollowersCount int64 493 569 FollowingCount int64 494 - Profile *db.Profile 570 + Profile *models.Profile 495 571 } 496 572 497 573 type ProfileFollowersParams struct { ··· 520 596 521 597 type FollowFragmentParams struct { 522 598 UserDid string 523 - FollowStatus db.FollowStatus 599 + FollowStatus models.FollowStatus 524 600 } 525 601 526 602 func (p *Pages) FollowFragment(w io.Writer, params FollowFragmentParams) error { ··· 529 605 530 606 type EditBioParams struct { 531 607 LoggedInUser *oauth.User 532 - Profile *db.Profile 608 + Profile *models.Profile 533 609 } 534 610 535 611 func (p *Pages) EditBioFragment(w io.Writer, params EditBioParams) error { ··· 538 614 539 615 type EditPinsParams struct { 540 616 LoggedInUser *oauth.User 541 - Profile *db.Profile 617 + Profile *models.Profile 542 618 AllRepos []PinnedRepo 543 619 } 544 620 545 621 type PinnedRepo struct { 546 622 IsPinned bool 547 - db.Repo 623 + models.Repo 548 624 } 549 625 550 626 func (p *Pages) EditPinsFragment(w io.Writer, params EditPinsParams) error { ··· 554 630 type RepoStarFragmentParams struct { 555 631 IsStarred bool 556 632 RepoAt syntax.ATURI 557 - Stats db.RepoStats 633 + Stats models.RepoStats 558 634 } 559 635 560 636 func (p *Pages) RepoStarFragment(w io.Writer, params RepoStarFragmentParams) error { ··· 587 663 EmailToDidOrHandle map[string]string 588 664 VerifiedCommits commitverify.VerifiedCommits 589 665 Languages []types.RepoLanguageDetails 590 - Pipelines map[string]db.Pipeline 666 + Pipelines map[string]models.Pipeline 591 667 NeedsKnotUpgrade bool 592 668 types.RepoIndexResponse 593 669 } ··· 630 706 Active string 631 707 EmailToDidOrHandle map[string]string 632 708 VerifiedCommits commitverify.VerifiedCommits 633 - Pipelines map[string]db.Pipeline 709 + Pipelines map[string]models.Pipeline 634 710 } 635 711 636 712 func (p *Pages) RepoLog(w io.Writer, params RepoLogParams) error { ··· 643 719 RepoInfo repoinfo.RepoInfo 644 720 Active string 645 721 EmailToDidOrHandle map[string]string 646 - Pipeline *db.Pipeline 722 + Pipeline *models.Pipeline 647 723 DiffOpts types.DiffOpts 648 724 649 725 // singular because it's always going to be just one ··· 663 739 Active string 664 740 BreadCrumbs [][]string 665 741 TreePath string 742 + Raw bool 743 + HTMLReadme template.HTML 666 744 types.RepoTreeResponse 667 745 } 668 746 ··· 689 767 690 768 func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error { 691 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 + 692 788 return p.executeRepo("repo/tree", w, params) 693 789 } 694 790 ··· 709 805 RepoInfo repoinfo.RepoInfo 710 806 Active string 711 807 types.RepoTagsResponse 712 - ArtifactMap map[plumbing.Hash][]db.Artifact 713 - DanglingArtifacts []db.Artifact 808 + ArtifactMap map[plumbing.Hash][]models.Artifact 809 + DanglingArtifacts []models.Artifact 714 810 } 715 811 716 812 func (p *Pages) RepoTags(w io.Writer, params RepoTagsParams) error { ··· 721 817 type RepoArtifactParams struct { 722 818 LoggedInUser *oauth.User 723 819 RepoInfo repoinfo.RepoInfo 724 - Artifact db.Artifact 820 + Artifact models.Artifact 725 821 } 726 822 727 823 func (p *Pages) RepoArtifactFragment(w io.Writer, params RepoArtifactParams) error { ··· 818 914 } 819 915 820 916 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 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 827 927 } 828 928 829 929 func (p *Pages) RepoGeneralSettings(w io.Writer, params RepoGeneralSettingsParams) error { ··· 865 965 LoggedInUser *oauth.User 866 966 RepoInfo repoinfo.RepoInfo 867 967 Active string 868 - Issues []db.Issue 968 + Issues []models.Issue 969 + LabelDefs map[string]*models.LabelDefinition 869 970 Page pagination.Page 870 971 FilteringByOpen bool 871 972 } ··· 876 977 } 877 978 878 979 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 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 885 986 886 - OrderedReactionKinds []db.ReactionKind 887 - Reactions map[db.ReactionKind]int 888 - UserReacted map[db.ReactionKind]bool 987 + OrderedReactionKinds []models.ReactionKind 988 + Reactions map[models.ReactionKind]int 989 + UserReacted map[models.ReactionKind]bool 889 990 } 890 991 891 992 func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error { ··· 896 997 type EditIssueParams struct { 897 998 LoggedInUser *oauth.User 898 999 RepoInfo repoinfo.RepoInfo 899 - Issue *db.Issue 1000 + Issue *models.Issue 900 1001 Action string 901 1002 } 902 1003 ··· 907 1008 908 1009 type ThreadReactionFragmentParams struct { 909 1010 ThreadAt syntax.ATURI 910 - Kind db.ReactionKind 1011 + Kind models.ReactionKind 911 1012 Count int 912 1013 IsReacted bool 913 1014 } ··· 919 1020 type RepoNewIssueParams struct { 920 1021 LoggedInUser *oauth.User 921 1022 RepoInfo repoinfo.RepoInfo 922 - Issue *db.Issue // existing issue if any -- passed when editing 1023 + Issue *models.Issue // existing issue if any -- passed when editing 923 1024 Active string 924 1025 Action string 925 1026 } ··· 933 1034 type EditIssueCommentParams struct { 934 1035 LoggedInUser *oauth.User 935 1036 RepoInfo repoinfo.RepoInfo 936 - Issue *db.Issue 937 - Comment *db.IssueComment 1037 + Issue *models.Issue 1038 + Comment *models.IssueComment 938 1039 } 939 1040 940 1041 func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error { ··· 944 1045 type ReplyIssueCommentPlaceholderParams struct { 945 1046 LoggedInUser *oauth.User 946 1047 RepoInfo repoinfo.RepoInfo 947 - Issue *db.Issue 948 - Comment *db.IssueComment 1048 + Issue *models.Issue 1049 + Comment *models.IssueComment 949 1050 } 950 1051 951 1052 func (p *Pages) ReplyIssueCommentPlaceholderFragment(w io.Writer, params ReplyIssueCommentPlaceholderParams) error { ··· 955 1056 type ReplyIssueCommentParams struct { 956 1057 LoggedInUser *oauth.User 957 1058 RepoInfo repoinfo.RepoInfo 958 - Issue *db.Issue 959 - Comment *db.IssueComment 1059 + Issue *models.Issue 1060 + Comment *models.IssueComment 960 1061 } 961 1062 962 1063 func (p *Pages) ReplyIssueCommentFragment(w io.Writer, params ReplyIssueCommentParams) error { ··· 966 1067 type IssueCommentBodyParams struct { 967 1068 LoggedInUser *oauth.User 968 1069 RepoInfo repoinfo.RepoInfo 969 - Issue *db.Issue 970 - Comment *db.IssueComment 1070 + Issue *models.Issue 1071 + Comment *models.IssueComment 971 1072 } 972 1073 973 1074 func (p *Pages) IssueCommentBodyFragment(w io.Writer, params IssueCommentBodyParams) error { ··· 994 1095 type RepoPullsParams struct { 995 1096 LoggedInUser *oauth.User 996 1097 RepoInfo repoinfo.RepoInfo 997 - Pulls []*db.Pull 1098 + Pulls []*models.Pull 998 1099 Active string 999 - FilteringBy db.PullState 1000 - Stacks map[string]db.Stack 1001 - Pipelines map[string]db.Pipeline 1100 + FilteringBy models.PullState 1101 + Stacks map[string]models.Stack 1102 + Pipelines map[string]models.Pipeline 1103 + LabelDefs map[string]*models.LabelDefinition 1002 1104 } 1003 1105 1004 1106 func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error { ··· 1028 1130 LoggedInUser *oauth.User 1029 1131 RepoInfo repoinfo.RepoInfo 1030 1132 Active string 1031 - Pull *db.Pull 1032 - Stack db.Stack 1033 - AbandonedPulls []*db.Pull 1133 + Pull *models.Pull 1134 + Stack models.Stack 1135 + AbandonedPulls []*models.Pull 1034 1136 MergeCheck types.MergeCheckResponse 1035 1137 ResubmitCheck ResubmitResult 1036 - Pipelines map[string]db.Pipeline 1138 + Pipelines map[string]models.Pipeline 1037 1139 1038 - OrderedReactionKinds []db.ReactionKind 1039 - Reactions map[db.ReactionKind]int 1040 - UserReacted map[db.ReactionKind]bool 1140 + OrderedReactionKinds []models.ReactionKind 1141 + Reactions map[models.ReactionKind]int 1142 + UserReacted map[models.ReactionKind]bool 1143 + 1144 + LabelDefs map[string]*models.LabelDefinition 1041 1145 } 1042 1146 1043 1147 func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error { ··· 1048 1152 type RepoPullPatchParams struct { 1049 1153 LoggedInUser *oauth.User 1050 1154 RepoInfo repoinfo.RepoInfo 1051 - Pull *db.Pull 1052 - Stack db.Stack 1155 + Pull *models.Pull 1156 + Stack models.Stack 1053 1157 Diff *types.NiceDiff 1054 1158 Round int 1055 - Submission *db.PullSubmission 1056 - OrderedReactionKinds []db.ReactionKind 1159 + Submission *models.PullSubmission 1160 + OrderedReactionKinds []models.ReactionKind 1057 1161 DiffOpts types.DiffOpts 1058 1162 } 1059 1163 ··· 1065 1169 type RepoPullInterdiffParams struct { 1066 1170 LoggedInUser *oauth.User 1067 1171 RepoInfo repoinfo.RepoInfo 1068 - Pull *db.Pull 1172 + Pull *models.Pull 1069 1173 Round int 1070 1174 Interdiff *patchutil.InterdiffResult 1071 - OrderedReactionKinds []db.ReactionKind 1175 + OrderedReactionKinds []models.ReactionKind 1072 1176 DiffOpts types.DiffOpts 1073 1177 } 1074 1178 ··· 1097 1201 1098 1202 type PullCompareForkParams struct { 1099 1203 RepoInfo repoinfo.RepoInfo 1100 - Forks []db.Repo 1204 + Forks []models.Repo 1101 1205 Selected string 1102 1206 } 1103 1207 ··· 1118 1222 type PullResubmitParams struct { 1119 1223 LoggedInUser *oauth.User 1120 1224 RepoInfo repoinfo.RepoInfo 1121 - Pull *db.Pull 1225 + Pull *models.Pull 1122 1226 SubmissionId int 1123 1227 } 1124 1228 ··· 1129 1233 type PullActionsParams struct { 1130 1234 LoggedInUser *oauth.User 1131 1235 RepoInfo repoinfo.RepoInfo 1132 - Pull *db.Pull 1236 + Pull *models.Pull 1133 1237 RoundNumber int 1134 1238 MergeCheck types.MergeCheckResponse 1135 1239 ResubmitCheck ResubmitResult 1136 - Stack db.Stack 1240 + Stack models.Stack 1137 1241 } 1138 1242 1139 1243 func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error { ··· 1143 1247 type PullNewCommentParams struct { 1144 1248 LoggedInUser *oauth.User 1145 1249 RepoInfo repoinfo.RepoInfo 1146 - Pull *db.Pull 1250 + Pull *models.Pull 1147 1251 RoundNumber int 1148 1252 } 1149 1253 ··· 1154 1258 type RepoCompareParams struct { 1155 1259 LoggedInUser *oauth.User 1156 1260 RepoInfo repoinfo.RepoInfo 1157 - Forks []db.Repo 1261 + Forks []models.Repo 1158 1262 Branches []types.Branch 1159 1263 Tags []*types.TagReference 1160 1264 Base string ··· 1173 1277 type RepoCompareNewParams struct { 1174 1278 LoggedInUser *oauth.User 1175 1279 RepoInfo repoinfo.RepoInfo 1176 - Forks []db.Repo 1280 + Forks []models.Repo 1177 1281 Branches []types.Branch 1178 1282 Tags []*types.TagReference 1179 1283 Base string ··· 1208 1312 return p.executePlain("repo/fragments/diff", w, []any{params.RepoInfo.FullName, &params.Diff}) 1209 1313 } 1210 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 + 1211 1339 type PipelinesParams struct { 1212 1340 LoggedInUser *oauth.User 1213 1341 RepoInfo repoinfo.RepoInfo 1214 - Pipelines []db.Pipeline 1342 + Pipelines []models.Pipeline 1215 1343 Active string 1216 1344 } 1217 1345 ··· 1243 1371 type WorkflowParams struct { 1244 1372 LoggedInUser *oauth.User 1245 1373 RepoInfo repoinfo.RepoInfo 1246 - Pipeline db.Pipeline 1374 + Pipeline models.Pipeline 1247 1375 Workflow string 1248 1376 LogUrl string 1249 1377 Active string ··· 1259 1387 Action string 1260 1388 1261 1389 // this is supplied in the case of editing an existing string 1262 - String db.String 1390 + String models.String 1263 1391 } 1264 1392 1265 1393 func (p *Pages) PutString(w io.Writer, params PutStringParams) error { ··· 1269 1397 type StringsDashboardParams struct { 1270 1398 LoggedInUser *oauth.User 1271 1399 Card ProfileCard 1272 - Strings []db.String 1400 + Strings []models.String 1273 1401 } 1274 1402 1275 1403 func (p *Pages) StringsDashboard(w io.Writer, params StringsDashboardParams) error { ··· 1278 1406 1279 1407 type StringTimelineParams struct { 1280 1408 LoggedInUser *oauth.User 1281 - Strings []db.String 1409 + Strings []models.String 1282 1410 } 1283 1411 1284 1412 func (p *Pages) StringsTimeline(w io.Writer, params StringTimelineParams) error { ··· 1290 1418 ShowRendered bool 1291 1419 RenderToggle bool 1292 1420 RenderedContents template.HTML 1293 - String db.String 1294 - Stats db.StringStats 1421 + String models.String 1422 + Stats models.StringStats 1295 1423 Owner identity.Identity 1296 1424 } 1297 1425
+7 -6
appview/pages/repoinfo/repoinfo.go
··· 7 7 "strings" 8 8 9 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" 10 + "tangled.org/core/appview/models" 11 + "tangled.org/core/appview/state/userutil" 12 12 ) 13 13 14 14 func (r RepoInfo) OwnerWithAt() string { ··· 24 24 } 25 25 26 26 func (r RepoInfo) OwnerWithoutAt() string { 27 - if strings.HasPrefix(r.OwnerWithAt(), "@") { 28 - return strings.TrimPrefix(r.OwnerWithAt(), "@") 27 + if after, ok := strings.CutPrefix(r.OwnerWithAt(), "@"); ok { 28 + return after 29 29 } else { 30 30 return userutil.FlattenDid(r.OwnerDid) 31 31 } ··· 52 52 53 53 type RepoInfo struct { 54 54 Name string 55 + Rkey string 55 56 OwnerDid string 56 57 OwnerHandle string 57 58 Description string ··· 59 60 Spindle string 60 61 RepoAt syntax.ATURI 61 62 IsStarred bool 62 - Stats db.RepoStats 63 + Stats models.RepoStats 63 64 Roles RolesInRepo 64 - Source *db.Repo 65 + Source *models.Repo 65 66 SourceHandle string 66 67 Ref string 67 68 DisableFork bool
+1 -1
appview/pages/templates/banner.html
··· 30 30 <div class="mx-6"> 31 31 These services may not be fully accessible until upgraded. 32 32 <a class="underline text-red-800 dark:text-red-200" 33 - href="https://tangled.sh/@tangled.sh/core/tree/master/docs/migrations.md"> 33 + href="https://tangled.org/@tangled.org/core/tree/master/docs/migrations.md"> 34 34 Click to read the upgrade guide</a>. 35 35 </div> 36 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 5 <div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto"> 6 6 <div class="mb-6"> 7 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" }} 8 + {{ i "triangle-alert" "w-8 h-8 text-red-500 dark:text-red-400" }} 9 9 </div> 10 10 </div> 11 11 ··· 14 14 500 &mdash; internal server error 15 15 </h1> 16 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> 17 + We encountered an error while processing your request. Please try again later. 18 + </p> 26 19 <div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6"> 27 20 <button onclick="location.reload()" class="btn-create gap-2"> 28 21 {{ i "refresh-cw" "w-4 h-4" }} 29 22 try again 30 23 </button> 31 24 <a href="/" class="btn no-underline hover:no-underline gap-2"> 32 - {{ i "home" "w-4 h-4" }} 25 + {{ i "arrow-left" "w-4 h-4" }} 33 26 back to home 34 27 </a> 35 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 }}
+2 -1
appview/pages/templates/fragments/logotype.html
··· 1 1 {{ define "fragments/logotype" }} 2 2 <span class="flex items-center gap-2"> 3 - <span class="font-bold italic">tangled</span> 3 + {{ template "fragments/dolly/logo" "size-16 text-black dark:text-white" }} 4 + <span class="font-bold text-4xl not-italic">tangled</span> 4 5 <span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1"> 5 6 alpha 6 7 </span>
+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 }}
+1 -1
appview/pages/templates/knots/index.html
··· 5 5 <h1 class="text-xl font-bold dark:text-white">Knots</h1> 6 6 <span class="flex items-center gap-1"> 7 7 {{ i "book" "w-3 h-3" }} 8 - <a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/knot-hosting.md">docs</a> 8 + <a href="https://tangled.org/@tangled.org/core/blob/master/docs/knot-hosting.md">docs</a> 9 9 </span> 10 10 </div> 11 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 14 <link rel="preconnect" href="https://avatar.tangled.sh" /> 15 15 <link rel="preconnect" href="https://camo.tangled.sh" /> 16 16 17 + <!-- pwa manifest --> 18 + <link rel="manifest" href="/pwa-manifest.json" /> 19 + 17 20 <!-- preload main font --> 18 21 <link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin /> 19 22 ··· 21 24 <title>{{ block "title" . }}{{ end }} · tangled</title> 22 25 {{ block "extrameta" . }}{{ end }} 23 26 </head> 24 - <body class="min-h-screen grid grid-cols-1 grid-rows-[min-content_auto_min-content] md:grid-cols-10 lg:grid-cols-12 gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200"> 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"> 25 28 {{ block "topbarLayout" . }} 26 - <header class="px-1 col-span-1 md:col-start-2 md:col-span-8 lg:col-start-3" style="z-index: 20;"> 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;"> 27 30 28 31 {{ if .LoggedInUser }} 29 32 <div id="upgrade-banner" ··· 37 40 {{ end }} 38 41 39 42 {{ block "mainLayout" . }} 40 - <div class="px-1 col-span-1 md:col-start-2 md:col-span-8 lg:col-start-3 flex flex-col gap-4"> 41 - {{ block "contentLayout" . }} 42 - <main class="col-span-1 md:col-span-8"> 43 + <div class="flex-grow"> 44 + <div class="max-w-screen-lg mx-auto flex flex-col gap-4"> 45 + {{ block "contentLayout" . }} 46 + <main> 43 47 {{ block "content" . }}{{ end }} 44 48 </main> 45 - {{ end }} 46 - 47 - {{ block "contentAfterLayout" . }} 48 - <main class="col-span-1 md:col-span-8"> 49 + {{ end }} 50 + 51 + {{ block "contentAfterLayout" . }} 52 + <main> 49 53 {{ block "contentAfter" . }}{{ end }} 50 54 </main> 51 - {{ end }} 55 + {{ end }} 56 + </div> 52 57 </div> 53 58 {{ end }} 54 59 55 60 {{ block "footerLayout" . }} 56 - <footer class="px-1 col-span-1 md:col-start-2 md:col-span-8 lg:col-start-3 mt-12"> 61 + <footer class="bg-white dark:bg-gray-800 mt-12"> 57 62 {{ template "layouts/fragments/footer" . }} 58 63 </footer> 59 64 {{ end }}
+87 -33
appview/pages/templates/layouts/fragments/footer.html
··· 1 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> 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> 10 13 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> 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> 19 46 </div> 20 47 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> 48 + <!-- Right section --> 49 + <div class="text-right"> 50 + <div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div> 26 51 </div> 52 + </div> 27 53 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> 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> 33 64 </div> 34 65 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> 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> 39 93 </div> 40 - </div> 41 94 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> 95 + <div class="text-center"> 96 + <div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div> 97 + </div> 44 98 </div> 45 99 </div> 46 100 </div>
+18 -6
appview/pages/templates/layouts/fragments/topbar.html
··· 1 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"> 2 + <nav class="mx-auto space-x-4 px-6 py-2 rounded-b dark:text-white drop-shadow-sm"> 3 3 <div class="flex justify-between p-0 items-center"> 4 4 <div id="left-items"> 5 - <a href="/" hx-boost="true" class="text-lg">{{ template "fragments/logotype" }}</a> 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> 6 12 </div> 7 13 8 - <div id="right-items" class="flex items-center gap-2"> 14 + <div id="right-items" class="flex items-center gap-4"> 9 15 {{ with .LoggedInUser }} 10 16 {{ block "newButton" . }} {{ end }} 17 + {{ template "notifications/fragments/bell" }} 11 18 {{ block "dropDown" . }} {{ end }} 12 19 {{ else }} 13 20 <a href="/login">login</a> ··· 24 31 {{ define "newButton" }} 25 32 <details class="relative inline-block text-left nav-dropdown"> 26 33 <summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2"> 27 - {{ i "plus" "w-4 h-4" }} new 34 + {{ i "plus" "w-4 h-4" }} <span class="hidden md:inline">new</span> 28 35 </summary> 29 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"> 30 37 <a href="/repo/new" class="flex items-center gap-2"> ··· 42 49 {{ define "dropDown" }} 43 50 <details class="relative inline-block text-left nav-dropdown"> 44 51 <summary 45 - class="cursor-pointer list-none flex items-center" 52 + class="cursor-pointer list-none flex items-center gap-1" 46 53 > 47 54 {{ $user := didOrHandle .Did .Handle }} 48 - {{ template "user/fragments/picHandle" $user }} 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> 49 61 </summary> 50 62 <div 51 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 3 {{ define "extrameta" }} 4 4 <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" /> 5 5 <meta property="og:type" content="profile" /> 6 - <meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}?tab={{ .Active }}" /> 6 + <meta property="og:url" content="https://tangled.org/{{ or .Card.UserHandle .Card.UserDid }}?tab={{ .Active }}" /> 7 7 <meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 8 8 {{ end }} 9 9 10 10 {{ define "content" }} 11 11 {{ template "profileTabs" . }} 12 - <section class="bg-white dark:bg-gray-800 p-6 rounded w-full dark:text-white drop-shadow-sm"> 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 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"> 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"> 15 19 <div class="flex flex-col gap-4"> 16 20 {{ template "user/fragments/profileCard" .Card }} 17 21 {{ block "punchcard" .Card.Punchcard }} {{ end }} 18 22 </div> 19 23 </div> 24 + 20 25 {{ block "profileContent" . }} {{ end }} 21 26 </div> 22 27 </section> ··· 101 106 {{ define "layouts/profilebase" }} 102 107 {{ template "layouts/base" . }} 103 108 {{ end }} 104 -
+6 -8
appview/pages/templates/layouts/repobase.html
··· 41 41 {{ template "repo/fragments/repoDescription" . }} 42 42 </section> 43 43 44 - <section 45 - class="w-full flex flex-col" 46 - > 44 + <section class="w-full flex flex-col" > 47 45 <nav class="w-full pl-4 overflow-auto"> 48 46 <div class="flex z-60"> 49 47 {{ $activeTabStyles := "-mb-px bg-white dark:bg-gray-800" }} ··· 80 78 {{ end }} 81 79 </div> 82 80 </nav> 83 - <section 84 - class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white" 85 - > 81 + {{ block "repoContentLayout" . }} 82 + <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white"> 86 83 {{ block "repoContent" . }}{{ end }} 87 - </section> 88 - {{ block "repoAfter" . }}{{ end }} 84 + </section> 85 + {{ block "repoAfter" . }}{{ end }} 86 + {{ end }} 89 87 </section> 90 88 {{ end }}
+13 -6
appview/pages/templates/legal/privacy.html
··· 1 1 {{ define "title" }}privacy policy{{ end }} 2 2 3 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> 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 }} 9 15 </div> 16 + </main> 10 17 </div> 11 - {{ end }} 18 + {{ end }}
+13 -6
appview/pages/templates/legal/terms.html
··· 1 1 {{ define "title" }}terms of service{{ end }} 2 2 3 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> 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 }} 9 15 </div> 16 + </main> 10 17 </div> 11 - {{ end }} 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 4 {{ template "repo/fragments/meta" . }} 5 5 6 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 }} 7 + {{ $url := printf "https://tangled.org/%s/blob/%s/%s" .RepoInfo.FullName .Ref .Path }} 8 8 9 9 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 10 10 ··· 78 78 {{ end }} 79 79 </div> 80 80 {{ end }} 81 + {{ template "fragments/multiline-select" }} 81 82 {{ end }}
+2 -2
appview/pages/templates/repo/branches.html
··· 4 4 5 5 {{ define "extrameta" }} 6 6 {{ $title := printf "branches &middot; %s" .RepoInfo.FullName }} 7 - {{ $url := printf "https://tangled.sh/%s/branches" .RepoInfo.FullName }} 8 - 7 + {{ $url := printf "https://tangled.org/%s/branches" .RepoInfo.FullName }} 8 + 9 9 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 10 10 {{ end }} 11 11
+2 -2
appview/pages/templates/repo/commit.html
··· 2 2 3 3 {{ define "extrameta" }} 4 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 }} 5 + {{ $url := printf "https://tangled.org/%s/commit/%s" .RepoInfo.FullName .Diff.Commit.This }} 6 6 7 7 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 8 8 {{ end }} ··· 61 61 {{ $committerDidOrHandle := index $.EmailToDidOrHandle $commit.Committer.Email }} 62 62 <a href="/{{ $committerDidOrHandle }}">{{ template "user/fragments/picHandleLink" $committerDidOrHandle }}</a> 63 63 </div> 64 - <div class="my-1 pt-2 text-xs border-t"> 64 + <div class="my-1 pt-2 text-xs border-t border-gray-200 dark:border-gray-700"> 65 65 <div class="text-gray-600 dark:text-gray-300">SSH Key Fingerprint:</div> 66 66 <div class="break-all">{{ .VerifiedCommit.Fingerprint $commit.This }}</div> 67 67 </div>
+7
appview/pages/templates/repo/fork.html
··· 6 6 </div> 7 7 <div class="p-6 bg-white dark:bg-gray-800 drop-shadow-sm rounded"> 8 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 + 9 16 <fieldset class="space-y-3"> 10 17 <legend class="dark:text-white">Select a knot to fork into</legend> 11 18 <div class="space-y-2">
+3 -3
appview/pages/templates/repo/fragments/cloneDropdown.html
··· 1 1 {{ define "repo/fragments/cloneDropdown" }} 2 2 {{ $knot := .RepoInfo.Knot }} 3 3 {{ if eq $knot "knot1.tangled.sh" }} 4 - {{ $knot = "tangled.sh" }} 4 + {{ $knot = "tangled.org" }} 5 5 {{ end }} 6 6 7 7 <details id="clone-dropdown" class="relative inline-block text-left group"> ··· 29 29 <code 30 30 class="flex-1 px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-l select-all cursor-pointer whitespace-nowrap overflow-x-auto" 31 31 onclick="window.getSelection().selectAllChildren(this)" 32 - data-url="https://tangled.sh/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}" 33 - >https://tangled.sh/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}</code> 32 + data-url="https://tangled.org/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}" 33 + >https://tangled.org/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}</code> 34 34 <button 35 35 onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))" 36 36 class="px-3 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 border-l border-gray-300 dark:border-gray-600"
+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 1 {{ define "repo/fragments/meta" }} 2 2 <meta 3 3 name="vcs:clone" 4 - content="https://tangled.sh/{{ .RepoInfo.FullName }}" 4 + content="https://tangled.org/{{ .RepoInfo.FullName }}" 5 5 /> 6 6 <meta 7 7 name="forge:summary" 8 - content="https://tangled.sh/{{ .RepoInfo.FullName }}" 8 + content="https://tangled.org/{{ .RepoInfo.FullName }}" 9 9 /> 10 10 <meta 11 11 name="forge:dir" 12 - content="https://tangled.sh/{{ .RepoInfo.FullName }}/tree/{ref}/{path}" 12 + content="https://tangled.org/{{ .RepoInfo.FullName }}/tree/{ref}/{path}" 13 13 /> 14 14 <meta 15 15 name="forge:file" 16 - content="https://tangled.sh/{{ .RepoInfo.FullName }}/blob/{ref}/{path}" 16 + content="https://tangled.org/{{ .RepoInfo.FullName }}/blob/{ref}/{path}" 17 17 /> 18 18 <meta 19 19 name="forge:line" 20 - content="https://tangled.sh/{{ .RepoInfo.FullName }}/blob/{ref}/{path}#L{line}" 20 + content="https://tangled.org/{{ .RepoInfo.FullName }}/blob/{ref}/{path}#L{line}" 21 21 /> 22 22 <meta 23 23 name="go-import" 24 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 }}" 25 29 /> 26 30 {{ end }}
+1 -1
appview/pages/templates/repo/fragments/og.html
··· 1 1 {{ define "repo/fragments/og" }} 2 2 {{ $title := or .Title .RepoInfo.FullName }} 3 3 {{ $description := or .Description .RepoInfo.Description }} 4 - {{ $url := or .Url (printf "https://tangled.sh/%s" .RepoInfo.FullName) }} 4 + {{ $url := or .Url (printf "https://tangled.org/%s" .RepoInfo.FullName) }} 5 5 6 6 7 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 1 {{ define "repo/fragments/shortTimeAgo" }} 2 - {{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (print (. | shortRelTimeFmt) " ago")) }} 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) }} 3 8 {{ end }} 4 9
+2 -23
appview/pages/templates/repo/index.html
··· 49 49 <div 50 50 class="flex flex-grow items-center gap-2 text-xs align-items-center justify-center" 51 51 > 52 - {{ template "repo/fragments/languageBall" $value.Name }} 52 + {{ template "repo/fragments/colorBall" (dict "color" (langColor $value.Name)) }} 53 53 <div>{{ or $value.Name "Other" }} 54 54 <span class="text-gray-500 dark:text-gray-400"> 55 55 {{ if lt $value.Percentage 0.05 }} ··· 340 340 341 341 {{ define "repoAfter" }} 342 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> 343 + {{ template "repo/fragments/readme" . }} 365 344 {{- end -}} 366 345 {{ end }}
+4 -4
appview/pages/templates/repo/issues/fragments/commentList.html
··· 3 3 {{ range $item := .CommentList }} 4 4 {{ template "commentListing" (list $ .) }} 5 5 {{ end }} 6 - <div> 6 + </div> 7 7 {{ end }} 8 8 9 9 {{ define "commentListing" }} ··· 16 16 "Issue" $root.Issue 17 17 "Comment" $comment.Self) }} 18 18 19 - <div class="rounded border border-gray-300 dark:border-gray-700 w-full overflow-hidden shadow-sm"> 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 20 {{ template "topLevelComment" $params }} 21 21 22 - <div class="relative ml-4 border-l border-gray-300 dark:border-gray-700"> 22 + <div class="relative ml-4 border-l-2 border-gray-200 dark:border-gray-700"> 23 23 {{ range $index, $reply := $comment.Replies }} 24 24 <div class="relative "> 25 25 <!-- Horizontal connector --> 26 - <div class="absolute left-0 top-6 w-4 h-px bg-gray-300 dark:bg-gray-700"></div> 26 + <div class="absolute left-0 top-6 w-4 h-1 bg-gray-200 dark:bg-gray-700"></div> 27 27 28 28 <div class="pl-2"> 29 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 3 4 4 {{ define "extrameta" }} 5 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 }} 6 + {{ $url := printf "https://tangled.org/%s/issues/%d" .RepoInfo.FullName .Issue.IssueId }} 7 7 8 8 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 9 9 {{ end }} 10 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 + 11 30 {{ define "repoContent" }} 12 31 <section id="issue-{{ .Issue.IssueId }}"> 13 32 {{ template "issueHeader" .Issue }} ··· 15 34 {{ if .Issue.Body }} 16 35 <article id="body" class="mt-4 prose dark:prose-invert">{{ .Issue.Body | markdown }}</article> 17 36 {{ end }} 18 - {{ template "issueReactions" . }} 37 + <div class="flex flex-wrap gap-2 items-stretch mt-4"> 38 + {{ template "issueReactions" . }} 39 + </div> 19 40 </section> 20 41 {{ end }} 21 42 ··· 86 107 {{ end }} 87 108 88 109 {{ define "issueReactions" }} 89 - <div class="flex items-center gap-2 mt-2"> 110 + <div class="flex items-center gap-2"> 90 111 {{ template "repo/fragments/reactionsPopUp" .OrderedReactionKinds }} 91 112 {{ range $kind := .OrderedReactionKinds }} 92 113 {{ ··· 100 121 {{ end }} 101 122 </div> 102 123 {{ end }} 124 + 103 125 104 126 {{ define "repoAfter" }} 105 127 <div class="flex flex-col gap-4 mt-4"> ··· 113 135 }} 114 136 115 137 {{ template "repo/issues/fragments/newComment" . }} 116 - <div> 138 + </div> 117 139 {{ end }} 118 -
+3 -46
appview/pages/templates/repo/issues/issues.html
··· 2 2 3 3 {{ define "extrameta" }} 4 4 {{ $title := "issues"}} 5 - {{ $url := printf "https://tangled.sh/%s/issues" .RepoInfo.FullName }} 5 + {{ $url := printf "https://tangled.org/%s/issues" .RepoInfo.FullName }} 6 6 7 7 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 8 8 {{ end }} ··· 37 37 {{ end }} 38 38 39 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 }} 40 + <div class="mt-2"> 41 + {{ template "repo/issues/fragments/issueListing" (dict "Issues" .Issues "RepoPrefix" .RepoInfo.FullName "LabelDefs" .LabelDefs) }} 85 42 </div> 86 43 {{ block "pagination" . }} {{ end }} 87 44 {{ end }}
+1 -1
appview/pages/templates/repo/log.html
··· 2 2 3 3 {{ define "extrameta" }} 4 4 {{ $title := printf "commits &middot; %s" .RepoInfo.FullName }} 5 - {{ $url := printf "https://tangled.sh/%s/commits" .RepoInfo.FullName }} 5 + {{ $url := printf "https://tangled.org/%s/commits" .RepoInfo.FullName }} 6 6 7 7 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 8 8 {{ end }}
+163 -61
appview/pages/templates/repo/new.html
··· 1 1 {{ define "title" }}new repo{{ end }} 2 2 3 3 {{ define "content" }} 4 - <div class="p-6"> 5 - <p class="text-xl font-bold dark:text-white">Create a new repository</p> 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" . }} 6 13 </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> 14 + {{ end }} 19 15 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 - /> 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 }} 29 21 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 - /> 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> 37 35 </div> 36 + <div id="repo" class="error mt-2"></div> 38 37 39 - <fieldset class="space-y-3"> 40 - <legend class="dark:text-white">Select a knot</legend> 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 + 41 52 <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> 53 + {{ template "name" . }} 54 + {{ template "description" . }} 58 55 </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> 56 + </div> 57 + </div> 58 + {{ end }} 61 59 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> 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 }} 71 64 </div> 72 - </form> 73 - </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> 74 176 {{ end }}
+2 -2
appview/pages/templates/repo/pipelines/pipelines.html
··· 2 2 3 3 {{ define "extrameta" }} 4 4 {{ $title := "pipelines"}} 5 - {{ $url := printf "https://tangled.sh/%s/pipelines" .RepoInfo.FullName }} 5 + {{ $url := printf "https://tangled.org/%s/pipelines" .RepoInfo.FullName }} 6 6 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 7 7 {{ end }} 8 8 ··· 60 60 <span class="inline-flex gap-2 items-center"> 61 61 <span class="font-bold">{{ $target }}</span> 62 62 {{ i "arrow-left" "size-4" }} 63 - {{ .Trigger.PRSourceBranch }} 63 + {{ .Trigger.PRSourceBranch }} 64 64 <span class="text-sm font-mono"> 65 65 @ 66 66 <a href="/{{ $root.RepoInfo.FullName }}/commit/{{ $sha }}">{{ slice $sha 0 8 }}</a>
+1 -1
appview/pages/templates/repo/pipelines/workflow.html
··· 2 2 3 3 {{ define "extrameta" }} 4 4 {{ $title := "pipelines"}} 5 - {{ $url := printf "https://tangled.sh/%s/pipelines" .RepoInfo.FullName }} 5 + {{ $url := printf "https://tangled.org/%s/pipelines" .RepoInfo.FullName }} 6 6 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 7 7 {{ end }} 8 8
+2 -2
appview/pages/templates/repo/pulls/interdiff.html
··· 5 5 6 6 {{ define "extrameta" }} 7 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 - 8 + {{ $url := printf "https://tangled.org/%s/pulls/%d/round/%d" .RepoInfo.FullName .Pull.PullId .Round }} 9 + 10 10 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" (unescapeHtml $title) "Url" $url) }} 11 11 {{ end }} 12 12
+2 -2
appview/pages/templates/repo/pulls/patch.html
··· 5 5 6 6 {{ define "extrameta" }} 7 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 - 8 + {{ $url := printf "https://tangled.org/%s/pulls/%d/round/%d" .RepoInfo.FullName .Pull.PullId .Round }} 9 + 10 10 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 11 11 {{ end }} 12 12
+31 -13
appview/pages/templates/repo/pulls/pull.html
··· 4 4 5 5 {{ define "extrameta" }} 6 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 }} 7 + {{ $url := printf "https://tangled.org/%s/pulls/%d" .RepoInfo.FullName .Pull.PullId }} 8 8 9 9 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 10 10 {{ end }} 11 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 }} 12 30 13 31 {{ define "repoContent" }} 14 32 {{ template "repo/pulls/fragments/pullHeader" . }} ··· 39 57 {{ with $item }} 40 58 <details {{ if eq $idx $lastIdx }}open{{ end }}> 41 59 <summary id="round-#{{ .RoundNumber }}" class="list-none cursor-pointer"> 42 - <div class="flex flex-wrap gap-2 items-center"> 60 + <div class="flex flex-wrap gap-2 items-stretch"> 43 61 <!-- round number --> 44 62 <div class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-3 py-2 dark:text-white"> 45 63 <span class="flex items-center">{{ i "hash" "w-4 h-4" }}{{ .RoundNumber }}</span> 46 64 </div> 47 65 <!-- round summary --> 48 - <div class="rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400"> 66 + <div class="flex-1 rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400"> 49 67 <span class="gap-1 flex items-center"> 50 68 {{ $owner := resolve $.Pull.OwnerDid }} 51 69 {{ $re := "re" }} ··· 72 90 <span class="hidden md:inline">diff</span> 73 91 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 74 92 </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> 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> 84 101 {{ end }} 102 + <span id="interdiff-error-{{.RoundNumber}}"></span> 85 103 </div> 86 104 </summary> 87 105 ··· 146 164 147 165 <div class="md:pl-[3.5rem] flex flex-col gap-2 mt-2 relative"> 148 166 {{ 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"> 167 + <div id="comment-{{$c.ID}}" class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full"> 150 168 {{ if gt $cidx 0 }} 151 169 <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> 152 170 {{ end }}
+8 -1
appview/pages/templates/repo/pulls/pulls.html
··· 2 2 3 3 {{ define "extrameta" }} 4 4 {{ $title := "pulls"}} 5 - {{ $url := printf "https://tangled.sh/%s/pulls" .RepoInfo.FullName }} 5 + {{ $url := printf "https://tangled.org/%s/pulls" .RepoInfo.FullName }} 6 6 7 7 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 8 8 {{ end }} ··· 107 107 {{ if and $pipeline $pipeline.Id }} 108 108 <span class="before:content-['·']"></span> 109 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 }} 110 117 {{ end }} 111 118 </div> 112 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 7 </div> 8 8 <div class="col-span-1 md:col-span-3 flex flex-col gap-6 p-2"> 9 9 {{ template "branchSettings" . }} 10 + {{ template "defaultLabelSettings" . }} 11 + {{ template "customLabelSettings" . }} 10 12 {{ template "deleteRepo" . }} 11 13 <div id="operation-error" class="text-red-500 dark:text-red-400"></div> 12 14 </div> ··· 42 44 </div> 43 45 {{ end }} 44 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 + 45 170 {{ define "deleteRepo" }} 46 171 {{ if .RepoInfo.Roles.RepoDeleteAllowed }} 47 172 <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> ··· 68 193 </div> 69 194 {{ end }} 70 195 {{ end }} 196 +
+2 -2
appview/pages/templates/repo/settings/pipelines.html
··· 22 22 <p class="text-gray-500 dark:text-gray-400"> 23 23 Choose a spindle to execute your workflows on. Only repository owners 24 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"> 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 26 click to learn more. 27 27 </a> 28 28 </p> ··· 109 109 hx-swap="none" 110 110 class="flex flex-col gap-2" 111 111 > 112 - <p class="uppercase p-0">ADD SECRET</p> 112 + <p class="uppercase p-0 font-bold">ADD SECRET</p> 113 113 <p class="text-sm text-gray-500 dark:text-gray-400">Secrets are available as environment variables in the workflow.</p> 114 114 <input 115 115 type="text"
+10 -10
appview/pages/templates/repo/tags.html
··· 4 4 5 5 {{ define "extrameta" }} 6 6 {{ $title := printf "tags &middot; %s" .RepoInfo.FullName }} 7 - {{ $url := printf "https://tangled.sh/%s/tags" .RepoInfo.FullName }} 8 - 7 + {{ $url := printf "https://tangled.org/%s/tags" .RepoInfo.FullName }} 8 + 9 9 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 10 10 {{ end }} 11 11 ··· 26 26 27 27 <div class="flex items-center gap-3 text-gray-500 dark:text-gray-400 text-sm"> 28 28 {{ if .Tag }} 29 - <a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Tag.Target.String }}" 29 + <a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Tag.Target.String }}" 30 30 class="no-underline hover:underline text-gray-500 dark:text-gray-400"> 31 31 {{ slice .Tag.Target.String 0 8 }} 32 32 </a> ··· 48 48 </a> 49 49 <div class="flex flex-grow flex-col text-gray-500 dark:text-gray-400 text-sm"> 50 50 {{ if .Tag }} 51 - <a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Tag.Target.String }}" 51 + <a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Tag.Target.String }}" 52 52 class="no-underline hover:underline text-gray-500 dark:text-gray-400 flex items-center gap-2"> 53 53 {{ i "git-commit-horizontal" "w-4 h-4" }} 54 54 {{ slice .Tag.Target.String 0 8 }} ··· 132 132 hx-target="this" 133 133 class="flex items-center gap-2 px-2"> 134 134 <div class="flex-grow"> 135 - <input type="file" 136 - name="artifact" 135 + <input type="file" 136 + name="artifact" 137 137 required 138 138 class="block py-2 px-0 w-full border-none 139 139 text-black dark:text-white ··· 148 148 </input> 149 149 </div> 150 150 <div class="flex justify-end"> 151 - <button 152 - type="submit" 153 - class="btn gap-2" 151 + <button 152 + type="submit" 153 + class="btn gap-2" 154 154 id="upload-btn-{{$unique}}" 155 155 title="Upload artifact"> 156 156 {{ i "upload" "w-4 h-4" }} 157 - <span class="hidden md:inline">upload</span> 157 + <span class="hidden md:inline">upload</span> 158 158 </button> 159 159 </div> 160 160 </form>
+7 -1
appview/pages/templates/repo/tree.html
··· 10 10 11 11 {{ template "repo/fragments/meta" . }} 12 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 }} 13 + {{ $url := printf "https://tangled.org/%s/tree/%s%s" .RepoInfo.FullName .Ref $path }} 14 14 15 15 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 16 16 {{ end }} ··· 88 88 </div> 89 89 </main> 90 90 {{end}} 91 + 92 + {{ define "repoAfter" }} 93 + {{- if or .HTMLReadme .Readme -}} 94 + {{ template "repo/fragments/readme" . }} 95 + {{- end -}} 96 + {{ end }}
+1 -1
appview/pages/templates/spindles/index.html
··· 5 5 <h1 class="text-xl font-bold dark:text-white">Spindles</h1> 6 6 <span class="flex items-center gap-1"> 7 7 {{ i "book" "w-3 h-3" }} 8 - <a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/spindle/hosting.md">docs</a> 8 + <a href="https://tangled.org/@tangled.org/core/blob/master/docs/spindle/hosting.md">docs</a> 9 9 </span> 10 10 </div> 11 11
+1 -1
appview/pages/templates/strings/dashboard.html
··· 3 3 {{ define "extrameta" }} 4 4 <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" /> 5 5 <meta property="og:type" content="profile" /> 6 - <meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}" /> 6 + <meta property="og:url" content="https://tangled.org/{{ or .Card.UserHandle .Card.UserDid }}" /> 7 7 <meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 8 8 {{ end }} 9 9
+2 -2
appview/pages/templates/strings/put.html
··· 3 3 {{ define "content" }} 4 4 <div class="px-6 py-2 mb-4"> 5 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> 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 8 {{ else }} 9 9 <p class="text-xl font-bold dark:text-white">Edit string</p> 10 10 {{ end }}
+4 -3
appview/pages/templates/strings/string.html
··· 4 4 {{ $ownerId := didOrHandle .Owner.DID.String .Owner.Handle.String }} 5 5 <meta property="og:title" content="{{ .String.Filename }} · by {{ $ownerId }}" /> 6 6 <meta property="og:type" content="object" /> 7 - <meta property="og:url" content="https://tangled.sh/strings/{{ $ownerId }}/{{ .String.Rkey }}" /> 7 + <meta property="og:url" content="https://tangled.org/strings/{{ $ownerId }}/{{ .String.Rkey }}" /> 8 8 <meta property="og:description" content="{{ .String.Description }}" /> 9 9 {{ end }} 10 10 ··· 23 23 hx-boost="true" 24 24 href="/strings/{{ .String.Did }}/{{ .String.Rkey }}/edit"> 25 25 {{ i "pencil" "size-4" }} 26 - <span class="hidden md:inline">edit</span> 26 + <span class="hidden md:inline">edit</span> 27 27 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 28 28 </a> 29 29 <button ··· 34 34 hx-confirm="Are you sure you want to delete the string `{{ .String.Filename }}`?" 35 35 > 36 36 {{ i "trash-2" "size-4" }} 37 - <span class="hidden md:inline">delete</span> 37 + <span class="hidden md:inline">delete</span> 38 38 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 39 39 </button> 40 40 </div> ··· 80 80 <div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ .String.Contents | escapeHtml }}</div> 81 81 {{ end }} 82 82 </div> 83 + {{ template "fragments/multiline-select" }} 83 84 </section> 84 85 {{ end }}
+5 -7
appview/pages/templates/strings/timeline.html
··· 26 26 {{ end }} 27 27 28 28 {{ define "stringCard" }} 29 + {{ $resolved := resolve .Did.String }} 29 30 <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> 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> 32 35 </div> 33 36 {{ with .Description }} 34 37 <div class="text-gray-600 dark:text-gray-300 text-sm"> ··· 42 45 43 46 {{ define "stringCardInfo" }} 44 47 {{ $stat := .Stats }} 45 - {{ $resolved := resolve .Did.String }} 46 48 <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 49 <span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span> 52 50 <span class="select-none [&:before]:content-['·']"></span> 53 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 }}
+1 -2
appview/pages/templates/timeline/fragments/hero.html
··· 22 22 </div> 23 23 24 24 <figure class="w-full hidden md:block md:w-auto"> 25 - <a href="https://tangled.sh/@tangled.sh/core" class="block"> 25 + <a href="https://tangled.org/@tangled.org/core" class="block"> 26 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 27 </a> 28 28 <figcaption class="text-sm text-gray-600 dark:text-gray-400 mt-2 text-center"> ··· 31 31 </figure> 32 32 </div> 33 33 {{ end }} 34 -
+23 -35
appview/pages/templates/timeline/fragments/timeline.html
··· 13 13 {{ with $e }} 14 14 <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm"> 15 15 {{ if .Repo }} 16 - {{ template "timeline/fragments/repoEvent" (list $ .Repo .Source) }} 16 + {{ template "timeline/fragments/repoEvent" (list $ .) }} 17 17 {{ else if .Star }} 18 - {{ template "timeline/fragments/starEvent" (list $ .Star) }} 18 + {{ template "timeline/fragments/starEvent" (list $ .) }} 19 19 {{ else if .Follow }} 20 - {{ template "timeline/fragments/followEvent" (list $ .Follow .Profile .FollowStats) }} 20 + {{ template "timeline/fragments/followEvent" (list $ .) }} 21 21 {{ end }} 22 22 </div> 23 23 {{ end }} ··· 29 29 30 30 {{ define "timeline/fragments/repoEvent" }} 31 31 {{ $root := index . 0 }} 32 - {{ $repo := index . 1 }} 33 - {{ $source := index . 2 }} 32 + {{ $event := index . 1 }} 33 + {{ $repo := $event.Repo }} 34 + {{ $source := $event.Source }} 34 35 {{ $userHandle := resolve $repo.Did }} 35 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"> 36 37 {{ template "user/fragments/picHandleLink" $repo.Did }} ··· 51 52 <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $repo.Created }}</span> 52 53 </div> 53 54 {{ with $repo }} 54 - {{ template "user/fragments/repoCard" (list $root . true) }} 55 + {{ template "user/fragments/repoCard" (list $root . true true (dict "IsStarred" $event.IsStarred "RepoAt" .RepoAt "Stats" (dict "StarCount" $event.StarCount))) }} 55 56 {{ end }} 56 57 {{ end }} 57 58 58 59 {{ define "timeline/fragments/starEvent" }} 59 60 {{ $root := index . 0 }} 60 - {{ $star := index . 1 }} 61 + {{ $event := index . 1 }} 62 + {{ $star := $event.Star }} 61 63 {{ with $star }} 62 64 {{ $starrerHandle := resolve .StarredByDid }} 63 65 {{ $repoOwnerHandle := resolve .Repo.Did }} ··· 70 72 <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" .Created }}</span> 71 73 </div> 72 74 {{ with .Repo }} 73 - {{ template "user/fragments/repoCard" (list $root . true) }} 75 + {{ template "user/fragments/repoCard" (list $root . true true (dict "IsStarred" $event.IsStarred "RepoAt" .RepoAt "Stats" (dict "StarCount" $event.StarCount))) }} 74 76 {{ end }} 75 77 {{ end }} 76 78 {{ end }} 77 79 78 80 {{ define "timeline/fragments/followEvent" }} 79 81 {{ $root := index . 0 }} 80 - {{ $follow := index . 1 }} 81 - {{ $profile := index . 2 }} 82 - {{ $stat := index . 3 }} 82 + {{ $event := index . 1 }} 83 + {{ $follow := $event.Follow }} 84 + {{ $profile := $event.Profile }} 85 + {{ $followStats := $event.FollowStats }} 86 + {{ $followStatus := $event.FollowStatus }} 83 87 84 88 {{ $userHandle := resolve $follow.UserDid }} 85 89 {{ $subjectHandle := resolve $follow.SubjectDid }} ··· 89 93 {{ template "user/fragments/picHandleLink" $subjectHandle }} 90 94 <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $follow.FollowedAt }}</span> 91 95 </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> 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) }} 116 104 {{ end }}
+2 -2
appview/pages/templates/timeline/home.html
··· 3 3 {{ define "extrameta" }} 4 4 <meta property="og:title" content="timeline · tangled" /> 5 5 <meta property="og:type" content="object" /> 6 - <meta property="og:url" content="https://tangled.sh" /> 6 + <meta property="og:url" content="https://tangled.org" /> 7 7 <meta property="og:description" content="tightly-knit social coding" /> 8 8 {{ end }} 9 9 ··· 12 12 <div class="flex flex-col gap-4"> 13 13 {{ template "timeline/fragments/hero" . }} 14 14 {{ template "features" . }} 15 + {{ template "timeline/fragments/goodfirstissues" . }} 15 16 {{ template "timeline/fragments/trending" . }} 16 17 {{ template "timeline/fragments/timeline" . }} 17 18 <div class="flex justify-end"> ··· 87 88 ) }} 88 89 </div> 89 90 {{ end }} 90 -
+2 -1
appview/pages/templates/timeline/timeline.html
··· 3 3 {{ define "extrameta" }} 4 4 <meta property="og:title" content="timeline · tangled" /> 5 5 <meta property="og:type" content="object" /> 6 - <meta property="og:url" content="https://tangled.sh" /> 6 + <meta property="og:url" content="https://tangled.org" /> 7 7 <meta property="og:description" content="tightly-knit social coding" /> 8 8 {{ end }} 9 9 ··· 13 13 {{ template "timeline/fragments/hero" . }} 14 14 {{ end }} 15 15 16 + {{ template "timeline/fragments/goodfirstissues" . }} 16 17 {{ template "timeline/fragments/trending" . }} 17 18 {{ template "timeline/fragments/timeline" . }} 18 19 {{ end }}
+2 -1
appview/pages/templates/user/completeSignup.html
··· 13 13 /> 14 14 <meta 15 15 property="og:url" 16 - content="https://tangled.sh/complete-signup" 16 + content="https://tangled.org/complete-signup" 17 17 /> 18 18 <meta 19 19 property="og:description" 20 20 content="complete your signup for tangled" 21 21 /> 22 22 <script src="/static/htmx.min.js"></script> 23 + <link rel="manifest" href="/pwa-manifest.json" /> 23 24 <link 24 25 rel="stylesheet" 25 26 href="/static/tw.css?{{ cssContentHash }}"
+8 -1
appview/pages/templates/user/followers.html
··· 10 10 <p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWERS</p> 11 11 <div id="followers" class="grid grid-cols-1 gap-4 mb-6"> 12 12 {{ range .Followers }} 13 - {{ template "user/fragments/followCard" . }} 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) }} 14 21 {{ else }} 15 22 <p class="px-6 dark:text-white">This user does not have any followers yet.</p> 16 23 {{ end }}
+8 -1
appview/pages/templates/user/following.html
··· 10 10 <p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWING</p> 11 11 <div id="following" class="grid grid-cols-1 gap-4 mb-6"> 12 12 {{ range .Following }} 13 - {{ template "user/fragments/followCard" . }} 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) }} 14 21 {{ else }} 15 22 <p class="px-6 dark:text-white">This user does not follow anyone yet.</p> 16 23 {{ end }}
+6 -2
appview/pages/templates/user/fragments/follow.html
··· 1 1 {{ define "user/fragments/follow" }} 2 2 <button id="{{ normalizeForHtmlId .UserDid }}" 3 - class="btn mt-2 w-full flex gap-2 items-center group" 3 + class="btn w-full flex gap-2 items-center group" 4 4 5 5 {{ if eq .FollowStatus.String "IsNotFollowing" }} 6 6 hx-post="/follow?subject={{.UserDid}}" ··· 12 12 hx-target="#{{ normalizeForHtmlId .UserDid }}" 13 13 hx-swap="outerHTML" 14 14 > 15 - {{ if eq .FollowStatus.String "IsNotFollowing" }}Follow{{ else }}Unfollow{{ end }} 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 }} 16 20 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 17 21 </button> 18 22 {{ end }}
+20 -17
appview/pages/templates/user/fragments/followCard.html
··· 1 1 {{ define "user/fragments/followCard" }} 2 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"> 3 + <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 rounded-sm"> 4 4 <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4"> 5 5 <div class="flex-shrink-0 max-h-full w-24 h-24"> 6 6 <img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" /> 7 7 </div> 8 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> 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> 19 23 </div> 20 - </div> 21 - 22 - {{ if ne .FollowStatus.String "IsSelf" }} 23 - <div class="max-w-24"> 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"> 24 26 {{ template "user/fragments/follow" . }} 25 27 </div> 26 - {{ end }} 28 + {{ end }} 29 + </div> 27 30 </div> 28 31 </div> 29 - {{ end }} 32 + {{ end }}
+2 -2
appview/pages/templates/user/fragments/picHandle.html
··· 2 2 <img 3 3 src="{{ tinyAvatar . }}" 4 4 alt="" 5 - class="rounded-full h-6 w-6 mr-1 border border-gray-300 dark:border-gray-700" 5 + class="rounded-full h-6 w-6 border border-gray-300 dark:border-gray-700" 6 6 /> 7 - {{ . | truncateAt30 }} 7 + {{ . | resolve | truncateAt30 }} 8 8 {{ end }}
+2 -3
appview/pages/templates/user/fragments/picHandleLink.html
··· 1 1 {{ define "user/fragments/picHandleLink" }} 2 - {{ $resolved := resolve . }} 3 - <a href="/{{ $resolved }}" class="flex items-center"> 4 - {{ template "user/fragments/picHandle" $resolved }} 2 + <a href="/{{ resolve . }}" class="flex items-center gap-1"> 3 + {{ template "user/fragments/picHandle" . }} 5 4 </a> 6 5 {{ end }}
+27 -13
appview/pages/templates/user/fragments/repoCard.html
··· 2 2 {{ $root := index . 0 }} 3 3 {{ $repo := index . 1 }} 4 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 }} 5 13 6 14 {{ with $repo }} 7 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"> 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" }} 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> 13 34 {{ 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 35 </div> 22 36 {{ with .Description }} 23 37 <div class="text-gray-600 dark:text-gray-300 text-sm line-clamp-2"> ··· 36 50 <div class="text-gray-400 text-sm font-mono inline-flex gap-4 mt-auto"> 37 51 {{ with .Language }} 38 52 <div class="flex gap-2 items-center text-sm"> 39 - {{ template "repo/fragments/languageBall" . }} 53 + {{ template "repo/fragments/colorBall" (dict "color" (langColor .)) }} 40 54 <span>{{ . }}</span> 41 55 </div> 42 56 {{ end }}
+4 -3
appview/pages/templates/user/login.html
··· 5 5 <meta charset="UTF-8" /> 6 6 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 7 7 <meta property="og:title" content="login · tangled" /> 8 - <meta property="og:url" content="https://tangled.sh/login" /> 8 + <meta property="og:url" content="https://tangled.org/login" /> 9 9 <meta property="og:description" content="login to for tangled" /> 10 10 <script src="/static/htmx.min.js"></script> 11 + <link rel="manifest" href="/pwa-manifest.json" /> 11 12 <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 12 13 <title>login &middot; tangled</title> 13 14 </head> 14 15 <body class="flex items-center justify-center min-h-screen"> 15 16 <main class="max-w-md px-6 -mt-4"> 16 - <h1 class="flex place-content-center text-2xl font-semibold italic dark:text-white" > 17 + <h1 class="flex place-content-center text-3xl font-semibold italic dark:text-white" > 17 18 {{ template "fragments/logotype" }} 18 19 </h1> 19 20 <h2 class="text-center text-xl italic dark:text-white"> ··· 36 37 placeholder="akshay.tngl.sh" 37 38 /> 38 39 <span class="text-sm text-gray-500 mt-1"> 39 - Use your <a href="https://atproto.com">ATProto</a> 40 + Use your <a href="https://atproto.com">AT Protocol</a> 40 41 handle to log in. If you're unsure, this is likely 41 42 your Tangled (<code>.tngl.sh</code>) or <a href="https://bsky.app">Bluesky</a> (<code>.bsky.social</code>) account. 42 43 </span>
+1 -1
appview/pages/templates/user/overview.html
··· 73 73 {{ with .Repo.RepoStats }} 74 74 {{ with .Language }} 75 75 <div class="flex gap-2 items-center text-xs font-mono text-gray-400 "> 76 - {{ template "repo/fragments/languageBall" . }} 76 + {{ template "repo/fragments/colorBall" (dict "color" (langColor .)) }} 77 77 <span>{{ . }}</span> 78 78 </div> 79 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 }}
+8 -3
appview/pages/templates/user/signup.html
··· 5 5 <meta charset="UTF-8" /> 6 6 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 7 7 <meta property="og:title" content="signup · tangled" /> 8 - <meta property="og:url" content="https://tangled.sh/signup" /> 8 + <meta property="og:url" content="https://tangled.org/signup" /> 9 9 <meta property="og:description" content="sign up for tangled" /> 10 10 <script src="/static/htmx.min.js"></script> 11 + <link rel="manifest" href="/pwa-manifest.json" /> 11 12 <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 12 13 <title>sign up &middot; tangled</title> 14 + 15 + <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script> 13 16 </head> 14 17 <body class="flex items-center justify-center min-h-screen"> 15 18 <main class="max-w-md px-6 -mt-4"> ··· 39 42 invite code, desired username, and password in the next 40 43 page to complete your registration. 41 44 </span> 45 + <div class="w-full mt-4 text-center"> 46 + <div class="cf-turnstile" data-sitekey="{{ .CloudflareSiteKey }}"></div> 47 + </div> 42 48 <button class="btn text-base w-full my-2 mt-6" type="submit" id="signup-button" tabindex="7" > 43 49 <span>join now</span> 44 50 </button> 45 51 </form> 46 52 <p class="text-sm text-gray-500"> 47 - Already have an ATProto account? <a href="/login" class="underline">Login to Tangled</a>. 53 + Already have an AT Protocol account? <a href="/login" class="underline">Login to Tangled</a>. 48 54 </p> 49 55 50 56 <p id="signup-msg" class="error w-full"></p> ··· 52 58 </body> 53 59 </html> 54 60 {{ end }} 55 -
+1 -1
appview/pagination/page.go
··· 8 8 func FirstPage() Page { 9 9 return Page{ 10 10 Offset: 0, 11 - Limit: 10, 11 + Limit: 30, 12 12 } 13 13 } 14 14
+10 -10
appview/pipelines/pipelines.go
··· 9 9 "strings" 10 10 "time" 11 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" 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 22 23 23 "github.com/go-chi/chi/v5" 24 24 "github.com/gorilla/websocket"
+1 -1
appview/pipelines/router.go
··· 4 4 "net/http" 5 5 6 6 "github.com/go-chi/chi/v5" 7 - "tangled.sh/tangled.sh/core/appview/middleware" 7 + "tangled.org/core/appview/middleware" 8 8 ) 9 9 10 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 12 "strings" 13 13 "time" 14 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" 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" 28 29 29 30 "github.com/bluekeyes/go-gitdiff/gitdiff" 30 31 comatproto "github.com/bluesky-social/indigo/api/atproto" ··· 75 76 return 76 77 } 77 78 78 - pull, ok := r.Context().Value("pull").(*db.Pull) 79 + pull, ok := r.Context().Value("pull").(*models.Pull) 79 80 if !ok { 80 81 log.Println("failed to get pull") 81 82 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") ··· 83 84 } 84 85 85 86 // can be nil if this pull is not stacked 86 - stack, _ := r.Context().Value("stack").(db.Stack) 87 + stack, _ := r.Context().Value("stack").(models.Stack) 87 88 88 89 roundNumberStr := chi.URLParam(r, "round") 89 90 roundNumber, err := strconv.Atoi(roundNumberStr) ··· 123 124 return 124 125 } 125 126 126 - pull, ok := r.Context().Value("pull").(*db.Pull) 127 + pull, ok := r.Context().Value("pull").(*models.Pull) 127 128 if !ok { 128 129 log.Println("failed to get pull") 129 130 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") ··· 131 132 } 132 133 133 134 // 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) 135 + stack, _ := r.Context().Value("stack").(models.Stack) 136 + abandonedPulls, _ := r.Context().Value("abandonedPulls").([]*models.Pull) 136 137 137 138 totalIdents := 1 138 139 for _, submission := range pull.Submissions { ··· 159 160 160 161 repoInfo := f.RepoInfo(user) 161 162 162 - m := make(map[string]db.Pipeline) 163 + m := make(map[string]models.Pipeline) 163 164 164 165 var shas []string 165 166 for _, s := range pull.Submissions { ··· 194 195 s.pages.Notice(w, "pulls", "Failed to load pull. Try again later.") 195 196 } 196 197 197 - userReactions := map[db.ReactionKind]bool{} 198 + userReactions := map[models.ReactionKind]bool{} 198 199 if user != nil { 199 200 userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.PullAt()) 200 201 } 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 + 202 219 s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 203 220 LoggedInUser: user, 204 221 RepoInfo: repoInfo, ··· 209 226 ResubmitCheck: resubmitResult, 210 227 Pipelines: m, 211 228 212 - OrderedReactionKinds: db.OrderedReactionKinds, 229 + OrderedReactionKinds: models.OrderedReactionKinds, 213 230 Reactions: reactionCountMap, 214 231 UserReacted: userReactions, 232 + 233 + LabelDefs: defs, 215 234 }) 216 235 } 217 236 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 { 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 { 220 239 return types.MergeCheckResponse{} 221 240 } 222 241 ··· 282 301 return result 283 302 } 284 303 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 { 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 { 287 306 return pages.Unknown 288 307 } 289 308 ··· 356 375 diffOpts.Split = true 357 376 } 358 377 359 - pull, ok := r.Context().Value("pull").(*db.Pull) 378 + pull, ok := r.Context().Value("pull").(*models.Pull) 360 379 if !ok { 361 380 log.Println("failed to get pull") 362 381 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 363 382 return 364 383 } 365 384 366 - stack, _ := r.Context().Value("stack").(db.Stack) 385 + stack, _ := r.Context().Value("stack").(models.Stack) 367 386 368 387 roundId := chi.URLParam(r, "round") 369 388 roundIdInt, err := strconv.Atoi(roundId) ··· 403 422 diffOpts.Split = true 404 423 } 405 424 406 - pull, ok := r.Context().Value("pull").(*db.Pull) 425 + pull, ok := r.Context().Value("pull").(*models.Pull) 407 426 if !ok { 408 427 log.Println("failed to get pull") 409 428 s.pages.Notice(w, "pull-error", "Failed to get pull.") ··· 451 470 } 452 471 453 472 func (s *Pulls) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) { 454 - pull, ok := r.Context().Value("pull").(*db.Pull) 473 + pull, ok := r.Context().Value("pull").(*models.Pull) 455 474 if !ok { 456 475 log.Println("failed to get pull") 457 476 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") ··· 474 493 user := s.oauth.GetUser(r) 475 494 params := r.URL.Query() 476 495 477 - state := db.PullOpen 496 + state := models.PullOpen 478 497 switch params.Get("state") { 479 498 case "closed": 480 - state = db.PullClosed 499 + state = models.PullClosed 481 500 case "merged": 482 - state = db.PullMerged 501 + state = models.PullMerged 483 502 } 484 503 485 504 f, err := s.repoResolver.Resolve(r) ··· 500 519 } 501 520 502 521 for _, p := range pulls { 503 - var pullSourceRepo *db.Repo 522 + var pullSourceRepo *models.Repo 504 523 if p.PullSource != nil { 505 524 if p.PullSource.RepoAt != nil { 506 525 pullSourceRepo, err = db.GetRepoByAtUri(s.db, p.PullSource.RepoAt.String()) ··· 515 534 } 516 535 517 536 // we want to group all stacked PRs into just one list 518 - stacks := make(map[string]db.Stack) 537 + stacks := make(map[string]models.Stack) 519 538 var shas []string 520 539 n := 0 521 540 for _, p := range pulls { ··· 551 570 log.Printf("failed to fetch pipeline statuses: %s", err) 552 571 // non-fatal 553 572 } 554 - m := make(map[string]db.Pipeline) 573 + m := make(map[string]models.Pipeline) 555 574 for _, p := range ps { 556 575 m[p.Sha] = p 557 576 } 558 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 + 559 594 s.pages.RepoPulls(w, pages.RepoPullsParams{ 560 595 LoggedInUser: s.oauth.GetUser(r), 561 596 RepoInfo: f.RepoInfo(user), 562 597 Pulls: pulls, 598 + LabelDefs: defs, 563 599 FilteringBy: state, 564 600 Stacks: stacks, 565 601 Pipelines: m, ··· 574 610 return 575 611 } 576 612 577 - pull, ok := r.Context().Value("pull").(*db.Pull) 613 + pull, ok := r.Context().Value("pull").(*models.Pull) 578 614 if !ok { 579 615 log.Println("failed to get pull") 580 616 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") ··· 647 683 return 648 684 } 649 685 650 - comment := &db.PullComment{ 686 + comment := &models.PullComment{ 651 687 OwnerDid: user.Did, 652 688 RepoAt: f.RepoAt().String(), 653 689 PullId: pull.PullId, ··· 890 926 return 891 927 } 892 928 893 - pullSource := &db.PullSource{ 929 + pullSource := &models.PullSource{ 894 930 Branch: sourceBranch, 895 931 } 896 932 recordPullSource := &tangled.RepoPull_Source{ ··· 1000 1036 forkAtUri := fork.RepoAt() 1001 1037 forkAtUriStr := forkAtUri.String() 1002 1038 1003 - pullSource := &db.PullSource{ 1039 + pullSource := &models.PullSource{ 1004 1040 Branch: sourceBranch, 1005 1041 RepoAt: &forkAtUri, 1006 1042 } ··· 1021 1057 title, body, targetBranch string, 1022 1058 patch string, 1023 1059 sourceRev string, 1024 - pullSource *db.PullSource, 1060 + pullSource *models.PullSource, 1025 1061 recordPullSource *tangled.RepoPull_Source, 1026 1062 isStacked bool, 1027 1063 ) { ··· 1057 1093 1058 1094 // We've already checked earlier if it's diff-based and title is empty, 1059 1095 // so if it's still empty now, it's intentionally skipped owing to format-patch. 1060 - if title == "" { 1096 + if title == "" || body == "" { 1061 1097 formatPatches, err := patchutil.ExtractPatches(patch) 1062 1098 if err != nil { 1063 1099 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err)) ··· 1068 1104 return 1069 1105 } 1070 1106 1071 - title = formatPatches[0].Title 1072 - body = formatPatches[0].Body 1107 + if title == "" { 1108 + title = formatPatches[0].Title 1109 + } 1110 + if body == "" { 1111 + body = formatPatches[0].Body 1112 + } 1073 1113 } 1074 1114 1075 1115 rkey := tid.TID() 1076 - initialSubmission := db.PullSubmission{ 1116 + initialSubmission := models.PullSubmission{ 1077 1117 Patch: patch, 1078 1118 SourceRev: sourceRev, 1079 1119 } 1080 - pull := &db.Pull{ 1120 + pull := &models.Pull{ 1081 1121 Title: title, 1082 1122 Body: body, 1083 1123 TargetBranch: targetBranch, 1084 1124 OwnerDid: user.Did, 1085 1125 RepoAt: f.RepoAt(), 1086 1126 Rkey: rkey, 1087 - Submissions: []*db.PullSubmission{ 1127 + Submissions: []*models.PullSubmission{ 1088 1128 &initialSubmission, 1089 1129 }, 1090 1130 PullSource: pullSource, ··· 1143 1183 targetBranch string, 1144 1184 patch string, 1145 1185 sourceRev string, 1146 - pullSource *db.PullSource, 1186 + pullSource *models.PullSource, 1147 1187 ) { 1148 1188 // run some necessary checks for stacked-prs first 1149 1189 ··· 1364 1404 forkOwnerDid := repoString[0] 1365 1405 forkName := repoString[1] 1366 1406 // fork repo 1367 - repo, err := db.GetRepo(s.db, forkOwnerDid, forkName) 1407 + repo, err := db.GetRepo( 1408 + s.db, 1409 + db.FilterEq("did", forkOwnerDid), 1410 + db.FilterEq("name", forkName), 1411 + ) 1368 1412 if err != nil { 1369 - log.Println("failed to get repo", user.Did, forkVal) 1413 + log.Println("failed to get repo", "did", forkOwnerDid, "name", forkName, "err", err) 1370 1414 return 1371 1415 } 1372 1416 ··· 1447 1491 return 1448 1492 } 1449 1493 1450 - pull, ok := r.Context().Value("pull").(*db.Pull) 1494 + pull, ok := r.Context().Value("pull").(*models.Pull) 1451 1495 if !ok { 1452 1496 log.Println("failed to get pull") 1453 1497 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") ··· 1478 1522 func (s *Pulls) resubmitPatch(w http.ResponseWriter, r *http.Request) { 1479 1523 user := s.oauth.GetUser(r) 1480 1524 1481 - pull, ok := r.Context().Value("pull").(*db.Pull) 1525 + pull, ok := r.Context().Value("pull").(*models.Pull) 1482 1526 if !ok { 1483 1527 log.Println("failed to get pull") 1484 1528 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") ··· 1505 1549 func (s *Pulls) resubmitBranch(w http.ResponseWriter, r *http.Request) { 1506 1550 user := s.oauth.GetUser(r) 1507 1551 1508 - pull, ok := r.Context().Value("pull").(*db.Pull) 1552 + pull, ok := r.Context().Value("pull").(*models.Pull) 1509 1553 if !ok { 1510 1554 log.Println("failed to get pull") 1511 1555 s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.") ··· 1568 1612 func (s *Pulls) resubmitFork(w http.ResponseWriter, r *http.Request) { 1569 1613 user := s.oauth.GetUser(r) 1570 1614 1571 - pull, ok := r.Context().Value("pull").(*db.Pull) 1615 + pull, ok := r.Context().Value("pull").(*models.Pull) 1572 1616 if !ok { 1573 1617 log.Println("failed to get pull") 1574 1618 s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.") ··· 1661 1705 } 1662 1706 1663 1707 // validate a resubmission against a pull request 1664 - func validateResubmittedPatch(pull *db.Pull, patch string) error { 1708 + func validateResubmittedPatch(pull *models.Pull, patch string) error { 1665 1709 if patch == "" { 1666 1710 return fmt.Errorf("Patch is empty.") 1667 1711 } ··· 1682 1726 r *http.Request, 1683 1727 f *reporesolver.ResolvedRepo, 1684 1728 user *oauth.User, 1685 - pull *db.Pull, 1729 + pull *models.Pull, 1686 1730 patch string, 1687 1731 sourceRev string, 1688 1732 ) { ··· 1786 1830 r *http.Request, 1787 1831 f *reporesolver.ResolvedRepo, 1788 1832 user *oauth.User, 1789 - pull *db.Pull, 1833 + pull *models.Pull, 1790 1834 patch string, 1791 1835 stackId string, 1792 1836 ) { 1793 1837 targetBranch := pull.TargetBranch 1794 1838 1795 - origStack, _ := r.Context().Value("stack").(db.Stack) 1839 + origStack, _ := r.Context().Value("stack").(models.Stack) 1796 1840 newStack, err := newStack(f, user, targetBranch, patch, pull.PullSource, stackId) 1797 1841 if err != nil { 1798 1842 log.Println("failed to create resubmitted stack", err) ··· 1801 1845 } 1802 1846 1803 1847 // 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) 1848 + origById := make(map[string]*models.Pull) 1849 + newById := make(map[string]*models.Pull) 1806 1850 for _, p := range origStack { 1807 1851 origById[p.ChangeId] = p 1808 1852 } ··· 1815 1859 // commits that got updated: corresponding pull is resubmitted & new round begins 1816 1860 // 1817 1861 // 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) 1862 + additions := make(map[string]*models.Pull) 1863 + deletions := make(map[string]*models.Pull) 1820 1864 unchanged := make(map[string]struct{}) 1821 1865 updated := make(map[string]struct{}) 1822 1866 ··· 1876 1920 // deleted pulls are marked as deleted in the DB 1877 1921 for _, p := range deletions { 1878 1922 // do not do delete already merged PRs 1879 - if p.State == db.PullMerged { 1923 + if p.State == models.PullMerged { 1880 1924 continue 1881 1925 } 1882 1926 ··· 1921 1965 np, _ := newById[id] 1922 1966 1923 1967 // do not update already merged PRs 1924 - if op.State == db.PullMerged { 1968 + if op.State == models.PullMerged { 1925 1969 continue 1926 1970 } 1927 1971 ··· 2042 2086 return 2043 2087 } 2044 2088 2045 - pull, ok := r.Context().Value("pull").(*db.Pull) 2089 + pull, ok := r.Context().Value("pull").(*models.Pull) 2046 2090 if !ok { 2047 2091 log.Println("failed to get pull") 2048 2092 s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.") 2049 2093 return 2050 2094 } 2051 2095 2052 - var pullsToMerge db.Stack 2096 + var pullsToMerge models.Stack 2053 2097 pullsToMerge = append(pullsToMerge, pull) 2054 2098 if pull.IsStacked() { 2055 - stack, ok := r.Context().Value("stack").(db.Stack) 2099 + stack, ok := r.Context().Value("stack").(models.Stack) 2056 2100 if !ok { 2057 2101 log.Println("failed to get stack") 2058 2102 s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.") ··· 2142 2186 return 2143 2187 } 2144 2188 2189 + // notify about the pull merge 2190 + for _, p := range pullsToMerge { 2191 + s.notifier.NewPullMerged(r.Context(), p) 2192 + } 2193 + 2145 2194 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.Name, pull.PullId)) 2146 2195 } 2147 2196 ··· 2154 2203 return 2155 2204 } 2156 2205 2157 - pull, ok := r.Context().Value("pull").(*db.Pull) 2206 + pull, ok := r.Context().Value("pull").(*models.Pull) 2158 2207 if !ok { 2159 2208 log.Println("failed to get pull") 2160 2209 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") ··· 2182 2231 } 2183 2232 defer tx.Rollback() 2184 2233 2185 - var pullsToClose []*db.Pull 2234 + var pullsToClose []*models.Pull 2186 2235 pullsToClose = append(pullsToClose, pull) 2187 2236 2188 2237 // if this PR is stacked, then we want to close all PRs below this one on the stack 2189 2238 if pull.IsStacked() { 2190 - stack := r.Context().Value("stack").(db.Stack) 2239 + stack := r.Context().Value("stack").(models.Stack) 2191 2240 subStack := stack.StrictlyBelow(pull) 2192 2241 pullsToClose = append(pullsToClose, subStack...) 2193 2242 } ··· 2209 2258 return 2210 2259 } 2211 2260 2261 + for _, p := range pullsToClose { 2262 + s.notifier.NewPullClosed(r.Context(), p) 2263 + } 2264 + 2212 2265 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 2213 2266 } 2214 2267 ··· 2222 2275 return 2223 2276 } 2224 2277 2225 - pull, ok := r.Context().Value("pull").(*db.Pull) 2278 + pull, ok := r.Context().Value("pull").(*models.Pull) 2226 2279 if !ok { 2227 2280 log.Println("failed to get pull") 2228 2281 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") ··· 2250 2303 } 2251 2304 defer tx.Rollback() 2252 2305 2253 - var pullsToReopen []*db.Pull 2306 + var pullsToReopen []*models.Pull 2254 2307 pullsToReopen = append(pullsToReopen, pull) 2255 2308 2256 2309 // if this PR is stacked, then we want to reopen all PRs above this one on the stack 2257 2310 if pull.IsStacked() { 2258 - stack := r.Context().Value("stack").(db.Stack) 2311 + stack := r.Context().Value("stack").(models.Stack) 2259 2312 subStack := stack.StrictlyAbove(pull) 2260 2313 pullsToReopen = append(pullsToReopen, subStack...) 2261 2314 } ··· 2280 2333 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 2281 2334 } 2282 2335 2283 - func newStack(f *reporesolver.ResolvedRepo, user *oauth.User, targetBranch, patch string, pullSource *db.PullSource, stackId string) (db.Stack, error) { 2336 + func newStack(f *reporesolver.ResolvedRepo, user *oauth.User, targetBranch, patch string, pullSource *models.PullSource, stackId string) (models.Stack, error) { 2284 2337 formatPatches, err := patchutil.ExtractPatches(patch) 2285 2338 if err != nil { 2286 2339 return nil, fmt.Errorf("Failed to extract patches: %v", err) ··· 2292 2345 } 2293 2346 2294 2347 // the stack is identified by a UUID 2295 - var stack db.Stack 2348 + var stack models.Stack 2296 2349 parentChangeId := "" 2297 2350 for _, fp := range formatPatches { 2298 2351 // all patches must have a jj change-id ··· 2305 2358 body := fp.Body 2306 2359 rkey := tid.TID() 2307 2360 2308 - initialSubmission := db.PullSubmission{ 2361 + initialSubmission := models.PullSubmission{ 2309 2362 Patch: fp.Raw, 2310 2363 SourceRev: fp.SHA, 2311 2364 } 2312 - pull := db.Pull{ 2365 + pull := models.Pull{ 2313 2366 Title: title, 2314 2367 Body: body, 2315 2368 TargetBranch: targetBranch, 2316 2369 OwnerDid: user.Did, 2317 2370 RepoAt: f.RepoAt(), 2318 2371 Rkey: rkey, 2319 - Submissions: []*db.PullSubmission{ 2372 + Submissions: []*models.PullSubmission{ 2320 2373 &initialSubmission, 2321 2374 }, 2322 2375 PullSource: pullSource,
+1 -1
appview/pulls/router.go
··· 4 4 "net/http" 5 5 6 6 "github.com/go-chi/chi/v5" 7 - "tangled.sh/tangled.sh/core/appview/middleware" 7 + "tangled.org/core/appview/middleware" 8 8 ) 9 9 10 10 func (s *Pulls) Router(mw *middleware.Middleware) http.Handler {
+49 -22
appview/repo/artifact.go
··· 4 4 "context" 5 5 "encoding/json" 6 6 "fmt" 7 + "io" 7 8 "log" 8 9 "net/http" 9 10 "net/url" ··· 16 17 "github.com/go-chi/chi/v5" 17 18 "github.com/go-git/go-git/v5/plumbing" 18 19 "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" 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" 26 28 ) 27 29 28 30 // TODO: proper statuses here on early exit ··· 100 102 } 101 103 defer tx.Rollback() 102 104 103 - artifact := db.Artifact{ 105 + artifact := models.Artifact{ 104 106 Did: user.Did, 105 107 Rkey: rkey, 106 108 RepoAt: f.RepoAt(), ··· 133 135 }) 134 136 } 135 137 136 - // TODO: proper statuses here on early exit 137 138 func (rp *Repo) DownloadArtifact(w http.ResponseWriter, r *http.Request) { 138 - tagParam := chi.URLParam(r, "tag") 139 - filename := chi.URLParam(r, "file") 140 139 f, err := rp.repoResolver.Resolve(r) 141 140 if err != nil { 142 141 log.Println("failed to get repo and knot", err) 142 + http.Error(w, "failed to resolve repo", http.StatusInternalServerError) 143 143 return 144 144 } 145 145 146 + tagParam := chi.URLParam(r, "tag") 147 + filename := chi.URLParam(r, "file") 148 + 146 149 tag, err := rp.resolveTag(r.Context(), f, tagParam) 147 150 if err != nil { 148 151 log.Println("failed to resolve tag", err) ··· 150 153 return 151 154 } 152 155 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 156 artifacts, err := db.GetArtifact( 160 157 rp.db, 161 158 db.FilterEq("repo_at", f.RepoAt()), ··· 164 161 ) 165 162 if err != nil { 166 163 log.Println("failed to get artifacts", err) 164 + http.Error(w, "failed to get artifact", http.StatusInternalServerError) 167 165 return 168 166 } 167 + 169 168 if len(artifacts) != 1 { 170 - log.Printf("too many or too little artifacts found") 169 + log.Printf("too many or too few artifacts found") 170 + http.Error(w, "artifact not found", http.StatusNotFound) 171 171 return 172 172 } 173 173 174 174 artifact := artifacts[0] 175 175 176 - getBlobResp, err := client.SyncGetBlob(r.Context(), artifact.BlobCid.String(), artifact.Did) 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) 177 184 if err != nil { 178 - log.Println("failed to get blob from pds", err) 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) 179 195 return 180 196 } 197 + defer resp.Body.Close() 181 198 182 - w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename)) 183 - w.Write(getBlobResp) 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 + } 184 211 } 185 212 186 213 // TODO: proper statuses here on early exit
+10 -9
appview/repo/feed.go
··· 8 8 "slices" 9 9 "time" 10 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" 11 + "tangled.org/core/appview/db" 12 + "tangled.org/core/appview/models" 13 + "tangled.org/core/appview/pagination" 14 + "tangled.org/core/appview/reporesolver" 14 15 15 16 "github.com/bluesky-social/indigo/atproto/syntax" 16 17 "github.com/gorilla/feeds" ··· 70 71 return feed, nil 71 72 } 72 73 73 - func (rp *Repo) createPullItems(ctx context.Context, pull *db.Pull, f *reporesolver.ResolvedRepo) ([]*feeds.Item, error) { 74 + func (rp *Repo) createPullItems(ctx context.Context, pull *models.Pull, f *reporesolver.ResolvedRepo) ([]*feeds.Item, error) { 74 75 owner, err := rp.idResolver.ResolveIdent(ctx, pull.OwnerDid) 75 76 if err != nil { 76 77 return nil, err ··· 108 109 return items, nil 109 110 } 110 111 111 - func (rp *Repo) createIssueItem(ctx context.Context, issue db.Issue, f *reporesolver.ResolvedRepo) (*feeds.Item, error) { 112 + func (rp *Repo) createIssueItem(ctx context.Context, issue models.Issue, f *reporesolver.ResolvedRepo) (*feeds.Item, error) { 112 113 owner, err := rp.idResolver.ResolveIdent(ctx, issue.Did) 113 114 if err != nil { 114 115 return nil, err ··· 128 129 }, nil 129 130 } 130 131 131 - func (rp *Repo) getPullState(pull *db.Pull) string { 132 - if pull.State == db.PullOpen { 132 + func (rp *Repo) getPullState(pull *models.Pull) string { 133 + if pull.State == models.PullOpen { 133 134 return "opened" 134 135 } 135 136 return pull.State.String() 136 137 } 137 138 138 - func (rp *Repo) buildPullDescription(handle syntax.Handle, state string, pull *db.Pull, repoName string) string { 139 + func (rp *Repo) buildPullDescription(handle syntax.Handle, state string, pull *models.Pull, repoName string) string { 139 140 base := fmt.Sprintf("@%s %s pull request #%d", handle, state, pull.PullId) 140 141 141 - if pull.State == db.PullMerged { 142 + if pull.State == models.PullMerged { 142 143 return fmt.Sprintf("%s (on round #%d) in %s", base, pull.LastRoundNumber(), repoName) 143 144 } 144 145
+26 -30
appview/repo/index.go
··· 17 17 18 18 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 19 19 "github.com/go-git/go-git/v5/plumbing" 20 - "tangled.sh/tangled.sh/core/api/tangled" 21 - "tangled.sh/tangled.sh/core/appview/commitverify" 22 - "tangled.sh/tangled.sh/core/appview/db" 23 - "tangled.sh/tangled.sh/core/appview/pages" 24 - "tangled.sh/tangled.sh/core/appview/pages/markup" 25 - "tangled.sh/tangled.sh/core/appview/reporesolver" 26 - "tangled.sh/tangled.sh/core/appview/xrpcclient" 27 - "tangled.sh/tangled.sh/core/types" 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 28 29 29 "github.com/go-chi/chi/v5" 30 30 "github.com/go-enry/go-enry/v2" ··· 191 191 } 192 192 193 193 for _, lang := range ls.Languages { 194 - langs = append(langs, db.RepoLanguage{ 194 + langs = append(langs, models.RepoLanguage{ 195 195 RepoAt: f.RepoAt(), 196 196 Ref: currentRef, 197 197 IsDefaultRef: isDefaultRef, ··· 200 200 }) 201 201 } 202 202 203 + tx, err := rp.db.Begin() 204 + if err != nil { 205 + return nil, err 206 + } 207 + defer tx.Rollback() 208 + 203 209 // update appview's cache 204 - err = db.InsertRepoLanguages(rp.db, langs) 210 + err = db.UpdateRepoLanguages(tx, f.RepoAt(), currentRef, langs) 205 211 if err != nil { 206 212 // non-fatal 207 213 log.Println("failed to cache lang results", err) 208 214 } 215 + 216 + err = tx.Commit() 217 + if err != nil { 218 + return nil, err 219 + } 209 220 } 210 221 211 222 var total int64 ··· 327 338 } 328 339 }() 329 340 330 - // readme content 331 - wg.Add(1) 332 - go func() { 333 - defer wg.Done() 334 - for _, filename := range markup.ReadmeFilenames { 335 - blobResp, err := tangled.RepoBlob(ctx, xrpcc, filename, false, ref, repo) 336 - if err != nil { 337 - continue 338 - } 339 - 340 - if blobResp == nil { 341 - continue 342 - } 343 - 344 - readmeContent = blobResp.Content 345 - readmeFileName = filename 346 - break 347 - } 348 - }() 349 - 350 341 wg.Wait() 351 342 352 343 if errs != nil { ··· 373 364 } 374 365 files = append(files, niceFile) 375 366 } 367 + } 368 + 369 + if treeResp != nil && treeResp.Readme != nil { 370 + readmeFileName = treeResp.Readme.Filename 371 + readmeContent = treeResp.Readme.Contents 376 372 } 377 373 378 374 result := &types.RepoIndexResponse{
+656 -78
appview/repo/repo.go
··· 20 20 comatproto "github.com/bluesky-social/indigo/api/atproto" 21 21 lexutil "github.com/bluesky-social/indigo/lex/util" 22 22 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 23 - "tangled.sh/tangled.sh/core/api/tangled" 24 - "tangled.sh/tangled.sh/core/appview/commitverify" 25 - "tangled.sh/tangled.sh/core/appview/config" 26 - "tangled.sh/tangled.sh/core/appview/db" 27 - "tangled.sh/tangled.sh/core/appview/notify" 28 - "tangled.sh/tangled.sh/core/appview/oauth" 29 - "tangled.sh/tangled.sh/core/appview/pages" 30 - "tangled.sh/tangled.sh/core/appview/pages/markup" 31 - "tangled.sh/tangled.sh/core/appview/reporesolver" 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/patchutil" 36 - "tangled.sh/tangled.sh/core/rbac" 37 - "tangled.sh/tangled.sh/core/tid" 38 - "tangled.sh/tangled.sh/core/types" 39 - "tangled.sh/tangled.sh/core/xrpc/serviceauth" 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" 40 42 41 43 securejoin "github.com/cyphar/filepath-securejoin" 42 44 "github.com/go-chi/chi/v5" ··· 57 59 notifier notify.Notifier 58 60 logger *slog.Logger 59 61 serviceAuth *serviceauth.ServiceAuth 62 + validator *validator.Validator 60 63 } 61 64 62 65 func New( ··· 70 73 notifier notify.Notifier, 71 74 enforcer *rbac.Enforcer, 72 75 logger *slog.Logger, 76 + validator *validator.Validator, 73 77 ) *Repo { 74 78 return &Repo{oauth: oauth, 75 79 repoResolver: repoResolver, ··· 81 85 notifier: notifier, 82 86 enforcer: enforcer, 83 87 logger: logger, 88 + validator: validator, 84 89 } 85 90 } 86 91 ··· 295 300 return 296 301 } 297 302 303 + newRepo := f.Repo 304 + newRepo.Description = newDescription 305 + record := newRepo.AsRecord() 306 + 298 307 // this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field 299 308 // 300 309 // SwapRecord is optional and should happen automagically, but given that it does not, we have to perform two requests 301 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, user.Did, rkey) 310 + ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 302 311 if err != nil { 303 312 // failed to get record 304 313 rp.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.") ··· 306 315 } 307 316 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 308 317 Collection: tangled.RepoNSID, 309 - Repo: user.Did, 310 - Rkey: rkey, 318 + Repo: newRepo.Did, 319 + Rkey: newRepo.Rkey, 311 320 SwapRecord: ex.Cid, 312 321 Record: &lexutil.LexiconTypeDecoder{ 313 - Val: &tangled.Repo{ 314 - Knot: f.Knot, 315 - Name: f.Name, 316 - Owner: user.Did, 317 - CreatedAt: f.Created.Format(time.RFC3339), 318 - Description: &newDescription, 319 - Spindle: &f.Spindle, 320 - }, 322 + Val: &record, 321 323 }, 322 324 }) 323 325 ··· 398 400 log.Println(err) 399 401 // non-fatal 400 402 } 401 - var pipeline *db.Pipeline 403 + var pipeline *models.Pipeline 402 404 if p, ok := pipelines[result.Diff.Commit.This]; ok { 403 405 pipeline = &p 404 406 } ··· 482 484 if xrpcResp.Dotdot != nil { 483 485 result.DotDot = *xrpcResp.Dotdot 484 486 } 487 + if xrpcResp.Readme != nil { 488 + result.ReadmeFileName = xrpcResp.Readme.Filename 489 + result.Readme = xrpcResp.Readme.Contents 490 + } 485 491 486 492 // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated, 487 493 // so we can safely redirect to the "parent" (which is the same file). ··· 550 556 } 551 557 552 558 // convert artifacts to map for easy UI building 553 - artifactMap := make(map[plumbing.Hash][]db.Artifact) 559 + artifactMap := make(map[plumbing.Hash][]models.Artifact) 554 560 for _, a := range artifacts { 555 561 artifactMap[a.Tag] = append(artifactMap[a.Tag], a) 556 562 } 557 563 558 - var danglingArtifacts []db.Artifact 564 + var danglingArtifacts []models.Artifact 559 565 for _, a := range artifacts { 560 566 found := false 561 567 for _, t := range result.Tags { ··· 871 877 return 872 878 } 873 879 874 - repoAt := f.RepoAt() 875 - rkey := repoAt.RecordKey().String() 876 - if rkey == "" { 877 - fail("Failed to resolve repo. Try again later", err) 878 - return 879 - } 880 - 881 880 newSpindle := r.FormValue("spindle") 882 881 removingSpindle := newSpindle == "[[none]]" // see pages/templates/repo/settings/pipelines.html for more info on why we use this value 883 882 client, err := rp.oauth.AuthorizedClient(r) ··· 900 899 } 901 900 } 902 901 902 + newRepo := f.Repo 903 + newRepo.Spindle = newSpindle 904 + record := newRepo.AsRecord() 905 + 903 906 spindlePtr := &newSpindle 904 907 if removingSpindle { 905 908 spindlePtr = nil 909 + newRepo.Spindle = "" 906 910 } 907 911 908 912 // optimistic update 909 - err = db.UpdateSpindle(rp.db, string(repoAt), spindlePtr) 913 + err = db.UpdateSpindle(rp.db, newRepo.RepoAt().String(), spindlePtr) 910 914 if err != nil { 911 915 fail("Failed to update spindle. Try again later.", err) 912 916 return 913 917 } 914 918 915 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, user.Did, rkey) 919 + ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 916 920 if err != nil { 917 921 fail("Failed to update spindle, no record found on PDS.", err) 918 922 return 919 923 } 920 924 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 921 925 Collection: tangled.RepoNSID, 922 - Repo: user.Did, 923 - Rkey: rkey, 926 + Repo: newRepo.Did, 927 + Rkey: newRepo.Rkey, 924 928 SwapRecord: ex.Cid, 925 929 Record: &lexutil.LexiconTypeDecoder{ 926 - Val: &tangled.Repo{ 927 - Knot: f.Knot, 928 - Name: f.Name, 929 - Owner: user.Did, 930 - CreatedAt: f.Created.Format(time.RFC3339), 931 - Description: &f.Description, 932 - Spindle: spindlePtr, 933 - }, 930 + Val: &record, 934 931 }, 935 932 }) 936 933 ··· 950 947 rp.pages.HxRefresh(w) 951 948 } 952 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 + 953 1478 func (rp *Repo) AddCollaborator(w http.ResponseWriter, r *http.Request) { 954 1479 user := rp.oauth.GetUser(r) 955 1480 l := rp.logger.With("handler", "AddCollaborator") ··· 1051 1576 return 1052 1577 } 1053 1578 1054 - err = db.AddCollaborator(rp.db, db.Collaborator{ 1579 + err = db.AddCollaborator(tx, models.Collaborator{ 1055 1580 Did: syntax.DID(currentUser.Did), 1056 1581 Rkey: rkey, 1057 1582 SubjectDid: collaboratorIdent.DID, ··· 1379 1904 return 1380 1905 } 1381 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 + 1382 1950 rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{ 1383 - LoggedInUser: user, 1384 - RepoInfo: f.RepoInfo(user), 1385 - Branches: result.Branches, 1386 - Tabs: settingsTabs, 1387 - Tab: "general", 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", 1388 1960 }) 1389 1961 } 1390 1962 ··· 1557 2129 } 1558 2130 1559 2131 // choose a name for a fork 1560 - forkName := f.Name 2132 + forkName := r.FormValue("repo_name") 2133 + if forkName == "" { 2134 + rp.pages.Notice(w, "repo", "Repository name cannot be empty.") 2135 + return 2136 + } 2137 + 1561 2138 // this check is *only* to see if the forked repo name already exists 1562 2139 // in the user's account. 1563 - existingRepo, err := db.GetRepo(rp.db, user.Did, f.Name) 2140 + existingRepo, err := db.GetRepo( 2141 + rp.db, 2142 + db.FilterEq("did", user.Did), 2143 + db.FilterEq("name", forkName), 2144 + ) 1564 2145 if err != nil { 1565 - if errors.Is(err, sql.ErrNoRows) { 1566 - // no existing repo with this name found, we can use the name as is 1567 - } else { 1568 - log.Println("error fetching existing repo from db", err) 2146 + if !errors.Is(err, sql.ErrNoRows) { 2147 + log.Println("error fetching existing repo from db", "err", err) 1569 2148 rp.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.") 1570 2149 return 1571 2150 } 1572 2151 } else if existingRepo != nil { 1573 - // repo with this name already exists, append random string 1574 - forkName = fmt.Sprintf("%s-%s", forkName, randomString(3)) 2152 + // repo with this name already exists 2153 + rp.pages.Notice(w, "repo", "A repository with this name already exists.") 2154 + return 1575 2155 } 1576 2156 l = l.With("forkName", forkName) 1577 2157 ··· 1587 2167 1588 2168 // create an atproto record for this fork 1589 2169 rkey := tid.TID() 1590 - repo := &db.Repo{ 1591 - Did: user.Did, 1592 - Name: forkName, 1593 - Knot: targetKnot, 1594 - Rkey: rkey, 1595 - Source: sourceAt, 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(), 1596 2179 } 2180 + record := repo.AsRecord() 1597 2181 1598 2182 xrpcClient, err := rp.oauth.AuthorizedClient(r) 1599 2183 if err != nil { ··· 1602 2186 return 1603 2187 } 1604 2188 1605 - createdAt := time.Now().Format(time.RFC3339) 1606 2189 atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1607 2190 Collection: tangled.RepoNSID, 1608 2191 Repo: user.Did, 1609 2192 Rkey: rkey, 1610 2193 Record: &lexutil.LexiconTypeDecoder{ 1611 - Val: &tangled.Repo{ 1612 - Knot: repo.Knot, 1613 - Name: repo.Name, 1614 - CreatedAt: createdAt, 1615 - Owner: user.Did, 1616 - Source: &sourceAt, 1617 - }}, 2194 + Val: &record, 2195 + }, 1618 2196 }) 1619 2197 if err != nil { 1620 2198 l.Error("failed to write to PDS", "err", err)
+6 -5
appview/repo/repo_util.go
··· 9 9 "sort" 10 10 "strings" 11 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" 12 + "tangled.org/core/appview/db" 13 + "tangled.org/core/appview/models" 14 + "tangled.org/core/appview/pages/repoinfo" 15 + "tangled.org/core/types" 15 16 16 17 "github.com/go-git/go-git/v5/plumbing/object" 17 18 ) ··· 143 144 d *db.DB, 144 145 repoInfo repoinfo.RepoInfo, 145 146 shas []string, 146 - ) (map[string]db.Pipeline, error) { 147 - m := make(map[string]db.Pipeline) 147 + ) (map[string]models.Pipeline, error) { 148 + m := make(map[string]models.Pipeline) 148 149 149 150 if len(shas) == 0 { 150 151 return m, nil
+13 -4
appview/repo/router.go
··· 4 4 "net/http" 5 5 6 6 "github.com/go-chi/chi/v5" 7 - "tangled.sh/tangled.sh/core/appview/middleware" 7 + "tangled.org/core/appview/middleware" 8 8 ) 9 9 10 10 func (rp *Repo) Router(mw *middleware.Middleware) http.Handler { ··· 21 21 r.Route("/tags", func(r chi.Router) { 22 22 r.Get("/", rp.RepoTags) 23 23 r.Route("/{tag}", func(r chi.Router) { 24 - r.Use(middleware.AuthMiddleware(rp.oauth)) 25 - // require auth to download for now 26 24 r.Get("/download/{file}", rp.DownloadArtifact) 27 25 28 26 // require repo:push to upload or delete artifacts ··· 30 28 // additionally: only the uploader can truly delete an artifact 31 29 // (record+blob will live on their pds) 32 30 r.Group(func(r chi.Router) { 33 - r.With(mw.RepoPermissionMiddleware("repo:push")) 31 + r.Use(middleware.AuthMiddleware(rp.oauth)) 32 + r.Use(mw.RepoPermissionMiddleware("repo:push")) 34 33 r.Post("/upload", rp.AttachArtifact) 35 34 r.Delete("/{file}", rp.DeleteArtifact) 36 35 }) ··· 64 63 r.Get("/*", rp.RepoCompare) 65 64 }) 66 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 + 67 72 // settings routes, needs auth 68 73 r.Group(func(r chi.Router) { 69 74 r.Use(middleware.AuthMiddleware(rp.oauth)) ··· 76 81 r.With(mw.RepoPermissionMiddleware("repo:settings")).Route("/settings", func(r chi.Router) { 77 82 r.Get("/", rp.RepoSettings) 78 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) 79 88 r.With(mw.RepoPermissionMiddleware("repo:invite")).Put("/collaborator", rp.AddCollaborator) 80 89 r.With(mw.RepoPermissionMiddleware("repo:delete")).Delete("/delete", rp.DeleteRepo) 81 90 r.Put("/branches/default", rp.SetDefaultBranch)
+14 -12
appview/reporesolver/resolver.go
··· 14 14 "github.com/bluesky-social/indigo/atproto/identity" 15 15 securejoin "github.com/cyphar/filepath-securejoin" 16 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" 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" 24 25 ) 25 26 26 27 type ResolvedRepo struct { 27 - db.Repo 28 + models.Repo 28 29 OwnerId identity.Identity 29 30 CurrentDir string 30 31 Ref string ··· 44 45 } 45 46 46 47 func (rr *RepoResolver) Resolve(r *http.Request) (*ResolvedRepo, error) { 47 - repo, ok := r.Context().Value("repo").(*db.Repo) 48 + repo, ok := r.Context().Value("repo").(*models.Repo) 48 49 if !ok { 49 50 log.Println("malformed middleware: `repo` not exist in context") 50 51 return nil, fmt.Errorf("malformed middleware") ··· 162 163 log.Println("failed to get repo source for ", repoAt, err) 163 164 } 164 165 165 - var sourceRepo *db.Repo 166 + var sourceRepo *models.Repo 166 167 if source != "" { 167 168 sourceRepo, err = db.GetRepoByAtUri(f.rr.execer, source) 168 169 if err != nil { ··· 184 185 OwnerDid: f.OwnerDid(), 185 186 OwnerHandle: f.OwnerHandle(), 186 187 Name: f.Name, 188 + Rkey: f.Repo.Rkey, 187 189 RepoAt: repoAt, 188 190 Description: f.Description, 189 191 IsStarred: isStarred, 190 192 Knot: knot, 191 193 Spindle: f.Spindle, 192 194 Roles: f.RolesInRepo(user), 193 - Stats: db.RepoStats{ 195 + Stats: models.RepoStats{ 194 196 StarCount: starCount, 195 197 IssueCount: issueCount, 196 198 PullCount: pullCount, ··· 210 212 func (f *ResolvedRepo) RolesInRepo(u *oauth.User) repoinfo.RolesInRepo { 211 213 if u != nil { 212 214 r := f.rr.enforcer.GetPermissionsInRepo(u.Did, f.Knot, f.DidSlashRepo()) 213 - return repoinfo.RolesInRepo{r} 215 + return repoinfo.RolesInRepo{Roles: r} 214 216 } else { 215 217 return repoinfo.RolesInRepo{} 216 218 }
+4 -4
appview/serververify/verify.go
··· 6 6 "fmt" 7 7 8 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" 9 + "tangled.org/core/api/tangled" 10 + "tangled.org/core/appview/db" 11 + "tangled.org/core/appview/xrpcclient" 12 + "tangled.org/core/rbac" 13 13 ) 14 14 15 15 var (
+62 -10
appview/settings/settings.go
··· 11 11 "time" 12 12 13 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" 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" 22 23 23 24 comatproto "github.com/bluesky-social/indigo/api/atproto" 24 25 lexutil "github.com/bluesky-social/indigo/lex/util" ··· 40 41 {"Name": "profile", "Icon": "user"}, 41 42 {"Name": "keys", "Icon": "key"}, 42 43 {"Name": "emails", "Icon": "mail"}, 44 + {"Name": "notifications", "Icon": "bell"}, 43 45 } 44 46 ) 45 47 ··· 67 69 r.Post("/primary", s.emailsPrimary) 68 70 }) 69 71 72 + r.Route("/notifications", func(r chi.Router) { 73 + r.Get("/", s.notificationsSettings) 74 + r.Put("/", s.updateNotificationPreferences) 75 + }) 76 + 70 77 return r 71 78 } 72 79 ··· 80 87 }) 81 88 } 82 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 + 83 135 func (s *Settings) keysSettings(w http.ResponseWriter, r *http.Request) { 84 136 user := s.OAuth.GetUser(r) 85 137 pubKeys, err := db.GetPublicKeysForDid(s.Db, user.Did) ··· 185 237 } 186 238 defer tx.Rollback() 187 239 188 - if err := db.AddEmail(tx, db.Email{ 240 + if err := db.AddEmail(tx, models.Email{ 189 241 Did: did, 190 242 Address: emAddr, 191 243 Verified: false, ··· 246 298 if s.Config.Core.Dev { 247 299 appUrl = "http://" + s.Config.Core.ListenAddr 248 300 } else { 249 - appUrl = "https://tangled.sh" 301 + appUrl = s.Config.Core.AppviewHost 250 302 } 251 303 252 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 2 3 3 import ( 4 4 "bufio" 5 + "encoding/json" 6 + "errors" 5 7 "fmt" 6 8 "log/slog" 7 9 "net/http" 10 + "net/url" 8 11 "os" 9 12 "strings" 10 13 11 14 "github.com/go-chi/chi/v5" 12 15 "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" 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" 21 25 ) 22 26 23 27 type Signup struct { ··· 115 119 func (s *Signup) signup(w http.ResponseWriter, r *http.Request) { 116 120 switch r.Method { 117 121 case http.MethodGet: 118 - s.pages.Signup(w) 122 + s.pages.Signup(w, pages.SignupParams{ 123 + CloudflareSiteKey: s.config.Cloudflare.TurnstileSiteKey, 124 + }) 119 125 case http.MethodPost: 120 126 if s.cf == nil { 121 127 http.Error(w, "signup is disabled", http.StatusFailedDependency) 128 + return 122 129 } 123 130 emailId := r.FormValue("email") 131 + cfToken := r.FormValue("cf-turnstile-response") 124 132 125 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 + 126 141 if !email.IsValidEmail(emailId) { 127 142 s.pages.Notice(w, noticeId, "Invalid email address.") 128 143 return ··· 163 178 s.pages.Notice(w, noticeId, "Failed to send email.") 164 179 return 165 180 } 166 - err = db.AddInflightSignup(s.db, db.InflightSignup{ 181 + err = db.AddInflightSignup(s.db, models.InflightSignup{ 167 182 Email: emailId, 168 183 InviteCode: code, 169 184 }) ··· 229 244 return 230 245 } 231 246 232 - err = db.AddEmail(s.db, db.Email{ 247 + err = db.AddEmail(s.db, models.Email{ 233 248 Did: did, 234 249 Address: email, 235 250 Verified: true, ··· 254 269 return 255 270 } 256 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 9 "time" 10 10 11 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" 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" 23 24 24 25 comatproto "github.com/bluesky-social/indigo/api/atproto" 25 26 "github.com/bluesky-social/indigo/atproto/syntax" ··· 115 116 } 116 117 117 118 // organize repos by did 118 - repoMap := make(map[string][]db.Repo) 119 + repoMap := make(map[string][]models.Repo) 119 120 for _, r := range repos { 120 121 repoMap[r.Did] = append(repoMap[r.Did], r) 121 122 } ··· 163 164 s.Enforcer.E.LoadPolicy() 164 165 }() 165 166 166 - err = db.AddSpindle(tx, db.Spindle{ 167 + err = db.AddSpindle(tx, models.Spindle{ 167 168 Owner: syntax.DID(user.Did), 168 169 Instance: instance, 169 170 }) ··· 524 525 rkey := tid.TID() 525 526 526 527 // add member to db 527 - if err = db.AddSpindleMember(tx, db.SpindleMember{ 528 + if err = db.AddSpindleMember(tx, models.SpindleMember{ 528 529 Did: syntax.DID(user.Did), 529 530 Rkey: rkey, 530 531 Instance: instance,
+8 -7
appview/state/follow.go
··· 7 7 8 8 comatproto "github.com/bluesky-social/indigo/api/atproto" 9 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" 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" 14 15 ) 15 16 16 17 func (s *State) Follow(w http.ResponseWriter, r *http.Request) { ··· 59 60 60 61 log.Println("created atproto record: ", resp.Uri) 61 62 62 - follow := &db.Follow{ 63 + follow := &models.Follow{ 63 64 UserDid: currentUser.Did, 64 65 SubjectDid: subjectIdent.DID.String(), 65 66 Rkey: rkey, ··· 75 76 76 77 s.pages.FollowFragment(w, pages.FollowFragmentParams{ 77 78 UserDid: subjectIdent.DID.String(), 78 - FollowStatus: db.IsFollowing, 79 + FollowStatus: models.IsFollowing, 79 80 }) 80 81 81 82 return ··· 106 107 107 108 s.pages.FollowFragment(w, pages.FollowFragmentParams{ 108 109 UserDid: subjectIdent.DID.String(), 109 - FollowStatus: db.IsNotFollowing, 110 + FollowStatus: models.IsNotFollowing, 110 111 }) 111 112 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 8 9 9 "github.com/bluesky-social/indigo/atproto/identity" 10 10 "github.com/go-chi/chi/v5" 11 - "tangled.sh/tangled.sh/core/appview/db" 11 + "tangled.org/core/appview/models" 12 12 ) 13 13 14 14 func (s *State) InfoRefs(w http.ResponseWriter, r *http.Request) { 15 15 user := r.Context().Value("resolvedId").(identity.Identity) 16 - repo := r.Context().Value("repo").(*db.Repo) 16 + repo := r.Context().Value("repo").(*models.Repo) 17 17 18 18 scheme := "https" 19 19 if s.config.Core.Dev { ··· 31 31 http.Error(w, "failed to resolve user", http.StatusInternalServerError) 32 32 return 33 33 } 34 - repo := r.Context().Value("repo").(*db.Repo) 34 + repo := r.Context().Value("repo").(*models.Repo) 35 35 36 36 scheme := "https" 37 37 if s.config.Core.Dev { ··· 48 48 http.Error(w, "failed to resolve user", http.StatusInternalServerError) 49 49 return 50 50 } 51 - repo := r.Context().Value("repo").(*db.Repo) 51 + repo := r.Context().Value("repo").(*models.Repo) 52 52 53 53 scheme := "https" 54 54 if s.config.Core.Dev {
+29 -15
appview/state/knotstream.go
··· 8 8 "slices" 9 9 "time" 10 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" 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" 20 21 21 22 "github.com/bluesky-social/indigo/atproto/syntax" 22 23 "github.com/go-git/go-git/v5/plumbing" ··· 124 125 } 125 126 } 126 127 127 - punch := db.Punch{ 128 + punch := models.Punch{ 128 129 Did: record.CommitterDid, 129 130 Date: time.Now(), 130 131 Count: count, ··· 156 157 return fmt.Errorf("%s is not a valid reference name", ref) 157 158 } 158 159 159 - var langs []db.RepoLanguage 160 + var langs []models.RepoLanguage 160 161 for _, l := range record.Meta.LangBreakdown.Inputs { 161 162 if l == nil { 162 163 continue 163 164 } 164 165 165 - langs = append(langs, db.RepoLanguage{ 166 + langs = append(langs, models.RepoLanguage{ 166 167 RepoAt: repo.RepoAt(), 167 168 Ref: ref.Short(), 168 169 IsDefaultRef: record.Meta.IsDefaultRef, ··· 171 172 }) 172 173 } 173 174 174 - return db.InsertRepoLanguages(d, langs) 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() 175 189 } 176 190 177 191 func ingestPipeline(d *db.DB, source ec.Source, msg ec.Message) error { ··· 207 221 } 208 222 209 223 // trigger info 210 - var trigger db.Trigger 224 + var trigger models.Trigger 211 225 var sha string 212 226 trigger.Kind = workflow.TriggerKind(record.TriggerMetadata.Kind) 213 227 switch trigger.Kind { ··· 234 248 return fmt.Errorf("failed to add trigger entry: %w", err) 235 249 } 236 250 237 - pipeline := db.Pipeline{ 251 + pipeline := models.Pipeline{ 238 252 Rkey: msg.Rkey, 239 253 Knot: source.Key(), 240 254 RepoOwner: syntax.DID(record.TriggerMetadata.Repo.Did),
+30 -37
appview/state/profile.go
··· 15 15 lexutil "github.com/bluesky-social/indigo/lex/util" 16 16 "github.com/go-chi/chi/v5" 17 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/pages" 18 + "tangled.org/core/api/tangled" 19 + "tangled.org/core/appview/db" 20 + "tangled.org/core/appview/models" 21 + "tangled.org/core/appview/pages" 21 22 ) 22 23 23 24 func (s *State) Profile(w http.ResponseWriter, r *http.Request) { ··· 76 77 } 77 78 78 79 loggedInUser := s.oauth.GetUser(r) 79 - followStatus := db.IsNotFollowing 80 + followStatus := models.IsNotFollowing 80 81 if loggedInUser != nil { 81 82 followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, did) 82 83 } ··· 130 131 } 131 132 132 133 // filter out ones that are pinned 133 - pinnedRepos := []db.Repo{} 134 + pinnedRepos := []models.Repo{} 134 135 for i, r := range repos { 135 136 // if this is a pinned repo, add it 136 137 if slices.Contains(profile.Profile.PinnedRepos[:], r.RepoAt()) { ··· 148 149 l.Error("failed to fetch collaborating repos", "err", err) 149 150 } 150 151 151 - pinnedCollaboratingRepos := []db.Repo{} 152 + pinnedCollaboratingRepos := []models.Repo{} 152 153 for _, r := range collaboratingRepos { 153 154 // if this is a pinned repo, add it 154 155 if slices.Contains(profile.Profile.PinnedRepos[:], r.RepoAt()) { ··· 216 217 s.pages.Error500(w) 217 218 return 218 219 } 219 - var repoAts []string 220 + var repos []models.Repo 220 221 for _, s := range stars { 221 - repoAts = append(repoAts, string(s.RepoAt)) 222 - } 223 - 224 - repos, err := db.GetRepos( 225 - s.db, 226 - 0, 227 - db.FilterIn("at_uri", repoAts), 228 - ) 229 - if err != nil { 230 - l.Error("failed to get repos", "err", err) 231 - s.pages.Error500(w) 232 - return 222 + if s.Repo != nil { 223 + repos = append(repos, *s.Repo) 224 + } 233 225 } 234 226 235 227 err = s.pages.ProfileStarred(w, pages.ProfileStarredParams{ ··· 271 263 272 264 func (s *State) followPage( 273 265 r *http.Request, 274 - fetchFollows func(db.Execer, string) ([]db.Follow, error), 275 - extractDid func(db.Follow) string, 266 + fetchFollows func(db.Execer, string) ([]models.Follow, error), 267 + extractDid func(models.Follow) string, 276 268 ) (*FollowsPageParams, error) { 277 269 l := s.logger.With("handler", "reposPage") 278 270 ··· 329 321 followCards := make([]pages.FollowCard, len(follows)) 330 322 for i, did := range followDids { 331 323 followStats := followStatsMap[did] 332 - followStatus := db.IsNotFollowing 324 + followStatus := models.IsNotFollowing 333 325 if _, exists := loggedInUserFollowing[did]; exists { 334 - followStatus = db.IsFollowing 326 + followStatus = models.IsFollowing 335 327 } else if loggedInUser != nil && loggedInUser.Did == did { 336 - followStatus = db.IsSelf 328 + followStatus = models.IsSelf 337 329 } 338 330 339 - var profile *db.Profile 331 + var profile *models.Profile 340 332 if p, exists := profiles[did]; exists { 341 333 profile = p 342 334 } else { 343 - profile = &db.Profile{} 335 + profile = &models.Profile{} 344 336 profile.Did = did 345 337 } 346 338 followCards[i] = pages.FollowCard{ 339 + LoggedInUser: loggedInUser, 347 340 UserDid: did, 348 341 FollowStatus: followStatus, 349 342 FollowersCount: followStats.Followers, ··· 358 351 } 359 352 360 353 func (s *State) followersPage(w http.ResponseWriter, r *http.Request) { 361 - followPage, err := s.followPage(r, db.GetFollowers, func(f db.Follow) string { return f.UserDid }) 354 + followPage, err := s.followPage(r, db.GetFollowers, func(f models.Follow) string { return f.UserDid }) 362 355 if err != nil { 363 356 s.pages.Notice(w, "all-followers", "Failed to load followers") 364 357 return ··· 372 365 } 373 366 374 367 func (s *State) followingPage(w http.ResponseWriter, r *http.Request) { 375 - followPage, err := s.followPage(r, db.GetFollowing, func(f db.Follow) string { return f.SubjectDid }) 368 + followPage, err := s.followPage(r, db.GetFollowing, func(f models.Follow) string { return f.SubjectDid }) 376 369 if err != nil { 377 370 s.pages.Notice(w, "all-following", "Failed to load following") 378 371 return ··· 453 446 return &feed, nil 454 447 } 455 448 456 - func (s *State) addPullRequestItems(ctx context.Context, feed *feeds.Feed, pulls []*db.Pull, author *feeds.Author) error { 449 + func (s *State) addPullRequestItems(ctx context.Context, feed *feeds.Feed, pulls []*models.Pull, author *feeds.Author) error { 457 450 for _, pull := range pulls { 458 451 owner, err := s.idResolver.ResolveIdent(ctx, pull.Repo.Did) 459 452 if err != nil { ··· 466 459 return nil 467 460 } 468 461 469 - func (s *State) addIssueItems(ctx context.Context, feed *feeds.Feed, issues []*db.Issue, author *feeds.Author) error { 462 + func (s *State) addIssueItems(ctx context.Context, feed *feeds.Feed, issues []*models.Issue, author *feeds.Author) error { 470 463 for _, issue := range issues { 471 464 owner, err := s.idResolver.ResolveIdent(ctx, issue.Repo.Did) 472 465 if err != nil { ··· 478 471 return nil 479 472 } 480 473 481 - func (s *State) addRepoItems(ctx context.Context, feed *feeds.Feed, repos []db.RepoEvent, author *feeds.Author) error { 474 + func (s *State) addRepoItems(ctx context.Context, feed *feeds.Feed, repos []models.RepoEvent, author *feeds.Author) error { 482 475 for _, repo := range repos { 483 476 item, err := s.createRepoItem(ctx, repo, author) 484 477 if err != nil { ··· 489 482 return nil 490 483 } 491 484 492 - func (s *State) createPullRequestItem(pull *db.Pull, owner *identity.Identity, author *feeds.Author) *feeds.Item { 485 + func (s *State) createPullRequestItem(pull *models.Pull, owner *identity.Identity, author *feeds.Author) *feeds.Item { 493 486 return &feeds.Item{ 494 487 Title: fmt.Sprintf("%s created pull request '%s' in @%s/%s", author.Name, pull.Title, owner.Handle, pull.Repo.Name), 495 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"}, ··· 498 491 } 499 492 } 500 493 501 - func (s *State) createIssueItem(issue *db.Issue, owner *identity.Identity, author *feeds.Author) *feeds.Item { 494 + func (s *State) createIssueItem(issue *models.Issue, owner *identity.Identity, author *feeds.Author) *feeds.Item { 502 495 return &feeds.Item{ 503 496 Title: fmt.Sprintf("%s created issue '%s' in @%s/%s", author.Name, issue.Title, owner.Handle, issue.Repo.Name), 504 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"}, ··· 507 500 } 508 501 } 509 502 510 - func (s *State) createRepoItem(ctx context.Context, repo db.RepoEvent, author *feeds.Author) (*feeds.Item, error) { 503 + func (s *State) createRepoItem(ctx context.Context, repo models.RepoEvent, author *feeds.Author) (*feeds.Item, error) { 511 504 var title string 512 505 if repo.Source != nil { 513 506 sourceOwner, err := s.idResolver.ResolveIdent(ctx, repo.Source.Did) ··· 558 551 stat1 := r.FormValue("stat1") 559 552 560 553 if stat0 != "" { 561 - profile.Stats[0].Kind = db.VanityStatKind(stat0) 554 + profile.Stats[0].Kind = models.VanityStatKind(stat0) 562 555 } 563 556 564 557 if stat1 != "" { 565 - profile.Stats[1].Kind = db.VanityStatKind(stat1) 558 + profile.Stats[1].Kind = models.VanityStatKind(stat1) 566 559 } 567 560 568 561 if err := db.ValidateProfile(s.db, profile); err != nil { ··· 613 606 s.updateProfile(profile, w, r) 614 607 } 615 608 616 - func (s *State) updateProfile(profile *db.Profile, w http.ResponseWriter, r *http.Request) { 609 + func (s *State) updateProfile(profile *models.Profile, w http.ResponseWriter, r *http.Request) { 617 610 user := s.oauth.GetUser(r) 618 611 tx, err := s.db.BeginTx(r.Context(), nil) 619 612 if err != nil {
+6 -5
appview/state/reaction.go
··· 9 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 10 11 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" 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" 16 17 ) 17 18 18 19 func (s *State) React(w http.ResponseWriter, r *http.Request) { ··· 30 31 return 31 32 } 32 33 33 - reactionKind, ok := db.ParseReactionKind(r.URL.Query().Get("kind")) 34 + reactionKind, ok := models.ParseReactionKind(r.URL.Query().Get("kind")) 34 35 if !ok { 35 36 log.Println("invalid reaction kind") 36 37 return
+53 -18
appview/state/router.go
··· 6 6 7 7 "github.com/go-chi/chi/v5" 8 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" 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" 22 24 ) 23 25 24 26 func (s *State) Router() http.Handler { ··· 32 34 s.pages, 33 35 ) 34 36 37 + router.Use(middleware.TryRefreshSession()) 35 38 router.Get("/favicon.svg", s.Favicon) 36 39 router.Get("/favicon.ico", s.Favicon) 40 + router.Get("/pwa-manifest.json", s.PWAManifest) 37 41 38 42 userRouter := s.UserRouter(&middleware) 39 43 standardRouter := s.StandardRouter(&middleware) ··· 90 94 r.Mount("/issues", s.IssuesRouter(mw)) 91 95 r.Mount("/pulls", s.PullsRouter(mw)) 92 96 r.Mount("/pipelines", s.PipelinesRouter(mw)) 97 + r.Mount("/labels", s.LabelsRouter(mw)) 93 98 94 99 // These routes get proxied to the knot 95 100 r.Get("/info/refs", s.InfoRefs) ··· 113 118 114 119 r.Get("/", s.HomeOrTimeline) 115 120 r.Get("/timeline", s.Timeline) 116 - r.With(middleware.AuthMiddleware(s.oauth)).Get("/upgradeBanner", s.UpgradeBanner) 121 + r.Get("/upgradeBanner", s.UpgradeBanner) 122 + 123 + // special-case handler for serving tangled.org/core 124 + r.Get("/core", s.Core()) 117 125 118 126 r.Route("/repo", func(r chi.Router) { 119 127 r.Route("/new", func(r chi.Router) { ··· 124 132 // r.Post("/import", s.ImportRepo) 125 133 }) 126 134 135 + r.Get("/goodfirstissues", s.GoodFirstIssues) 136 + 127 137 r.With(middleware.AuthMiddleware(s.oauth)).Route("/follow", func(r chi.Router) { 128 138 r.Post("/", s.Follow) 129 139 r.Delete("/", s.Follow) ··· 151 161 r.Mount("/strings", s.StringsRouter(mw)) 152 162 r.Mount("/knots", s.KnotsRouter()) 153 163 r.Mount("/spindles", s.SpindlesRouter()) 164 + r.Mount("/notifications", s.NotificationsRouter(mw)) 165 + 154 166 r.Mount("/signup", s.SignupRouter()) 155 167 r.Mount("/", s.OAuthRouter()) 156 168 157 169 r.Get("/keys/{user}", s.Keys) 158 170 r.Get("/terms", s.TermsOfService) 159 171 r.Get("/privacy", s.PrivacyPolicy) 172 + r.Get("/brand", s.Brand) 160 173 161 174 r.NotFound(func(w http.ResponseWriter, r *http.Request) { 162 175 s.pages.Error404(w) ··· 164 177 return r 165 178 } 166 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 + 167 194 func (s *State) OAuthRouter() http.Handler { 168 195 store := sessions.NewCookieStore([]byte(s.config.Core.CookieSecret)) 169 196 oauth := oauthhandler.New(s.config, s.pages, s.idResolver, s.db, s.sess, store, s.oauth, s.enforcer, s.posthog) ··· 221 248 Db: s.db, 222 249 OAuth: s.oauth, 223 250 Pages: s.pages, 224 - Config: s.config, 225 - Enforcer: s.enforcer, 226 251 IdResolver: s.idResolver, 227 - Knotstream: s.knotstream, 252 + Notifier: s.notifier, 228 253 Logger: logger, 229 254 } 230 255 ··· 243 268 244 269 func (s *State) RepoRouter(mw *middleware.Middleware) http.Handler { 245 270 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) 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) 247 272 return repo.Router(mw) 248 273 } 249 274 250 275 func (s *State) PipelinesRouter(mw *middleware.Middleware) http.Handler { 251 276 pipes := pipelines.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.enforcer) 252 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) 253 288 } 254 289 255 290 func (s *State) SignupRouter() http.Handler {
+11 -10
appview/state/spindlestream.go
··· 9 9 "time" 10 10 11 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" 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" 21 22 ) 22 23 23 24 func Spindlestream(ctx context.Context, c *config.Config, d *db.DB, enforcer *rbac.Enforcer) (*ec.Consumer, error) { ··· 89 90 created = t 90 91 } 91 92 92 - status := db.PipelineStatus{ 93 + status := models.PipelineStatus{ 93 94 Spindle: source.Key(), 94 95 Rkey: msg.Rkey, 95 96 PipelineKnot: strings.TrimPrefix(pipelineUri.Authority().String(), "did:web:"),
+8 -7
appview/state/star.go
··· 8 8 comatproto "github.com/bluesky-social/indigo/api/atproto" 9 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 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" 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" 15 16 ) 16 17 17 18 func (s *State) Star(w http.ResponseWriter, r *http.Request) { ··· 55 56 } 56 57 log.Println("created atproto record: ", resp.Uri) 57 58 58 - star := &db.Star{ 59 + star := &models.Star{ 59 60 StarredByDid: currentUser.Did, 60 61 RepoAt: subjectUri, 61 62 Rkey: rkey, ··· 77 78 s.pages.RepoStarFragment(w, pages.RepoStarFragmentParams{ 78 79 IsStarred: true, 79 80 RepoAt: subjectUri, 80 - Stats: db.RepoStats{ 81 + Stats: models.RepoStats{ 81 82 StarCount: starCount, 82 83 }, 83 84 }) ··· 119 120 s.pages.RepoStarFragment(w, pages.RepoStarFragmentParams{ 120 121 IsStarred: false, 121 122 RepoAt: subjectUri, 122 - Stats: db.RepoStats{ 123 + Stats: models.RepoStats{ 123 124 StarCount: starCount, 124 125 }, 125 126 })
+121 -36
appview/state/state.go
··· 17 17 securejoin "github.com/cyphar/filepath-securejoin" 18 18 "github.com/go-chi/chi/v5" 19 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" 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" 40 41 ) 41 42 42 43 type State struct { ··· 78 79 cache := cache.New(config.Redis.Addr) 79 80 sess := session.New(cache) 80 81 oauth := oauth.NewOAuth(config, sess) 81 - validator := validator.New(d) 82 + validator := validator.New(d, res, enforcer) 82 83 83 84 posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint}) 84 85 if err != nil { ··· 87 88 88 89 repoResolver := reporesolver.New(config, enforcer, res, d) 89 90 90 - wrapper := db.DbWrapper{d} 91 + wrapper := db.DbWrapper{Execer: d} 91 92 jc, err := jetstream.NewJetstreamClient( 92 93 config.Jetstream.Endpoint, 93 94 "appview", ··· 102 103 tangled.StringNSID, 103 104 tangled.RepoIssueNSID, 104 105 tangled.RepoIssueCommentNSID, 106 + tangled.LabelDefinitionNSID, 107 + tangled.LabelOpNSID, 105 108 }, 106 109 nil, 107 110 slog.Default(), ··· 116 119 return nil, fmt.Errorf("failed to create jetstream client: %w", err) 117 120 } 118 121 122 + if err := BackfillDefaultDefs(d, res); err != nil { 123 + return nil, fmt.Errorf("failed to backfill default label defs: %w", err) 124 + } 125 + 119 126 ingester := appview.Ingester{ 120 127 Db: wrapper, 121 128 Enforcer: enforcer, ··· 142 149 spindlestream.Start(ctx) 143 150 144 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 145 157 if !config.Core.Dev { 146 - notifiers = append(notifiers, posthogService.NewPosthogNotifier(posthog)) 158 + notifiers = append(notifiers, phnotify.NewPosthogNotifier(posthog)) 147 159 } 148 160 notifier := notify.NewMergedNotifier(notifiers...) 149 161 ··· 186 198 s.pages.Favicon(w) 187 199 } 188 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 + 189 224 func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) { 190 225 user := s.oauth.GetUser(r) 191 226 s.pages.TermsOfService(w, pages.TermsOfServiceParams{ ··· 200 235 }) 201 236 } 202 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 + 203 245 func (s *State) HomeOrTimeline(w http.ResponseWriter, r *http.Request) { 204 246 if s.oauth.GetUser(r) != nil { 205 247 s.Timeline(w, r) ··· 211 253 func (s *State) Timeline(w http.ResponseWriter, r *http.Request) { 212 254 user := s.oauth.GetUser(r) 213 255 214 - timeline, err := db.MakeTimeline(s.db, 50) 256 + var userDid string 257 + if user != nil { 258 + userDid = user.Did 259 + } 260 + timeline, err := db.MakeTimeline(s.db, 50, userDid) 215 261 if err != nil { 216 262 log.Println(err) 217 263 s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") ··· 224 270 return 225 271 } 226 272 227 - s.pages.Timeline(w, pages.TimelineParams{ 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{ 228 279 LoggedInUser: user, 229 280 Timeline: timeline, 230 281 Repos: repos, 231 - }) 282 + GfiLabel: gfiLabel, 283 + })) 232 284 } 233 285 234 286 func (s *State) UpgradeBanner(w http.ResponseWriter, r *http.Request) { 235 287 user := s.oauth.GetUser(r) 288 + if user == nil { 289 + return 290 + } 291 + 236 292 l := s.logger.With("handler", "UpgradeBanner") 237 293 l = l.With("did", user.Did) 238 294 l = l.With("handle", user.Handle) ··· 266 322 } 267 323 268 324 func (s *State) Home(w http.ResponseWriter, r *http.Request) { 269 - timeline, err := db.MakeTimeline(s.db, 5) 325 + timeline, err := db.MakeTimeline(s.db, 5, "") 270 326 if err != nil { 271 327 log.Println(err) 272 328 s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") ··· 415 471 } 416 472 417 473 // Check for existing repos 418 - existingRepo, err := db.GetRepo(s.db, user.Did, repoName) 474 + existingRepo, err := db.GetRepo( 475 + s.db, 476 + db.FilterEq("did", user.Did), 477 + db.FilterEq("name", repoName), 478 + ) 419 479 if err == nil && existingRepo != nil { 420 480 l.Info("repo exists") 421 481 s.pages.Notice(w, "repo", fmt.Sprintf("You already have a repository by this name on %s", existingRepo.Knot)) ··· 424 484 425 485 // create atproto record for this repo 426 486 rkey := tid.TID() 427 - repo := &db.Repo{ 487 + repo := &models.Repo{ 428 488 Did: user.Did, 429 489 Name: repoName, 430 490 Knot: domain, 431 491 Rkey: rkey, 432 492 Description: description, 493 + Created: time.Now(), 494 + Labels: models.DefaultLabelDefs(), 433 495 } 496 + record := repo.AsRecord() 434 497 435 498 xrpcClient, err := s.oauth.AuthorizedClient(r) 436 499 if err != nil { ··· 439 502 return 440 503 } 441 504 442 - createdAt := time.Now().Format(time.RFC3339) 443 505 atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 444 506 Collection: tangled.RepoNSID, 445 507 Repo: user.Did, 446 508 Rkey: rkey, 447 509 Record: &lexutil.LexiconTypeDecoder{ 448 - Val: &tangled.Repo{ 449 - Knot: repo.Knot, 450 - Name: repoName, 451 - CreatedAt: createdAt, 452 - Owner: user.Did, 453 - }}, 510 + Val: &record, 511 + }, 454 512 }) 455 513 if err != nil { 456 514 l.Info("PDS write failed", "err", err) ··· 574 632 }) 575 633 return err 576 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 8 "strconv" 9 9 "time" 10 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" 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" 22 21 23 22 "github.com/bluesky-social/indigo/api/atproto" 24 23 "github.com/bluesky-social/indigo/atproto/identity" ··· 31 30 Db *db.DB 32 31 OAuth *oauth.OAuth 33 32 Pages *pages.Pages 34 - Config *config.Config 35 - Enforcer *rbac.Enforcer 36 33 IdResolver *idresolver.Resolver 37 34 Logger *slog.Logger 38 - Knotstream *eventconsumer.Consumer 35 + Notifier notify.Notifier 39 36 } 40 37 41 38 func (s *Strings) Router(mw *middleware.Middleware) http.Handler { ··· 239 236 description := r.FormValue("description") 240 237 241 238 // construct new string from form values 242 - entry := db.String{ 239 + entry := models.String{ 243 240 Did: first.Did, 244 241 Rkey: first.Rkey, 245 242 Filename: filename, ··· 284 281 return 285 282 } 286 283 284 + s.Notifier.EditString(r.Context(), &entry) 285 + 287 286 // if that went okay, redir to the string 288 287 s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+entry.Rkey) 289 288 } ··· 320 319 321 320 description := r.FormValue("description") 322 321 323 - string := db.String{ 322 + string := models.String{ 324 323 Did: syntax.DID(user.Did), 325 324 Rkey: tid.TID(), 326 325 Filename: filename, ··· 357 356 fail("Failed to create string.", err) 358 357 return 359 358 } 359 + 360 + s.Notifier.NewString(r.Context(), &string) 360 361 361 362 // successful 362 363 s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+string.Rkey) ··· 399 400 fail("Failed to delete string.", err) 400 401 return 401 402 } 403 + 404 + s.Notifier.DeleteString(r.Context(), user.Did, rkey) 402 405 403 406 s.Pages.HxRedirect(w, "/strings/"+user.Handle) 404 407 }
+4 -3
appview/validator/issue.go
··· 4 4 "fmt" 5 5 "strings" 6 6 7 - "tangled.sh/tangled.sh/core/appview/db" 7 + "tangled.org/core/appview/db" 8 + "tangled.org/core/appview/models" 8 9 ) 9 10 10 - func (v *Validator) ValidateIssueComment(comment *db.IssueComment) error { 11 + func (v *Validator) ValidateIssueComment(comment *models.IssueComment) error { 11 12 // if comments have parents, only ingest ones that are 1 level deep 12 13 if comment.ReplyTo != nil { 13 14 parents, err := db.GetIssueComments(v.db, db.FilterEq("at_uri", *comment.ReplyTo)) ··· 32 33 return nil 33 34 } 34 35 35 - func (v *Validator) ValidateIssue(issue *db.Issue) error { 36 + func (v *Validator) ValidateIssue(issue *models.Issue) error { 36 37 if issue.Title == "" { 37 38 return fmt.Errorf("issue title is empty") 38 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 1 package validator 2 2 3 3 import ( 4 - "tangled.sh/tangled.sh/core/appview/db" 5 - "tangled.sh/tangled.sh/core/appview/pages/markup" 4 + "tangled.org/core/appview/db" 5 + "tangled.org/core/appview/pages/markup" 6 + "tangled.org/core/idresolver" 7 + "tangled.org/core/rbac" 6 8 ) 7 9 8 10 type Validator struct { 9 11 db *db.DB 10 12 sanitizer markup.Sanitizer 13 + resolver *idresolver.Resolver 14 + enforcer *rbac.Enforcer 11 15 } 12 16 13 - func New(db *db.DB) *Validator { 17 + func New(db *db.DB, res *idresolver.Resolver, enforcer *rbac.Enforcer) *Validator { 14 18 return &Validator{ 15 19 db: db, 16 20 sanitizer: markup.NewSanitizer(), 21 + resolver: res, 22 + enforcer: enforcer, 17 23 } 18 24 }
+2 -2
cmd/appview/main.go
··· 7 7 "net/http" 8 8 "os" 9 9 10 - "tangled.sh/tangled.sh/core/appview/config" 11 - "tangled.sh/tangled.sh/core/appview/state" 10 + "tangled.org/core/appview/config" 11 + "tangled.org/core/appview/state" 12 12 ) 13 13 14 14 func main() {
+1 -1
cmd/combinediff/main.go
··· 5 5 "os" 6 6 7 7 "github.com/bluekeyes/go-gitdiff/gitdiff" 8 - "tangled.sh/tangled.sh/core/patchutil" 8 + "tangled.org/core/patchutil" 9 9 ) 10 10 11 11 func main() {
+6 -2
cmd/gen.go
··· 2 2 3 3 import ( 4 4 cbg "github.com/whyrusleeping/cbor-gen" 5 - "tangled.sh/tangled.sh/core/api/tangled" 5 + "tangled.org/core/api/tangled" 6 6 ) 7 7 8 8 func main() { ··· 20 20 tangled.GitRefUpdate{}, 21 21 tangled.GitRefUpdate_CommitCountBreakdown{}, 22 22 tangled.GitRefUpdate_IndividualEmailCommitCount{}, 23 - tangled.GitRefUpdate_LangBreakdown{}, 24 23 tangled.GitRefUpdate_IndividualLanguageSize{}, 24 + tangled.GitRefUpdate_LangBreakdown{}, 25 25 tangled.GitRefUpdate_Meta{}, 26 26 tangled.GraphFollow{}, 27 27 tangled.Knot{}, 28 28 tangled.KnotMember{}, 29 + tangled.LabelDefinition{}, 30 + tangled.LabelDefinition_ValueType{}, 31 + tangled.LabelOp{}, 32 + tangled.LabelOp_Operand{}, 29 33 tangled.Pipeline{}, 30 34 tangled.Pipeline_CloneOpts{}, 31 35 tangled.Pipeline_ManualTriggerData{},
+1 -1
cmd/interdiff/main.go
··· 5 5 "os" 6 6 7 7 "github.com/bluekeyes/go-gitdiff/gitdiff" 8 - "tangled.sh/tangled.sh/core/patchutil" 8 + "tangled.org/core/patchutil" 9 9 ) 10 10 11 11 func main() {
+5 -5
cmd/knot/main.go
··· 5 5 "os" 6 6 7 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" 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 13 ) 14 14 15 15 func main() {
+3 -3
cmd/spindle/main.go
··· 4 4 "context" 5 5 "os" 6 6 7 - "tangled.sh/tangled.sh/core/log" 8 - "tangled.sh/tangled.sh/core/spindle" 9 - _ "tangled.sh/tangled.sh/core/tid" 7 + "tangled.org/core/log" 8 + "tangled.org/core/spindle" 9 + _ "tangled.org/core/tid" 10 10 ) 11 11 12 12 func main() {
+1 -1
cmd/verifysig/main.go
··· 7 7 "os" 8 8 "strings" 9 9 10 - "tangled.sh/tangled.sh/core/crypto" 10 + "tangled.org/core/crypto" 11 11 ) 12 12 13 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 9 10 10 "github.com/hiddeco/sshsig" 11 11 "golang.org/x/crypto/ssh" 12 - "tangled.sh/tangled.sh/core/types" 12 + "tangled.org/core/types" 13 13 ) 14 14 15 15 func VerifySignature(pubKey, signature, payload []byte) (error, bool) {
+2 -2
docs/knot-hosting.md
··· 19 19 First, clone this repository: 20 20 21 21 ``` 22 - git clone https://tangled.sh/@tangled.sh/core 22 + git clone https://tangled.org/@tangled.org/core 23 23 ``` 24 24 25 25 Then, build the `knot` CLI. This is the knot administration and operation tool. ··· 130 130 131 131 You should now have a running knot server! You can finalize 132 132 your registration by hitting the `verify` button on the 133 - [/knots](https://tangled.sh/knots) page. This simply creates 133 + [/knots](https://tangled.org/knots) page. This simply creates 134 134 a record on your PDS to announce the existence of the knot. 135 135 136 136 ### custom paths
+4 -5
docs/migrations.md
··· 14 14 For knots: 15 15 16 16 - Upgrade to latest tag (v1.9.0 or above) 17 - - Head to the [knot dashboard](https://tangled.sh/knots) and 17 + - Head to the [knot dashboard](https://tangled.org/knots) and 18 18 hit the "retry" button to verify your knot 19 19 20 20 For spindles: 21 21 22 22 - Upgrade to latest tag (v1.9.0 or above) 23 23 - Head to the [spindle 24 - dashboard](https://tangled.sh/spindles) and hit the 24 + dashboard](https://tangled.org/spindles) and hit the 25 25 "retry" button to verify your spindle 26 26 27 27 ## Upgrading from v1.7.x ··· 38 38 environment variable entirely 39 39 - `KNOT_SERVER_OWNER` is now required on boot, set this to 40 40 your DID. You can find your DID in the 41 - [settings](https://tangled.sh/settings) page. 41 + [settings](https://tangled.org/settings) page. 42 42 - Restart your knot once you have replaced the environment 43 43 variable 44 - - Head to the [knot dashboard](https://tangled.sh/knots) and 44 + - Head to the [knot dashboard](https://tangled.org/knots) and 45 45 hit the "retry" button to verify your knot. This simply 46 46 writes a `sh.tangled.knot` record to your PDS. 47 47 ··· 57 57 }; 58 58 }; 59 59 ``` 60 -
+1 -1
docs/spindle/openbao.md
··· 44 44 ### production 45 45 46 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 47 + [@tangled.org/infra](https://tangled.org/@tangled.org/infra) for how this can be 48 48 achieved using Nix. 49 49 50 50 Then, initialize the bao server:
+3 -3
docs/spindle/pipeline.md
··· 21 21 - `manual`: The workflow can be triggered manually. 22 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 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: 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 25 26 26 ```yaml 27 27 when: ··· 73 73 - nodejs 74 74 - go 75 75 # custom registry 76 - git+https://tangled.sh/@example.com/my_pkg: 76 + git+https://tangled.org/@example.com/my_pkg: 77 77 - my_pkg 78 78 ``` 79 79 ··· 141 141 - nodejs 142 142 - go 143 143 # custom registry 144 - git+https://tangled.sh/@example.com/my_pkg: 144 + git+https://tangled.org/@example.com/my_pkg: 145 145 - my_pkg 146 146 147 147 environment:
+2 -2
eventconsumer/consumer.go
··· 9 9 "sync" 10 10 "time" 11 11 12 - "tangled.sh/tangled.sh/core/eventconsumer/cursor" 13 - "tangled.sh/tangled.sh/core/log" 12 + "tangled.org/core/eventconsumer/cursor" 13 + "tangled.org/core/log" 14 14 15 15 "github.com/avast/retry-go/v4" 16 16 "github.com/gorilla/websocket"
+1 -1
eventconsumer/cursor/redis.go
··· 5 5 "fmt" 6 6 "strconv" 7 7 8 - "tangled.sh/tangled.sh/core/appview/cache" 8 + "tangled.org/core/appview/cache" 9 9 ) 10 10 11 11 const (
+2 -1
flake.nix
··· 151 151 nativeBuildInputs = [ 152 152 pkgs.go 153 153 pkgs.air 154 + pkgs.tilt 154 155 pkgs.gopls 155 156 pkgs.httpie 156 157 pkgs.litecli ··· 187 188 tailwind-watcher = 188 189 pkgs.writeShellScriptBin "run" 189 190 '' 190 - ${pkgs.tailwindcss}/bin/tailwindcss -w -i input.css -o ./appview/pages/static/tw.css 191 + ${pkgs.tailwindcss}/bin/tailwindcss --watch=always -i input.css -o ./appview/pages/static/tw.css 191 192 ''; 192 193 in { 193 194 fmt = {
+2 -2
go.mod
··· 1 - module tangled.sh/tangled.sh/core 1 + module tangled.org/core 2 2 3 3 go 1.24.4 4 4 ··· 43 43 github.com/yuin/goldmark v1.7.12 44 44 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc 45 45 golang.org/x/crypto v0.40.0 46 + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b 46 47 golang.org/x/net v0.42.0 47 48 golang.org/x/sync v0.16.0 48 49 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da ··· 168 169 go.uber.org/atomic v1.11.0 // indirect 169 170 go.uber.org/multierr v1.11.0 // indirect 170 171 go.uber.org/zap v1.27.0 // indirect 171 - golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect 172 172 golang.org/x/sys v0.34.0 // indirect 173 173 golang.org/x/text v0.27.0 // indirect 174 174 golang.org/x/time v0.12.0 // indirect
+2 -2
guard/guard.go
··· 15 15 "github.com/bluesky-social/indigo/atproto/identity" 16 16 securejoin "github.com/cyphar/filepath-securejoin" 17 17 "github.com/urfave/cli/v3" 18 - "tangled.sh/tangled.sh/core/idresolver" 19 - "tangled.sh/tangled.sh/core/log" 18 + "tangled.org/core/idresolver" 19 + "tangled.org/core/log" 20 20 ) 21 21 22 22 func Command() *cli.Command {
+2 -5
input.css
··· 228 228 } 229 229 /* LineHighlight */ 230 230 .chroma .hl { 231 - background-color: #bcc0cc; 231 + @apply bg-amber-400/30 dark:bg-amber-500/20; 232 232 } 233 + 233 234 /* LineNumbersTable */ 234 235 .chroma .lnt { 235 236 white-space: pre; ··· 864 865 text-decoration: underline; 865 866 } 866 867 } 867 - 868 - .chroma .line:has(.ln:target) { 869 - @apply bg-amber-400/30 dark:bg-amber-500/20; 870 - }
+1 -1
jetstream/jetstream.go
··· 13 13 "github.com/bluesky-social/jetstream/pkg/client" 14 14 "github.com/bluesky-social/jetstream/pkg/client/schedulers/sequential" 15 15 "github.com/bluesky-social/jetstream/pkg/models" 16 - "tangled.sh/tangled.sh/core/log" 16 + "tangled.org/core/log" 17 17 ) 18 18 19 19 type DB interface {
+1 -1
keyfetch/keyfetch.go
··· 10 10 "strings" 11 11 12 12 "github.com/urfave/cli/v3" 13 - "tangled.sh/tangled.sh/core/log" 13 + "tangled.org/core/log" 14 14 ) 15 15 16 16 func Command() *cli.Command {
+1 -1
knotserver/config/config.go
··· 41 41 Repo Repo `env:",prefix=KNOT_REPO_"` 42 42 Server Server `env:",prefix=KNOT_SERVER_"` 43 43 Git Git `env:",prefix=KNOT_GIT_"` 44 - AppViewEndpoint string `env:"APPVIEW_ENDPOINT, default=https://tangled.sh"` 44 + AppViewEndpoint string `env:"APPVIEW_ENDPOINT, default=https://tangled.org"` 45 45 } 46 46 47 47 func Load(ctx context.Context) (*Config, error) {
+1 -1
knotserver/db/events.go
··· 4 4 "fmt" 5 5 "time" 6 6 7 - "tangled.sh/tangled.sh/core/notifier" 7 + "tangled.org/core/notifier" 8 8 ) 9 9 10 10 type Event struct {
+1 -1
knotserver/db/pubkeys.go
··· 4 4 "strconv" 5 5 "time" 6 6 7 - "tangled.sh/tangled.sh/core/api/tangled" 7 + "tangled.org/core/api/tangled" 8 8 ) 9 9 10 10 type PublicKey struct {
+1 -1
knotserver/git/branch.go
··· 9 9 10 10 "github.com/go-git/go-git/v5/plumbing" 11 11 "github.com/go-git/go-git/v5/plumbing/object" 12 - "tangled.sh/tangled.sh/core/types" 12 + "tangled.org/core/types" 13 13 ) 14 14 15 15 func (g *GitRepo) Branches() ([]types.Branch, error) {
+2 -2
knotserver/git/diff.go
··· 12 12 "github.com/bluekeyes/go-gitdiff/gitdiff" 13 13 "github.com/go-git/go-git/v5/plumbing" 14 14 "github.com/go-git/go-git/v5/plumbing/object" 15 - "tangled.sh/tangled.sh/core/patchutil" 16 - "tangled.sh/tangled.sh/core/types" 15 + "tangled.org/core/patchutil" 16 + "tangled.org/core/types" 17 17 ) 18 18 19 19 func (g *GitRepo) Diff() (*types.NiceDiff, error) {
-103
knotserver/git/git.go
··· 27 27 h plumbing.Hash 28 28 } 29 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 30 // infoWrapper wraps the property of a TreeEntry so it can export fs.FileInfo 44 31 // to tar WriteHeader 45 32 type infoWrapper struct { ··· 48 35 mode fs.FileMode 49 36 modTime time.Time 50 37 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 38 } 90 39 91 40 func Open(path string, ref string) (*GitRepo, error) { ··· 171 120 return g.r.CommitObject(h) 172 121 } 173 122 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 123 func (g *GitRepo) FileContentN(path string, cap int64) ([]byte, error) { 183 124 c, err := g.r.CommitObject(g.h) 184 125 if err != nil { ··· 211 152 } 212 153 213 154 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 155 } 240 156 241 157 func (g *GitRepo) RawContent(path string) ([]byte, error) { ··· 410 326 func (i *infoWrapper) Sys() any { 411 327 return nil 412 328 } 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 - }
+1 -1
knotserver/git/post_receive.go
··· 9 9 "strings" 10 10 "time" 11 11 12 - "tangled.sh/tangled.sh/core/api/tangled" 12 + "tangled.org/core/api/tangled" 13 13 14 14 "github.com/go-git/go-git/v5/plumbing" 15 15 )
+1 -3
knotserver/git/tag.go
··· 2 2 3 3 import ( 4 4 "fmt" 5 - "slices" 6 5 "strconv" 7 6 "strings" 8 7 "time" ··· 35 34 outFormat.WriteString("") 36 35 outFormat.WriteString(recordSeparator) 37 36 38 - output, err := g.forEachRef(outFormat.String(), "refs/tags") 37 + output, err := g.forEachRef(outFormat.String(), "--sort=-creatordate", "refs/tags") 39 38 if err != nil { 40 39 return nil, fmt.Errorf("failed to get tags: %w", err) 41 40 } ··· 94 93 tags = append(tags, tag) 95 94 } 96 95 97 - slices.Reverse(tags) 98 96 return tags, nil 99 97 }
+1 -1
knotserver/git/tree.go
··· 8 8 "time" 9 9 10 10 "github.com/go-git/go-git/v5/plumbing/object" 11 - "tangled.sh/tangled.sh/core/types" 11 + "tangled.org/core/types" 12 12 ) 13 13 14 14 func (g *GitRepo) FileTree(ctx context.Context, path string) ([]types.NiceTree, error) {
+1 -1
knotserver/git.go
··· 10 10 11 11 securejoin "github.com/cyphar/filepath-securejoin" 12 12 "github.com/go-chi/chi/v5" 13 - "tangled.sh/tangled.sh/core/knotserver/git/service" 13 + "tangled.org/core/knotserver/git/service" 14 14 ) 15 15 16 16 func (d *Knot) InfoRefs(w http.ResponseWriter, r *http.Request) {
-4
knotserver/http_util.go
··· 16 16 w.WriteHeader(status) 17 17 json.NewEncoder(w).Encode(map[string]string{"error": msg}) 18 18 } 19 - 20 - func notFound(w http.ResponseWriter) { 21 - writeError(w, "not found", http.StatusNotFound) 22 - }
+10 -10
knotserver/ingester.go
··· 15 15 "github.com/bluesky-social/indigo/xrpc" 16 16 "github.com/bluesky-social/jetstream/pkg/models" 17 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" 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 25 ) 26 26 27 27 func (h *Knot) processPublicKey(ctx context.Context, event *models.Event) error { ··· 141 141 return fmt.Errorf("rejected pull record: not this knot, %s != %s", repo.Knot, h.c.Server.Hostname) 142 142 } 143 143 144 - didSlashRepo, err := securejoin.SecureJoin(repo.Owner, repo.Name) 144 + didSlashRepo, err := securejoin.SecureJoin(ident.DID.String(), repo.Name) 145 145 if err != nil { 146 146 return fmt.Errorf("failed to construct relative repo path: %w", err) 147 147 } ··· 151 151 return fmt.Errorf("failed to construct absolute repo path: %w", err) 152 152 } 153 153 154 - gr, err := git.Open(repoPath, record.Source.Branch) 154 + gr, err := git.Open(repoPath, record.Source.Sha) 155 155 if err != nil { 156 156 return fmt.Errorf("failed to open git repository: %w", err) 157 157 } ··· 191 191 Kind: string(workflow.TriggerKindPullRequest), 192 192 PullRequest: &trigger, 193 193 Repo: &tangled.Pipeline_TriggerRepo{ 194 - Did: repo.Owner, 194 + Did: ident.DID.String(), 195 195 Knot: repo.Knot, 196 196 Repo: repo.Name, 197 197 },
+8 -8
knotserver/internal.go
··· 13 13 securejoin "github.com/cyphar/filepath-securejoin" 14 14 "github.com/go-chi/chi/v5" 15 15 "github.com/go-chi/chi/v5/middleware" 16 - "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" 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 24 ) 25 25 26 26 type InternalHandle struct {
+9 -9
knotserver/router.go
··· 7 7 "net/http" 8 8 9 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" 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 19 ) 20 20 21 21 type Knot struct {
+8 -8
knotserver/server.go
··· 6 6 "net/http" 7 7 8 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" 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 17 ) 18 18 19 19 func Command() *cli.Command {
-36
knotserver/util.go
··· 1 1 package knotserver 2 2 3 3 import ( 4 - "net/http" 5 - "os" 6 - "path/filepath" 7 - 8 4 "github.com/bluesky-social/indigo/atproto/syntax" 9 - securejoin "github.com/cyphar/filepath-securejoin" 10 - "github.com/go-chi/chi/v5" 11 5 ) 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 6 43 7 var TIDClock = syntax.NewTIDClock(0) 44 8
+5 -5
knotserver/xrpc/create_repo.go
··· 13 13 "github.com/bluesky-social/indigo/xrpc" 14 14 securejoin "github.com/cyphar/filepath-securejoin" 15 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" 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 21 ) 22 22 23 23 func (h *Xrpc) CreateRepo(w http.ResponseWriter, r *http.Request) {
+3 -3
knotserver/xrpc/delete_repo.go
··· 11 11 "github.com/bluesky-social/indigo/atproto/syntax" 12 12 "github.com/bluesky-social/indigo/xrpc" 13 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" 14 + "tangled.org/core/api/tangled" 15 + "tangled.org/core/rbac" 16 + xrpcerr "tangled.org/core/xrpc/errors" 17 17 ) 18 18 19 19 func (x *Xrpc) DeleteRepo(w http.ResponseWriter, r *http.Request) {
+5 -5
knotserver/xrpc/fork_status.go
··· 8 8 9 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 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" 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 16 ) 17 17 18 18 func (x *Xrpc) ForkStatus(w http.ResponseWriter, r *http.Request) {
+4 -4
knotserver/xrpc/fork_sync.go
··· 8 8 9 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 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" 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 15 ) 16 16 17 17 func (x *Xrpc) ForkSync(w http.ResponseWriter, r *http.Request) {
+4 -4
knotserver/xrpc/hidden_ref.go
··· 9 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 10 "github.com/bluesky-social/indigo/xrpc" 11 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" 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 16 ) 17 17 18 18 func (x *Xrpc) HiddenRef(w http.ResponseWriter, r *http.Request) {
+2 -2
knotserver/xrpc/list_keys.go
··· 4 4 "net/http" 5 5 "strconv" 6 6 7 - "tangled.sh/tangled.sh/core/api/tangled" 8 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 7 + "tangled.org/core/api/tangled" 8 + xrpcerr "tangled.org/core/xrpc/errors" 9 9 ) 10 10 11 11 func (x *Xrpc) ListKeys(w http.ResponseWriter, r *http.Request) {
+6 -6
knotserver/xrpc/merge.go
··· 8 8 9 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 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" 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 17 ) 18 18 19 19 func (x *Xrpc) Merge(w http.ResponseWriter, r *http.Request) {
+3 -3
knotserver/xrpc/merge_check.go
··· 7 7 "net/http" 8 8 9 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" 10 + "tangled.org/core/api/tangled" 11 + "tangled.org/core/knotserver/git" 12 + xrpcerr "tangled.org/core/xrpc/errors" 13 13 ) 14 14 15 15 func (x *Xrpc) MergeCheck(w http.ResponseWriter, r *http.Request) {
+2 -2
knotserver/xrpc/owner.go
··· 3 3 import ( 4 4 "net/http" 5 5 6 - "tangled.sh/tangled.sh/core/api/tangled" 7 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 6 + "tangled.org/core/api/tangled" 7 + xrpcerr "tangled.org/core/xrpc/errors" 8 8 ) 9 9 10 10 func (x *Xrpc) Owner(w http.ResponseWriter, r *http.Request) {
+2 -2
knotserver/xrpc/repo_archive.go
··· 8 8 9 9 "github.com/go-git/go-git/v5/plumbing" 10 10 11 - "tangled.sh/tangled.sh/core/knotserver/git" 12 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 11 + "tangled.org/core/knotserver/git" 12 + xrpcerr "tangled.org/core/xrpc/errors" 13 13 ) 14 14 15 15 func (x *Xrpc) RepoArchive(w http.ResponseWriter, r *http.Request) {
+4 -4
knotserver/xrpc/repo_blob.go
··· 9 9 "slices" 10 10 "strings" 11 11 12 - "tangled.sh/tangled.sh/core/api/tangled" 13 - "tangled.sh/tangled.sh/core/knotserver/git" 14 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 12 + "tangled.org/core/api/tangled" 13 + "tangled.org/core/knotserver/git" 14 + xrpcerr "tangled.org/core/xrpc/errors" 15 15 ) 16 16 17 17 func (x *Xrpc) RepoBlob(w http.ResponseWriter, r *http.Request) { ··· 44 44 45 45 contents, err := gr.RawContent(treePath) 46 46 if err != nil { 47 - x.Logger.Error("file content", "error", err.Error()) 47 + x.Logger.Error("file content", "error", err.Error(), "treePath", treePath) 48 48 writeError(w, xrpcerr.NewXrpcError( 49 49 xrpcerr.WithTag("FileNotFound"), 50 50 xrpcerr.WithMessage("file not found at the specified path"),
+3 -3
knotserver/xrpc/repo_branch.go
··· 5 5 "net/url" 6 6 "time" 7 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" 8 + "tangled.org/core/api/tangled" 9 + "tangled.org/core/knotserver/git" 10 + xrpcerr "tangled.org/core/xrpc/errors" 11 11 ) 12 12 13 13 func (x *Xrpc) RepoBranch(w http.ResponseWriter, r *http.Request) {
+3 -3
knotserver/xrpc/repo_branches.go
··· 4 4 "net/http" 5 5 "strconv" 6 6 7 - "tangled.sh/tangled.sh/core/knotserver/git" 8 - "tangled.sh/tangled.sh/core/types" 9 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 7 + "tangled.org/core/knotserver/git" 8 + "tangled.org/core/types" 9 + xrpcerr "tangled.org/core/xrpc/errors" 10 10 ) 11 11 12 12 func (x *Xrpc) RepoBranches(w http.ResponseWriter, r *http.Request) {
+3 -3
knotserver/xrpc/repo_compare.go
··· 4 4 "fmt" 5 5 "net/http" 6 6 7 - "tangled.sh/tangled.sh/core/knotserver/git" 8 - "tangled.sh/tangled.sh/core/types" 9 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 7 + "tangled.org/core/knotserver/git" 8 + "tangled.org/core/types" 9 + xrpcerr "tangled.org/core/xrpc/errors" 10 10 ) 11 11 12 12 func (x *Xrpc) RepoCompare(w http.ResponseWriter, r *http.Request) {
+3 -3
knotserver/xrpc/repo_diff.go
··· 3 3 import ( 4 4 "net/http" 5 5 6 - "tangled.sh/tangled.sh/core/knotserver/git" 7 - "tangled.sh/tangled.sh/core/types" 8 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 6 + "tangled.org/core/knotserver/git" 7 + "tangled.org/core/types" 8 + xrpcerr "tangled.org/core/xrpc/errors" 9 9 ) 10 10 11 11 func (x *Xrpc) RepoDiff(w http.ResponseWriter, r *http.Request) {
+3 -3
knotserver/xrpc/repo_get_default_branch.go
··· 4 4 "net/http" 5 5 "time" 6 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" 7 + "tangled.org/core/api/tangled" 8 + "tangled.org/core/knotserver/git" 9 + xrpcerr "tangled.org/core/xrpc/errors" 10 10 ) 11 11 12 12 func (x *Xrpc) RepoGetDefaultBranch(w http.ResponseWriter, r *http.Request) {
+3 -3
knotserver/xrpc/repo_languages.go
··· 6 6 "net/http" 7 7 "time" 8 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" 9 + "tangled.org/core/api/tangled" 10 + "tangled.org/core/knotserver/git" 11 + xrpcerr "tangled.org/core/xrpc/errors" 12 12 ) 13 13 14 14 func (x *Xrpc) RepoLanguages(w http.ResponseWriter, r *http.Request) {
+3 -3
knotserver/xrpc/repo_log.go
··· 4 4 "net/http" 5 5 "strconv" 6 6 7 - "tangled.sh/tangled.sh/core/knotserver/git" 8 - "tangled.sh/tangled.sh/core/types" 9 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 7 + "tangled.org/core/knotserver/git" 8 + "tangled.org/core/types" 9 + xrpcerr "tangled.org/core/xrpc/errors" 10 10 ) 11 11 12 12 func (x *Xrpc) RepoLog(w http.ResponseWriter, r *http.Request) {
+3 -3
knotserver/xrpc/repo_tags.go
··· 7 7 "github.com/go-git/go-git/v5/plumbing" 8 8 "github.com/go-git/go-git/v5/plumbing/object" 9 9 10 - "tangled.sh/tangled.sh/core/knotserver/git" 11 - "tangled.sh/tangled.sh/core/types" 12 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 10 + "tangled.org/core/knotserver/git" 11 + "tangled.org/core/types" 12 + xrpcerr "tangled.org/core/xrpc/errors" 13 13 ) 14 14 15 15 func (x *Xrpc) RepoTags(w http.ResponseWriter, r *http.Request) {
+27 -3
knotserver/xrpc/repo_tree.go
··· 4 4 "net/http" 5 5 "path/filepath" 6 6 "time" 7 + "unicode/utf8" 7 8 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" 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" 11 13 ) 12 14 13 15 func (x *Xrpc) RepoTree(w http.ResponseWriter, r *http.Request) { ··· 43 45 return 44 46 } 45 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 + 46 66 // convert NiceTree -> tangled.RepoTree_TreeEntry 47 67 treeEntries := make([]*tangled.RepoTree_TreeEntry, len(files)) 48 68 for i, file := range files { ··· 83 103 Parent: parentPtr, 84 104 Dotdot: dotdotPtr, 85 105 Files: treeEntries, 106 + Readme: &tangled.RepoTree_Readme{ 107 + Filename: readmeFileName, 108 + Contents: readmeContents, 109 + }, 86 110 } 87 111 88 112 writeJson(w, response)
+4 -4
knotserver/xrpc/set_default_branch.go
··· 9 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 10 "github.com/bluesky-social/indigo/xrpc" 11 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" 12 + "tangled.org/core/api/tangled" 13 + "tangled.org/core/knotserver/git" 14 + "tangled.org/core/rbac" 15 15 16 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 16 + xrpcerr "tangled.org/core/xrpc/errors" 17 17 ) 18 18 19 19 const ActorDid string = "ActorDid"
+2 -2
knotserver/xrpc/version.go
··· 5 5 "net/http" 6 6 "runtime/debug" 7 7 8 - "tangled.sh/tangled.sh/core/api/tangled" 8 + "tangled.org/core/api/tangled" 9 9 ) 10 10 11 11 // version is set during build time. ··· 24 24 var modified bool 25 25 26 26 for _, mod := range info.Deps { 27 - if mod.Path == "tangled.sh/tangled.sh/knotserver/xrpc" { 27 + if mod.Path == "tangled.org/tangled.org/knotserver/xrpc" { 28 28 modVer = mod.Version 29 29 break 30 30 }
+9 -9
knotserver/xrpc/xrpc.go
··· 7 7 "strings" 8 8 9 9 securejoin "github.com/cyphar/filepath-securejoin" 10 - "tangled.sh/tangled.sh/core/api/tangled" 11 - "tangled.sh/tangled.sh/core/idresolver" 12 - "tangled.sh/tangled.sh/core/jetstream" 13 - "tangled.sh/tangled.sh/core/knotserver/config" 14 - "tangled.sh/tangled.sh/core/knotserver/db" 15 - "tangled.sh/tangled.sh/core/notifier" 16 - "tangled.sh/tangled.sh/core/rbac" 17 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 18 - "tangled.sh/tangled.sh/core/xrpc/serviceauth" 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 19 20 20 "github.com/go-chi/chi/v5" 21 21 )
-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 3 "package": "tangled", 4 4 "prefix": "sh.tangled", 5 5 "outdir": "api/tangled", 6 - "import": "tangled.sh/tangled.sh/core/api/tangled", 6 + "import": "tangled.org/core/api/tangled", 7 7 "gen-server": true 8 8 } 9 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 12 "required": [ 13 13 "name", 14 14 "knot", 15 - "owner", 16 15 "createdAt" 17 16 ], 18 17 "properties": { 19 18 "name": { 20 19 "type": "string", 21 20 "description": "name of the repo" 22 - }, 23 - "owner": { 24 - "type": "string", 25 - "format": "did" 26 21 }, 27 22 "knot": { 28 23 "type": "string", ··· 41 36 "type": "string", 42 37 "format": "uri", 43 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 + } 44 47 }, 45 48 "createdAt": { 46 49 "type": "string",
+19
lexicons/repo/tree.json
··· 41 41 "type": "string", 42 42 "description": "Parent directory path" 43 43 }, 44 + "readme": { 45 + "type": "ref", 46 + "ref": "#readme", 47 + "description": "Readme for this file tree" 48 + }, 44 49 "files": { 45 50 "type": "array", 46 51 "items": { ··· 69 74 "description": "Invalid request parameters" 70 75 } 71 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 + } 72 91 }, 73 92 "treeEntry": { 74 93 "type": "object",
+2 -2
nix/pkgs/knot-unwrapped.nix
··· 4 4 sqlite-lib, 5 5 src, 6 6 }: let 7 - version = "1.9.0-alpha"; 7 + version = "1.9.1-alpha"; 8 8 in 9 9 buildGoApplication { 10 10 pname = "knot"; ··· 16 16 tags = ["libsqlite3"]; 17 17 18 18 ldflags = [ 19 - "-X tangled.sh/tangled.sh/core/knotserver/xrpc.version=${version}" 19 + "-X tangled.org/core/knotserver/xrpc.version=${version}" 20 20 ]; 21 21 22 22 env.CGO_CFLAGS = "-I ${sqlite-lib}/include ";
+1 -1
patchutil/interdiff.go
··· 5 5 "strings" 6 6 7 7 "github.com/bluekeyes/go-gitdiff/gitdiff" 8 - "tangled.sh/tangled.sh/core/types" 8 + "tangled.org/core/types" 9 9 ) 10 10 11 11 type InterdiffResult struct {
+1 -1
patchutil/patchutil.go
··· 10 10 "strings" 11 11 12 12 "github.com/bluekeyes/go-gitdiff/gitdiff" 13 - "tangled.sh/tangled.sh/core/types" 13 + "tangled.org/core/types" 14 14 ) 15 15 16 16 func ExtractPatches(formatPatch string) ([]types.FormatPatch, error) {
+1 -1
rbac/rbac_test.go
··· 4 4 "database/sql" 5 5 "testing" 6 6 7 - "tangled.sh/tangled.sh/core/rbac" 7 + "tangled.org/core/rbac" 8 8 9 9 adapter "github.com/Blank-Xu/sql-adapter" 10 10 "github.com/casbin/casbin/v2"
+4 -4
readme.md
··· 1 1 # tangled 2 2 3 3 Hello Tanglers! This is the codebase for 4 - [Tangled](https://tangled.sh)&mdash;a code collaboration platform built 4 + [Tangled](https://tangled.org)&mdash;a code collaboration platform built 5 5 on the [AT Protocol](https://atproto.com). 6 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 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 9 libera.chat](https://web.libera.chat/#tangled). 10 10 11 11 ## docs ··· 17 17 ## security 18 18 19 19 If you've identified a security issue in Tangled, please email 20 - [security@tangled.sh](mailto:security@tangled.sh) with details! 20 + [security@tangled.org](mailto:security@tangled.org) with details!
+4 -4
spindle/db/events.go
··· 5 5 "fmt" 6 6 "time" 7 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" 8 + "tangled.org/core/api/tangled" 9 + "tangled.org/core/notifier" 10 + "tangled.org/core/spindle/models" 11 + "tangled.org/core/tid" 12 12 ) 13 13 14 14 type Event struct {
+5 -5
spindle/engine/engine.go
··· 8 8 9 9 securejoin "github.com/cyphar/filepath-securejoin" 10 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" 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 16 ) 17 17 18 18 var (
+6 -6
spindle/engines/nixery/engine.go
··· 19 19 "github.com/docker/docker/client" 20 20 "github.com/docker/docker/pkg/stdcopy" 21 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" 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 28 ) 29 29 30 30 const (
+2 -2
spindle/engines/nixery/setup_steps.go
··· 5 5 "path" 6 6 "strings" 7 7 8 - "tangled.sh/tangled.sh/core/api/tangled" 9 - "tangled.sh/tangled.sh/core/workflow" 8 + "tangled.org/core/api/tangled" 9 + "tangled.org/core/workflow" 10 10 ) 11 11 12 12 func nixConfStep() Step {
+11 -11
spindle/ingester.go
··· 7 7 "fmt" 8 8 "time" 9 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" 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 15 16 16 comatproto "github.com/bluesky-social/indigo/api/atproto" 17 17 "github.com/bluesky-social/indigo/atproto/identity" ··· 146 146 147 147 l := s.l.With("component", "ingester", "record", tangled.RepoNSID) 148 148 149 - l.Info("ingesting repo record") 149 + l.Info("ingesting repo record", "did", did) 150 150 151 151 switch e.Commit.Operation { 152 152 case models.CommitOperationCreate, models.CommitOperationUpdate: ··· 162 162 163 163 // no spindle configured for this repo 164 164 if record.Spindle == nil { 165 - l.Info("no spindle configured", "did", record.Owner, "name", record.Name) 165 + l.Info("no spindle configured", "name", record.Name) 166 166 return nil 167 167 } 168 168 169 169 // this repo did not want this spindle 170 170 if *record.Spindle != domain { 171 - l.Info("different spindle configured", "did", record.Owner, "name", record.Name, "spindle", *record.Spindle, "domain", domain) 171 + l.Info("different spindle configured", "name", record.Name, "spindle", *record.Spindle, "domain", domain) 172 172 return nil 173 173 } 174 174 175 175 // add this repo to the watch list 176 - if err := s.db.AddRepo(record.Knot, record.Owner, record.Name); err != nil { 176 + if err := s.db.AddRepo(record.Knot, did, record.Name); err != nil { 177 177 l.Error("failed to add repo", "error", err) 178 178 return fmt.Errorf("failed to add repo: %w", err) 179 179 } 180 180 181 - didSlashRepo, err := securejoin.SecureJoin(record.Owner, record.Name) 181 + didSlashRepo, err := securejoin.SecureJoin(did, record.Name) 182 182 if err != nil { 183 183 return err 184 184 } 185 185 186 186 // add repo to rbac 187 - if err := s.e.AddRepo(record.Owner, rbac.ThisServer, didSlashRepo); err != nil { 187 + if err := s.e.AddRepo(did, rbac.ThisServer, didSlashRepo); err != nil { 188 188 l.Error("failed to add repo to enforcer", "error", err) 189 189 return fmt.Errorf("failed to add repo: %w", err) 190 190 }
+2 -2
spindle/models/engine.go
··· 4 4 "context" 5 5 "time" 6 6 7 - "tangled.sh/tangled.sh/core/api/tangled" 8 - "tangled.sh/tangled.sh/core/spindle/secrets" 7 + "tangled.org/core/api/tangled" 8 + "tangled.org/core/spindle/secrets" 9 9 ) 10 10 11 11 type Engine interface {
+1 -1
spindle/models/models.go
··· 5 5 "regexp" 6 6 "slices" 7 7 8 - "tangled.sh/tangled.sh/core/api/tangled" 8 + "tangled.org/core/api/tangled" 9 9 10 10 "github.com/bluesky-social/indigo/atproto/syntax" 11 11 )
+17 -17
spindle/server.go
··· 9 9 "net/http" 10 10 11 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" 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 29 ) 30 30 31 31 //go:embed motd
+1 -1
spindle/stream.go
··· 10 10 "strconv" 11 11 "time" 12 12 13 - "tangled.sh/tangled.sh/core/spindle/models" 13 + "tangled.org/core/spindle/models" 14 14 15 15 "github.com/go-chi/chi/v5" 16 16 "github.com/gorilla/websocket"
+5 -5
spindle/xrpc/add_secret.go
··· 10 10 "github.com/bluesky-social/indigo/atproto/syntax" 11 11 "github.com/bluesky-social/indigo/xrpc" 12 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" 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 17 ) 18 18 19 19 func (x *Xrpc) AddSecret(w http.ResponseWriter, r *http.Request) { ··· 62 62 } 63 63 64 64 repo := resp.Value.Val.(*tangled.Repo) 65 - didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name) 65 + didPath, err := securejoin.SecureJoin(ident.DID.String(), repo.Name) 66 66 if err != nil { 67 67 fail(xrpcerr.GenericError(err)) 68 68 return
+5 -5
spindle/xrpc/list_secrets.go
··· 10 10 "github.com/bluesky-social/indigo/atproto/syntax" 11 11 "github.com/bluesky-social/indigo/xrpc" 12 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" 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 17 ) 18 18 19 19 func (x *Xrpc) ListSecrets(w http.ResponseWriter, r *http.Request) { ··· 57 57 } 58 58 59 59 repo := resp.Value.Val.(*tangled.Repo) 60 - didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name) 60 + didPath, err := securejoin.SecureJoin(ident.DID.String(), repo.Name) 61 61 if err != nil { 62 62 fail(xrpcerr.GenericError(err)) 63 63 return
+2 -2
spindle/xrpc/owner.go
··· 4 4 "encoding/json" 5 5 "net/http" 6 6 7 - "tangled.sh/tangled.sh/core/api/tangled" 8 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 7 + "tangled.org/core/api/tangled" 8 + xrpcerr "tangled.org/core/xrpc/errors" 9 9 ) 10 10 11 11 func (x *Xrpc) Owner(w http.ResponseWriter, r *http.Request) {
+5 -5
spindle/xrpc/remove_secret.go
··· 9 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 10 "github.com/bluesky-social/indigo/xrpc" 11 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" 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 16 ) 17 17 18 18 func (x *Xrpc) RemoveSecret(w http.ResponseWriter, r *http.Request) { ··· 56 56 } 57 57 58 58 repo := resp.Value.Val.(*tangled.Repo) 59 - didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name) 59 + didPath, err := securejoin.SecureJoin(ident.DID.String(), repo.Name) 60 60 if err != nil { 61 61 fail(xrpcerr.GenericError(err)) 62 62 return
+9 -9
spindle/xrpc/xrpc.go
··· 8 8 9 9 "github.com/go-chi/chi/v5" 10 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" 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 20 ) 21 21 22 22 const ActorDid string = "ActorDid"
+7 -5
types/repo.go
··· 41 41 } 42 42 43 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"` 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"` 49 51 } 50 52 51 53 type TagReference struct {
+1 -1
workflow/compile.go
··· 4 4 "errors" 5 5 "fmt" 6 6 7 - "tangled.sh/tangled.sh/core/api/tangled" 7 + "tangled.org/core/api/tangled" 8 8 ) 9 9 10 10 type RawWorkflow struct {
+1 -1
workflow/compile_test.go
··· 5 5 "testing" 6 6 7 7 "github.com/stretchr/testify/assert" 8 - "tangled.sh/tangled.sh/core/api/tangled" 8 + "tangled.org/core/api/tangled" 9 9 ) 10 10 11 11 var trigger = tangled.Pipeline_TriggerMetadata{
+1 -1
workflow/def.go
··· 6 6 "slices" 7 7 "strings" 8 8 9 - "tangled.sh/tangled.sh/core/api/tangled" 9 + "tangled.org/core/api/tangled" 10 10 11 11 "github.com/go-git/go-git/v5/plumbing" 12 12 "gopkg.in/yaml.v3"
+2 -2
xrpc/serviceauth/service_auth.go
··· 8 8 "strings" 9 9 10 10 "github.com/bluesky-social/indigo/atproto/auth" 11 - "tangled.sh/tangled.sh/core/idresolver" 12 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 11 + "tangled.org/core/idresolver" 12 + xrpcerr "tangled.org/core/xrpc/errors" 13 13 ) 14 14 15 15 const ActorDid string = "ActorDid"