+388
-732
api/tangled/cbor_gen.go
+388
-732
api/tangled/cbor_gen.go
···
1202
1202
1203
1203
return nil
1204
1204
}
1205
-
func (t *GitRefUpdate_Meta) MarshalCBOR(w io.Writer) error {
1206
-
if t == nil {
1207
-
_, err := w.Write(cbg.CborNull)
1208
-
return err
1209
-
}
1210
-
1211
-
cw := cbg.NewCborWriter(w)
1212
-
fieldCount := 3
1213
-
1214
-
if t.LangBreakdown == nil {
1215
-
fieldCount--
1216
-
}
1217
-
1218
-
if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
1219
-
return err
1220
-
}
1221
-
1222
-
// t.CommitCount (tangled.GitRefUpdate_Meta_CommitCount) (struct)
1223
-
if len("commitCount") > 1000000 {
1224
-
return xerrors.Errorf("Value in field \"commitCount\" was too long")
1225
-
}
1226
-
1227
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("commitCount"))); err != nil {
1228
-
return err
1229
-
}
1230
-
if _, err := cw.WriteString(string("commitCount")); err != nil {
1231
-
return err
1232
-
}
1233
-
1234
-
if err := t.CommitCount.MarshalCBOR(cw); err != nil {
1235
-
return err
1236
-
}
1237
-
1238
-
// t.IsDefaultRef (bool) (bool)
1239
-
if len("isDefaultRef") > 1000000 {
1240
-
return xerrors.Errorf("Value in field \"isDefaultRef\" was too long")
1241
-
}
1242
-
1243
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("isDefaultRef"))); err != nil {
1244
-
return err
1245
-
}
1246
-
if _, err := cw.WriteString(string("isDefaultRef")); err != nil {
1247
-
return err
1248
-
}
1249
-
1250
-
if err := cbg.WriteBool(w, t.IsDefaultRef); err != nil {
1251
-
return err
1252
-
}
1253
-
1254
-
// t.LangBreakdown (tangled.GitRefUpdate_Meta_LangBreakdown) (struct)
1255
-
if t.LangBreakdown != nil {
1256
-
1257
-
if len("langBreakdown") > 1000000 {
1258
-
return xerrors.Errorf("Value in field \"langBreakdown\" was too long")
1259
-
}
1260
-
1261
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("langBreakdown"))); err != nil {
1262
-
return err
1263
-
}
1264
-
if _, err := cw.WriteString(string("langBreakdown")); err != nil {
1265
-
return err
1266
-
}
1267
-
1268
-
if err := t.LangBreakdown.MarshalCBOR(cw); err != nil {
1269
-
return err
1270
-
}
1271
-
}
1272
-
return nil
1273
-
}
1274
-
1275
-
func (t *GitRefUpdate_Meta) UnmarshalCBOR(r io.Reader) (err error) {
1276
-
*t = GitRefUpdate_Meta{}
1277
-
1278
-
cr := cbg.NewCborReader(r)
1279
-
1280
-
maj, extra, err := cr.ReadHeader()
1281
-
if err != nil {
1282
-
return err
1283
-
}
1284
-
defer func() {
1285
-
if err == io.EOF {
1286
-
err = io.ErrUnexpectedEOF
1287
-
}
1288
-
}()
1289
-
1290
-
if maj != cbg.MajMap {
1291
-
return fmt.Errorf("cbor input should be of type map")
1292
-
}
1293
-
1294
-
if extra > cbg.MaxLength {
1295
-
return fmt.Errorf("GitRefUpdate_Meta: map struct too large (%d)", extra)
1296
-
}
1297
-
1298
-
n := extra
1299
-
1300
-
nameBuf := make([]byte, 13)
1301
-
for i := uint64(0); i < n; i++ {
1302
-
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
1303
-
if err != nil {
1304
-
return err
1305
-
}
1306
-
1307
-
if !ok {
1308
-
// Field doesn't exist on this type, so ignore it
1309
-
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
1310
-
return err
1311
-
}
1312
-
continue
1313
-
}
1314
-
1315
-
switch string(nameBuf[:nameLen]) {
1316
-
// t.CommitCount (tangled.GitRefUpdate_Meta_CommitCount) (struct)
1317
-
case "commitCount":
1318
-
1319
-
{
1320
-
1321
-
b, err := cr.ReadByte()
1322
-
if err != nil {
1323
-
return err
1324
-
}
1325
-
if b != cbg.CborNull[0] {
1326
-
if err := cr.UnreadByte(); err != nil {
1327
-
return err
1328
-
}
1329
-
t.CommitCount = new(GitRefUpdate_Meta_CommitCount)
1330
-
if err := t.CommitCount.UnmarshalCBOR(cr); err != nil {
1331
-
return xerrors.Errorf("unmarshaling t.CommitCount pointer: %w", err)
1332
-
}
1333
-
}
1334
-
1335
-
}
1336
-
// t.IsDefaultRef (bool) (bool)
1337
-
case "isDefaultRef":
1338
-
1339
-
maj, extra, err = cr.ReadHeader()
1340
-
if err != nil {
1341
-
return err
1342
-
}
1343
-
if maj != cbg.MajOther {
1344
-
return fmt.Errorf("booleans must be major type 7")
1345
-
}
1346
-
switch extra {
1347
-
case 20:
1348
-
t.IsDefaultRef = false
1349
-
case 21:
1350
-
t.IsDefaultRef = true
1351
-
default:
1352
-
return fmt.Errorf("booleans are either major type 7, value 20 or 21 (got %d)", extra)
1353
-
}
1354
-
// t.LangBreakdown (tangled.GitRefUpdate_Meta_LangBreakdown) (struct)
1355
-
case "langBreakdown":
1356
-
1357
-
{
1358
-
1359
-
b, err := cr.ReadByte()
1360
-
if err != nil {
1361
-
return err
1362
-
}
1363
-
if b != cbg.CborNull[0] {
1364
-
if err := cr.UnreadByte(); err != nil {
1365
-
return err
1366
-
}
1367
-
t.LangBreakdown = new(GitRefUpdate_Meta_LangBreakdown)
1368
-
if err := t.LangBreakdown.UnmarshalCBOR(cr); err != nil {
1369
-
return xerrors.Errorf("unmarshaling t.LangBreakdown pointer: %w", err)
1370
-
}
1371
-
}
1372
-
1373
-
}
1374
-
1375
-
default:
1376
-
// Field doesn't exist on this type, so ignore it
1377
-
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
1378
-
return err
1379
-
}
1380
-
}
1381
-
}
1382
-
1383
-
return nil
1384
-
}
1385
-
func (t *GitRefUpdate_Meta_CommitCount) MarshalCBOR(w io.Writer) error {
1205
+
func (t *GitRefUpdate_CommitCountBreakdown) MarshalCBOR(w io.Writer) error {
1386
1206
if t == nil {
1387
1207
_, err := w.Write(cbg.CborNull)
1388
1208
return err
···
1399
1219
return err
1400
1220
}
1401
1221
1402
-
// t.ByEmail ([]*tangled.GitRefUpdate_Meta_CommitCount_ByEmail_Elem) (slice)
1222
+
// t.ByEmail ([]*tangled.GitRefUpdate_IndividualEmailCommitCount) (slice)
1403
1223
if t.ByEmail != nil {
1404
1224
1405
1225
if len("byEmail") > 1000000 {
···
1430
1250
return nil
1431
1251
}
1432
1252
1433
-
func (t *GitRefUpdate_Meta_CommitCount) UnmarshalCBOR(r io.Reader) (err error) {
1434
-
*t = GitRefUpdate_Meta_CommitCount{}
1253
+
func (t *GitRefUpdate_CommitCountBreakdown) UnmarshalCBOR(r io.Reader) (err error) {
1254
+
*t = GitRefUpdate_CommitCountBreakdown{}
1435
1255
1436
1256
cr := cbg.NewCborReader(r)
1437
1257
···
1450
1270
}
1451
1271
1452
1272
if extra > cbg.MaxLength {
1453
-
return fmt.Errorf("GitRefUpdate_Meta_CommitCount: map struct too large (%d)", extra)
1273
+
return fmt.Errorf("GitRefUpdate_CommitCountBreakdown: map struct too large (%d)", extra)
1454
1274
}
1455
1275
1456
1276
n := extra
···
1471
1291
}
1472
1292
1473
1293
switch string(nameBuf[:nameLen]) {
1474
-
// t.ByEmail ([]*tangled.GitRefUpdate_Meta_CommitCount_ByEmail_Elem) (slice)
1294
+
// t.ByEmail ([]*tangled.GitRefUpdate_IndividualEmailCommitCount) (slice)
1475
1295
case "byEmail":
1476
1296
1477
1297
maj, extra, err = cr.ReadHeader()
···
1488
1308
}
1489
1309
1490
1310
if extra > 0 {
1491
-
t.ByEmail = make([]*GitRefUpdate_Meta_CommitCount_ByEmail_Elem, extra)
1311
+
t.ByEmail = make([]*GitRefUpdate_IndividualEmailCommitCount, extra)
1492
1312
}
1493
1313
1494
1314
for i := 0; i < int(extra); i++ {
···
1510
1330
if err := cr.UnreadByte(); err != nil {
1511
1331
return err
1512
1332
}
1513
-
t.ByEmail[i] = new(GitRefUpdate_Meta_CommitCount_ByEmail_Elem)
1333
+
t.ByEmail[i] = new(GitRefUpdate_IndividualEmailCommitCount)
1514
1334
if err := t.ByEmail[i].UnmarshalCBOR(cr); err != nil {
1515
1335
return xerrors.Errorf("unmarshaling t.ByEmail[i] pointer: %w", err)
1516
1336
}
···
1531
1351
1532
1352
return nil
1533
1353
}
1534
-
func (t *GitRefUpdate_Meta_CommitCount_ByEmail_Elem) MarshalCBOR(w io.Writer) error {
1354
+
func (t *GitRefUpdate_IndividualEmailCommitCount) MarshalCBOR(w io.Writer) error {
1535
1355
if t == nil {
1536
1356
_, err := w.Write(cbg.CborNull)
1537
1357
return err
···
1590
1410
return nil
1591
1411
}
1592
1412
1593
-
func (t *GitRefUpdate_Meta_CommitCount_ByEmail_Elem) UnmarshalCBOR(r io.Reader) (err error) {
1594
-
*t = GitRefUpdate_Meta_CommitCount_ByEmail_Elem{}
1413
+
func (t *GitRefUpdate_IndividualEmailCommitCount) UnmarshalCBOR(r io.Reader) (err error) {
1414
+
*t = GitRefUpdate_IndividualEmailCommitCount{}
1595
1415
1596
1416
cr := cbg.NewCborReader(r)
1597
1417
···
1610
1430
}
1611
1431
1612
1432
if extra > cbg.MaxLength {
1613
-
return fmt.Errorf("GitRefUpdate_Meta_CommitCount_ByEmail_Elem: map struct too large (%d)", extra)
1433
+
return fmt.Errorf("GitRefUpdate_IndividualEmailCommitCount: map struct too large (%d)", extra)
1614
1434
}
1615
1435
1616
1436
n := extra
···
1679
1499
1680
1500
return nil
1681
1501
}
1682
-
func (t *GitRefUpdate_Meta_LangBreakdown) MarshalCBOR(w io.Writer) error {
1502
+
func (t *GitRefUpdate_LangBreakdown) MarshalCBOR(w io.Writer) error {
1683
1503
if t == nil {
1684
1504
_, err := w.Write(cbg.CborNull)
1685
1505
return err
···
1696
1516
return err
1697
1517
}
1698
1518
1699
-
// t.Inputs ([]*tangled.GitRefUpdate_Pair) (slice)
1519
+
// t.Inputs ([]*tangled.GitRefUpdate_IndividualLanguageSize) (slice)
1700
1520
if t.Inputs != nil {
1701
1521
1702
1522
if len("inputs") > 1000000 {
···
1727
1547
return nil
1728
1548
}
1729
1549
1730
-
func (t *GitRefUpdate_Meta_LangBreakdown) UnmarshalCBOR(r io.Reader) (err error) {
1731
-
*t = GitRefUpdate_Meta_LangBreakdown{}
1550
+
func (t *GitRefUpdate_LangBreakdown) UnmarshalCBOR(r io.Reader) (err error) {
1551
+
*t = GitRefUpdate_LangBreakdown{}
1732
1552
1733
1553
cr := cbg.NewCborReader(r)
1734
1554
···
1747
1567
}
1748
1568
1749
1569
if extra > cbg.MaxLength {
1750
-
return fmt.Errorf("GitRefUpdate_Meta_LangBreakdown: map struct too large (%d)", extra)
1570
+
return fmt.Errorf("GitRefUpdate_LangBreakdown: map struct too large (%d)", extra)
1751
1571
}
1752
1572
1753
1573
n := extra
···
1768
1588
}
1769
1589
1770
1590
switch string(nameBuf[:nameLen]) {
1771
-
// t.Inputs ([]*tangled.GitRefUpdate_Pair) (slice)
1591
+
// t.Inputs ([]*tangled.GitRefUpdate_IndividualLanguageSize) (slice)
1772
1592
case "inputs":
1773
1593
1774
1594
maj, extra, err = cr.ReadHeader()
···
1785
1605
}
1786
1606
1787
1607
if extra > 0 {
1788
-
t.Inputs = make([]*GitRefUpdate_Pair, extra)
1608
+
t.Inputs = make([]*GitRefUpdate_IndividualLanguageSize, extra)
1789
1609
}
1790
1610
1791
1611
for i := 0; i < int(extra); i++ {
···
1807
1627
if err := cr.UnreadByte(); err != nil {
1808
1628
return err
1809
1629
}
1810
-
t.Inputs[i] = new(GitRefUpdate_Pair)
1630
+
t.Inputs[i] = new(GitRefUpdate_IndividualLanguageSize)
1811
1631
if err := t.Inputs[i].UnmarshalCBOR(cr); err != nil {
1812
1632
return xerrors.Errorf("unmarshaling t.Inputs[i] pointer: %w", err)
1813
1633
}
···
1828
1648
1829
1649
return nil
1830
1650
}
1831
-
func (t *GitRefUpdate_Pair) MarshalCBOR(w io.Writer) error {
1651
+
func (t *GitRefUpdate_IndividualLanguageSize) MarshalCBOR(w io.Writer) error {
1832
1652
if t == nil {
1833
1653
_, err := w.Write(cbg.CborNull)
1834
1654
return err
···
1888
1708
return nil
1889
1709
}
1890
1710
1891
-
func (t *GitRefUpdate_Pair) UnmarshalCBOR(r io.Reader) (err error) {
1892
-
*t = GitRefUpdate_Pair{}
1711
+
func (t *GitRefUpdate_IndividualLanguageSize) UnmarshalCBOR(r io.Reader) (err error) {
1712
+
*t = GitRefUpdate_IndividualLanguageSize{}
1893
1713
1894
1714
cr := cbg.NewCborReader(r)
1895
1715
···
1908
1728
}
1909
1729
1910
1730
if extra > cbg.MaxLength {
1911
-
return fmt.Errorf("GitRefUpdate_Pair: map struct too large (%d)", extra)
1731
+
return fmt.Errorf("GitRefUpdate_IndividualLanguageSize: map struct too large (%d)", extra)
1912
1732
}
1913
1733
1914
1734
n := extra
···
1965
1785
}
1966
1786
1967
1787
t.Size = int64(extraI)
1788
+
}
1789
+
1790
+
default:
1791
+
// Field doesn't exist on this type, so ignore it
1792
+
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
1793
+
return err
1794
+
}
1795
+
}
1796
+
}
1797
+
1798
+
return nil
1799
+
}
1800
+
func (t *GitRefUpdate_Meta) MarshalCBOR(w io.Writer) error {
1801
+
if t == nil {
1802
+
_, err := w.Write(cbg.CborNull)
1803
+
return err
1804
+
}
1805
+
1806
+
cw := cbg.NewCborWriter(w)
1807
+
fieldCount := 3
1808
+
1809
+
if t.LangBreakdown == nil {
1810
+
fieldCount--
1811
+
}
1812
+
1813
+
if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
1814
+
return err
1815
+
}
1816
+
1817
+
// t.CommitCount (tangled.GitRefUpdate_CommitCountBreakdown) (struct)
1818
+
if len("commitCount") > 1000000 {
1819
+
return xerrors.Errorf("Value in field \"commitCount\" was too long")
1820
+
}
1821
+
1822
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("commitCount"))); err != nil {
1823
+
return err
1824
+
}
1825
+
if _, err := cw.WriteString(string("commitCount")); err != nil {
1826
+
return err
1827
+
}
1828
+
1829
+
if err := t.CommitCount.MarshalCBOR(cw); err != nil {
1830
+
return err
1831
+
}
1832
+
1833
+
// t.IsDefaultRef (bool) (bool)
1834
+
if len("isDefaultRef") > 1000000 {
1835
+
return xerrors.Errorf("Value in field \"isDefaultRef\" was too long")
1836
+
}
1837
+
1838
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("isDefaultRef"))); err != nil {
1839
+
return err
1840
+
}
1841
+
if _, err := cw.WriteString(string("isDefaultRef")); err != nil {
1842
+
return err
1843
+
}
1844
+
1845
+
if err := cbg.WriteBool(w, t.IsDefaultRef); err != nil {
1846
+
return err
1847
+
}
1848
+
1849
+
// t.LangBreakdown (tangled.GitRefUpdate_LangBreakdown) (struct)
1850
+
if t.LangBreakdown != nil {
1851
+
1852
+
if len("langBreakdown") > 1000000 {
1853
+
return xerrors.Errorf("Value in field \"langBreakdown\" was too long")
1854
+
}
1855
+
1856
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("langBreakdown"))); err != nil {
1857
+
return err
1858
+
}
1859
+
if _, err := cw.WriteString(string("langBreakdown")); err != nil {
1860
+
return err
1861
+
}
1862
+
1863
+
if err := t.LangBreakdown.MarshalCBOR(cw); err != nil {
1864
+
return err
1865
+
}
1866
+
}
1867
+
return nil
1868
+
}
1869
+
1870
+
func (t *GitRefUpdate_Meta) UnmarshalCBOR(r io.Reader) (err error) {
1871
+
*t = GitRefUpdate_Meta{}
1872
+
1873
+
cr := cbg.NewCborReader(r)
1874
+
1875
+
maj, extra, err := cr.ReadHeader()
1876
+
if err != nil {
1877
+
return err
1878
+
}
1879
+
defer func() {
1880
+
if err == io.EOF {
1881
+
err = io.ErrUnexpectedEOF
1882
+
}
1883
+
}()
1884
+
1885
+
if maj != cbg.MajMap {
1886
+
return fmt.Errorf("cbor input should be of type map")
1887
+
}
1888
+
1889
+
if extra > cbg.MaxLength {
1890
+
return fmt.Errorf("GitRefUpdate_Meta: map struct too large (%d)", extra)
1891
+
}
1892
+
1893
+
n := extra
1894
+
1895
+
nameBuf := make([]byte, 13)
1896
+
for i := uint64(0); i < n; i++ {
1897
+
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
1898
+
if err != nil {
1899
+
return err
1900
+
}
1901
+
1902
+
if !ok {
1903
+
// Field doesn't exist on this type, so ignore it
1904
+
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
1905
+
return err
1906
+
}
1907
+
continue
1908
+
}
1909
+
1910
+
switch string(nameBuf[:nameLen]) {
1911
+
// t.CommitCount (tangled.GitRefUpdate_CommitCountBreakdown) (struct)
1912
+
case "commitCount":
1913
+
1914
+
{
1915
+
1916
+
b, err := cr.ReadByte()
1917
+
if err != nil {
1918
+
return err
1919
+
}
1920
+
if b != cbg.CborNull[0] {
1921
+
if err := cr.UnreadByte(); err != nil {
1922
+
return err
1923
+
}
1924
+
t.CommitCount = new(GitRefUpdate_CommitCountBreakdown)
1925
+
if err := t.CommitCount.UnmarshalCBOR(cr); err != nil {
1926
+
return xerrors.Errorf("unmarshaling t.CommitCount pointer: %w", err)
1927
+
}
1928
+
}
1929
+
1930
+
}
1931
+
// t.IsDefaultRef (bool) (bool)
1932
+
case "isDefaultRef":
1933
+
1934
+
maj, extra, err = cr.ReadHeader()
1935
+
if err != nil {
1936
+
return err
1937
+
}
1938
+
if maj != cbg.MajOther {
1939
+
return fmt.Errorf("booleans must be major type 7")
1940
+
}
1941
+
switch extra {
1942
+
case 20:
1943
+
t.IsDefaultRef = false
1944
+
case 21:
1945
+
t.IsDefaultRef = true
1946
+
default:
1947
+
return fmt.Errorf("booleans are either major type 7, value 20 or 21 (got %d)", extra)
1948
+
}
1949
+
// t.LangBreakdown (tangled.GitRefUpdate_LangBreakdown) (struct)
1950
+
case "langBreakdown":
1951
+
1952
+
{
1953
+
1954
+
b, err := cr.ReadByte()
1955
+
if err != nil {
1956
+
return err
1957
+
}
1958
+
if b != cbg.CborNull[0] {
1959
+
if err := cr.UnreadByte(); err != nil {
1960
+
return err
1961
+
}
1962
+
t.LangBreakdown = new(GitRefUpdate_LangBreakdown)
1963
+
if err := t.LangBreakdown.UnmarshalCBOR(cr); err != nil {
1964
+
return xerrors.Errorf("unmarshaling t.LangBreakdown pointer: %w", err)
1965
+
}
1966
+
}
1967
+
1968
1968
}
1969
1969
1970
1970
default:
···
5642
5642
}
5643
5643
5644
5644
cw := cbg.NewCborWriter(w)
5645
-
fieldCount := 7
5645
+
fieldCount := 5
5646
5646
5647
5647
if t.Body == nil {
5648
5648
fieldCount--
···
5726
5726
return err
5727
5727
}
5728
5728
5729
-
// t.Owner (string) (string)
5730
-
if len("owner") > 1000000 {
5731
-
return xerrors.Errorf("Value in field \"owner\" was too long")
5732
-
}
5733
-
5734
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("owner"))); err != nil {
5735
-
return err
5736
-
}
5737
-
if _, err := cw.WriteString(string("owner")); err != nil {
5738
-
return err
5739
-
}
5740
-
5741
-
if len(t.Owner) > 1000000 {
5742
-
return xerrors.Errorf("Value in field t.Owner was too long")
5743
-
}
5744
-
5745
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Owner))); err != nil {
5746
-
return err
5747
-
}
5748
-
if _, err := cw.WriteString(string(t.Owner)); err != nil {
5749
-
return err
5750
-
}
5751
-
5752
5729
// t.Title (string) (string)
5753
5730
if len("title") > 1000000 {
5754
5731
return xerrors.Errorf("Value in field \"title\" was too long")
···
5772
5749
return err
5773
5750
}
5774
5751
5775
-
// t.IssueId (int64) (int64)
5776
-
if len("issueId") > 1000000 {
5777
-
return xerrors.Errorf("Value in field \"issueId\" was too long")
5778
-
}
5779
-
5780
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("issueId"))); err != nil {
5781
-
return err
5782
-
}
5783
-
if _, err := cw.WriteString(string("issueId")); err != nil {
5784
-
return err
5785
-
}
5786
-
5787
-
if t.IssueId >= 0 {
5788
-
if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.IssueId)); err != nil {
5789
-
return err
5790
-
}
5791
-
} else {
5792
-
if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.IssueId-1)); err != nil {
5793
-
return err
5794
-
}
5795
-
}
5796
-
5797
5752
// t.CreatedAt (string) (string)
5798
5753
if len("createdAt") > 1000000 {
5799
5754
return xerrors.Errorf("Value in field \"createdAt\" was too long")
···
5903
5858
5904
5859
t.LexiconTypeID = string(sval)
5905
5860
}
5906
-
// t.Owner (string) (string)
5907
-
case "owner":
5908
-
5909
-
{
5910
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
5911
-
if err != nil {
5912
-
return err
5913
-
}
5914
-
5915
-
t.Owner = string(sval)
5916
-
}
5917
5861
// t.Title (string) (string)
5918
5862
case "title":
5919
5863
···
5925
5869
5926
5870
t.Title = string(sval)
5927
5871
}
5928
-
// t.IssueId (int64) (int64)
5929
-
case "issueId":
5930
-
{
5931
-
maj, extra, err := cr.ReadHeader()
5932
-
if err != nil {
5933
-
return err
5934
-
}
5935
-
var extraI int64
5936
-
switch maj {
5937
-
case cbg.MajUnsignedInt:
5938
-
extraI = int64(extra)
5939
-
if extraI < 0 {
5940
-
return fmt.Errorf("int64 positive overflow")
5941
-
}
5942
-
case cbg.MajNegativeInt:
5943
-
extraI = int64(extra)
5944
-
if extraI < 0 {
5945
-
return fmt.Errorf("int64 negative overflow")
5946
-
}
5947
-
extraI = -1 - extraI
5948
-
default:
5949
-
return fmt.Errorf("wrong type for int64 field: %d", maj)
5950
-
}
5951
-
5952
-
t.IssueId = int64(extraI)
5953
-
}
5954
5872
// t.CreatedAt (string) (string)
5955
5873
case "createdAt":
5956
5874
···
5980
5898
}
5981
5899
5982
5900
cw := cbg.NewCborWriter(w)
5983
-
fieldCount := 7
5984
-
5985
-
if t.CommentId == nil {
5986
-
fieldCount--
5987
-
}
5901
+
fieldCount := 5
5988
5902
5989
-
if t.Owner == nil {
5990
-
fieldCount--
5991
-
}
5992
-
5993
-
if t.Repo == nil {
5903
+
if t.ReplyTo == nil {
5994
5904
fieldCount--
5995
5905
}
5996
5906
···
6021
5931
return err
6022
5932
}
6023
5933
6024
-
// t.Repo (string) (string)
6025
-
if t.Repo != nil {
6026
-
6027
-
if len("repo") > 1000000 {
6028
-
return xerrors.Errorf("Value in field \"repo\" was too long")
6029
-
}
6030
-
6031
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("repo"))); err != nil {
6032
-
return err
6033
-
}
6034
-
if _, err := cw.WriteString(string("repo")); err != nil {
6035
-
return err
6036
-
}
6037
-
6038
-
if t.Repo == nil {
6039
-
if _, err := cw.Write(cbg.CborNull); err != nil {
6040
-
return err
6041
-
}
6042
-
} else {
6043
-
if len(*t.Repo) > 1000000 {
6044
-
return xerrors.Errorf("Value in field t.Repo was too long")
6045
-
}
6046
-
6047
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Repo))); err != nil {
6048
-
return err
6049
-
}
6050
-
if _, err := cw.WriteString(string(*t.Repo)); err != nil {
6051
-
return err
6052
-
}
6053
-
}
6054
-
}
6055
-
6056
5934
// t.LexiconTypeID (string) (string)
6057
5935
if len("$type") > 1000000 {
6058
5936
return xerrors.Errorf("Value in field \"$type\" was too long")
···
6095
5973
return err
6096
5974
}
6097
5975
6098
-
// t.Owner (string) (string)
6099
-
if t.Owner != nil {
5976
+
// t.ReplyTo (string) (string)
5977
+
if t.ReplyTo != nil {
6100
5978
6101
-
if len("owner") > 1000000 {
6102
-
return xerrors.Errorf("Value in field \"owner\" was too long")
5979
+
if len("replyTo") > 1000000 {
5980
+
return xerrors.Errorf("Value in field \"replyTo\" was too long")
6103
5981
}
6104
5982
6105
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("owner"))); err != nil {
5983
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("replyTo"))); err != nil {
6106
5984
return err
6107
5985
}
6108
-
if _, err := cw.WriteString(string("owner")); err != nil {
5986
+
if _, err := cw.WriteString(string("replyTo")); err != nil {
6109
5987
return err
6110
5988
}
6111
5989
6112
-
if t.Owner == nil {
5990
+
if t.ReplyTo == nil {
6113
5991
if _, err := cw.Write(cbg.CborNull); err != nil {
6114
5992
return err
6115
5993
}
6116
5994
} else {
6117
-
if len(*t.Owner) > 1000000 {
6118
-
return xerrors.Errorf("Value in field t.Owner was too long")
5995
+
if len(*t.ReplyTo) > 1000000 {
5996
+
return xerrors.Errorf("Value in field t.ReplyTo was too long")
6119
5997
}
6120
5998
6121
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Owner))); err != nil {
5999
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.ReplyTo))); err != nil {
6122
6000
return err
6123
6001
}
6124
-
if _, err := cw.WriteString(string(*t.Owner)); err != nil {
6002
+
if _, err := cw.WriteString(string(*t.ReplyTo)); err != nil {
6125
6003
return err
6126
6004
}
6127
6005
}
6128
6006
}
6129
6007
6130
-
// t.CommentId (int64) (int64)
6131
-
if t.CommentId != nil {
6132
-
6133
-
if len("commentId") > 1000000 {
6134
-
return xerrors.Errorf("Value in field \"commentId\" was too long")
6135
-
}
6136
-
6137
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("commentId"))); err != nil {
6138
-
return err
6139
-
}
6140
-
if _, err := cw.WriteString(string("commentId")); err != nil {
6141
-
return err
6142
-
}
6143
-
6144
-
if t.CommentId == nil {
6145
-
if _, err := cw.Write(cbg.CborNull); err != nil {
6146
-
return err
6147
-
}
6148
-
} else {
6149
-
if *t.CommentId >= 0 {
6150
-
if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(*t.CommentId)); err != nil {
6151
-
return err
6152
-
}
6153
-
} else {
6154
-
if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-*t.CommentId-1)); err != nil {
6155
-
return err
6156
-
}
6157
-
}
6158
-
}
6159
-
6160
-
}
6161
-
6162
6008
// t.CreatedAt (string) (string)
6163
6009
if len("createdAt") > 1000000 {
6164
6010
return xerrors.Errorf("Value in field \"createdAt\" was too long")
···
6236
6082
6237
6083
t.Body = string(sval)
6238
6084
}
6239
-
// t.Repo (string) (string)
6240
-
case "repo":
6241
-
6242
-
{
6243
-
b, err := cr.ReadByte()
6244
-
if err != nil {
6245
-
return err
6246
-
}
6247
-
if b != cbg.CborNull[0] {
6248
-
if err := cr.UnreadByte(); err != nil {
6249
-
return err
6250
-
}
6251
-
6252
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
6253
-
if err != nil {
6254
-
return err
6255
-
}
6256
-
6257
-
t.Repo = (*string)(&sval)
6258
-
}
6259
-
}
6260
6085
// t.LexiconTypeID (string) (string)
6261
6086
case "$type":
6262
6087
···
6279
6104
6280
6105
t.Issue = string(sval)
6281
6106
}
6282
-
// t.Owner (string) (string)
6283
-
case "owner":
6107
+
// t.ReplyTo (string) (string)
6108
+
case "replyTo":
6284
6109
6285
6110
{
6286
6111
b, err := cr.ReadByte()
···
6297
6122
return err
6298
6123
}
6299
6124
6300
-
t.Owner = (*string)(&sval)
6301
-
}
6302
-
}
6303
-
// t.CommentId (int64) (int64)
6304
-
case "commentId":
6305
-
{
6306
-
6307
-
b, err := cr.ReadByte()
6308
-
if err != nil {
6309
-
return err
6310
-
}
6311
-
if b != cbg.CborNull[0] {
6312
-
if err := cr.UnreadByte(); err != nil {
6313
-
return err
6314
-
}
6315
-
maj, extra, err := cr.ReadHeader()
6316
-
if err != nil {
6317
-
return err
6318
-
}
6319
-
var extraI int64
6320
-
switch maj {
6321
-
case cbg.MajUnsignedInt:
6322
-
extraI = int64(extra)
6323
-
if extraI < 0 {
6324
-
return fmt.Errorf("int64 positive overflow")
6325
-
}
6326
-
case cbg.MajNegativeInt:
6327
-
extraI = int64(extra)
6328
-
if extraI < 0 {
6329
-
return fmt.Errorf("int64 negative overflow")
6330
-
}
6331
-
extraI = -1 - extraI
6332
-
default:
6333
-
return fmt.Errorf("wrong type for int64 field: %d", maj)
6334
-
}
6335
-
6336
-
t.CommentId = (*int64)(&extraI)
6125
+
t.ReplyTo = (*string)(&sval)
6337
6126
}
6338
6127
}
6339
6128
// t.CreatedAt (string) (string)
···
6529
6318
}
6530
6319
6531
6320
cw := cbg.NewCborWriter(w)
6532
-
fieldCount := 9
6321
+
fieldCount := 7
6533
6322
6534
6323
if t.Body == nil {
6535
6324
fieldCount--
···
6640
6429
return err
6641
6430
}
6642
6431
6643
-
// t.PullId (int64) (int64)
6644
-
if len("pullId") > 1000000 {
6645
-
return xerrors.Errorf("Value in field \"pullId\" was too long")
6646
-
}
6647
-
6648
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("pullId"))); err != nil {
6649
-
return err
6650
-
}
6651
-
if _, err := cw.WriteString(string("pullId")); err != nil {
6652
-
return err
6653
-
}
6654
-
6655
-
if t.PullId >= 0 {
6656
-
if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.PullId)); err != nil {
6657
-
return err
6658
-
}
6659
-
} else {
6660
-
if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.PullId-1)); err != nil {
6661
-
return err
6662
-
}
6663
-
}
6664
-
6665
6432
// t.Source (tangled.RepoPull_Source) (struct)
6666
6433
if t.Source != nil {
6667
6434
···
6681
6448
}
6682
6449
}
6683
6450
6684
-
// t.CreatedAt (string) (string)
6685
-
if len("createdAt") > 1000000 {
6686
-
return xerrors.Errorf("Value in field \"createdAt\" was too long")
6451
+
// t.Target (tangled.RepoPull_Target) (struct)
6452
+
if len("target") > 1000000 {
6453
+
return xerrors.Errorf("Value in field \"target\" was too long")
6687
6454
}
6688
6455
6689
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil {
6456
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("target"))); err != nil {
6690
6457
return err
6691
6458
}
6692
-
if _, err := cw.WriteString(string("createdAt")); err != nil {
6459
+
if _, err := cw.WriteString(string("target")); err != nil {
6693
6460
return err
6694
6461
}
6695
6462
6696
-
if len(t.CreatedAt) > 1000000 {
6697
-
return xerrors.Errorf("Value in field t.CreatedAt was too long")
6698
-
}
6699
-
6700
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil {
6701
-
return err
6702
-
}
6703
-
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
6463
+
if err := t.Target.MarshalCBOR(cw); err != nil {
6704
6464
return err
6705
6465
}
6706
6466
6707
-
// t.TargetRepo (string) (string)
6708
-
if len("targetRepo") > 1000000 {
6709
-
return xerrors.Errorf("Value in field \"targetRepo\" was too long")
6467
+
// t.CreatedAt (string) (string)
6468
+
if len("createdAt") > 1000000 {
6469
+
return xerrors.Errorf("Value in field \"createdAt\" was too long")
6710
6470
}
6711
6471
6712
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("targetRepo"))); err != nil {
6472
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil {
6713
6473
return err
6714
6474
}
6715
-
if _, err := cw.WriteString(string("targetRepo")); err != nil {
6475
+
if _, err := cw.WriteString(string("createdAt")); err != nil {
6716
6476
return err
6717
6477
}
6718
6478
6719
-
if len(t.TargetRepo) > 1000000 {
6720
-
return xerrors.Errorf("Value in field t.TargetRepo was too long")
6479
+
if len(t.CreatedAt) > 1000000 {
6480
+
return xerrors.Errorf("Value in field t.CreatedAt was too long")
6721
6481
}
6722
6482
6723
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.TargetRepo))); err != nil {
6483
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil {
6724
6484
return err
6725
6485
}
6726
-
if _, err := cw.WriteString(string(t.TargetRepo)); err != nil {
6727
-
return err
6728
-
}
6729
-
6730
-
// t.TargetBranch (string) (string)
6731
-
if len("targetBranch") > 1000000 {
6732
-
return xerrors.Errorf("Value in field \"targetBranch\" was too long")
6733
-
}
6734
-
6735
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("targetBranch"))); err != nil {
6736
-
return err
6737
-
}
6738
-
if _, err := cw.WriteString(string("targetBranch")); err != nil {
6739
-
return err
6740
-
}
6741
-
6742
-
if len(t.TargetBranch) > 1000000 {
6743
-
return xerrors.Errorf("Value in field t.TargetBranch was too long")
6744
-
}
6745
-
6746
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.TargetBranch))); err != nil {
6747
-
return err
6748
-
}
6749
-
if _, err := cw.WriteString(string(t.TargetBranch)); err != nil {
6486
+
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
6750
6487
return err
6751
6488
}
6752
6489
return nil
···
6777
6514
6778
6515
n := extra
6779
6516
6780
-
nameBuf := make([]byte, 12)
6517
+
nameBuf := make([]byte, 9)
6781
6518
for i := uint64(0); i < n; i++ {
6782
6519
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
6783
6520
if err != nil {
···
6847
6584
6848
6585
t.Title = string(sval)
6849
6586
}
6850
-
// t.PullId (int64) (int64)
6851
-
case "pullId":
6852
-
{
6853
-
maj, extra, err := cr.ReadHeader()
6854
-
if err != nil {
6855
-
return err
6856
-
}
6857
-
var extraI int64
6858
-
switch maj {
6859
-
case cbg.MajUnsignedInt:
6860
-
extraI = int64(extra)
6861
-
if extraI < 0 {
6862
-
return fmt.Errorf("int64 positive overflow")
6863
-
}
6864
-
case cbg.MajNegativeInt:
6865
-
extraI = int64(extra)
6866
-
if extraI < 0 {
6867
-
return fmt.Errorf("int64 negative overflow")
6868
-
}
6869
-
extraI = -1 - extraI
6870
-
default:
6871
-
return fmt.Errorf("wrong type for int64 field: %d", maj)
6872
-
}
6873
-
6874
-
t.PullId = int64(extraI)
6875
-
}
6876
6587
// t.Source (tangled.RepoPull_Source) (struct)
6877
6588
case "source":
6878
6589
···
6893
6604
}
6894
6605
6895
6606
}
6896
-
// t.CreatedAt (string) (string)
6897
-
case "createdAt":
6607
+
// t.Target (tangled.RepoPull_Target) (struct)
6608
+
case "target":
6898
6609
6899
6610
{
6900
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
6611
+
6612
+
b, err := cr.ReadByte()
6901
6613
if err != nil {
6902
6614
return err
6903
6615
}
6904
-
6905
-
t.CreatedAt = string(sval)
6906
-
}
6907
-
// t.TargetRepo (string) (string)
6908
-
case "targetRepo":
6909
-
6910
-
{
6911
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
6912
-
if err != nil {
6913
-
return err
6616
+
if b != cbg.CborNull[0] {
6617
+
if err := cr.UnreadByte(); err != nil {
6618
+
return err
6619
+
}
6620
+
t.Target = new(RepoPull_Target)
6621
+
if err := t.Target.UnmarshalCBOR(cr); err != nil {
6622
+
return xerrors.Errorf("unmarshaling t.Target pointer: %w", err)
6623
+
}
6914
6624
}
6915
6625
6916
-
t.TargetRepo = string(sval)
6917
6626
}
6918
-
// t.TargetBranch (string) (string)
6919
-
case "targetBranch":
6627
+
// t.CreatedAt (string) (string)
6628
+
case "createdAt":
6920
6629
6921
6630
{
6922
6631
sval, err := cbg.ReadStringWithMax(cr, 1000000)
···
6924
6633
return err
6925
6634
}
6926
6635
6927
-
t.TargetBranch = string(sval)
6636
+
t.CreatedAt = string(sval)
6928
6637
}
6929
6638
6930
6639
default:
···
6944
6653
}
6945
6654
6946
6655
cw := cbg.NewCborWriter(w)
6947
-
fieldCount := 7
6948
6656
6949
-
if t.CommentId == nil {
6950
-
fieldCount--
6951
-
}
6952
-
6953
-
if t.Owner == nil {
6954
-
fieldCount--
6955
-
}
6956
-
6957
-
if t.Repo == nil {
6958
-
fieldCount--
6959
-
}
6960
-
6961
-
if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
6657
+
if _, err := cw.Write([]byte{164}); err != nil {
6962
6658
return err
6963
6659
}
6964
6660
···
7008
6704
return err
7009
6705
}
7010
6706
7011
-
// t.Repo (string) (string)
7012
-
if t.Repo != nil {
7013
-
7014
-
if len("repo") > 1000000 {
7015
-
return xerrors.Errorf("Value in field \"repo\" was too long")
7016
-
}
7017
-
7018
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("repo"))); err != nil {
7019
-
return err
7020
-
}
7021
-
if _, err := cw.WriteString(string("repo")); err != nil {
7022
-
return err
7023
-
}
7024
-
7025
-
if t.Repo == nil {
7026
-
if _, err := cw.Write(cbg.CborNull); err != nil {
7027
-
return err
7028
-
}
7029
-
} else {
7030
-
if len(*t.Repo) > 1000000 {
7031
-
return xerrors.Errorf("Value in field t.Repo was too long")
7032
-
}
7033
-
7034
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Repo))); err != nil {
7035
-
return err
7036
-
}
7037
-
if _, err := cw.WriteString(string(*t.Repo)); err != nil {
7038
-
return err
7039
-
}
7040
-
}
7041
-
}
7042
-
7043
6707
// t.LexiconTypeID (string) (string)
7044
6708
if len("$type") > 1000000 {
7045
6709
return xerrors.Errorf("Value in field \"$type\" was too long")
···
7059
6723
return err
7060
6724
}
7061
6725
7062
-
// t.Owner (string) (string)
7063
-
if t.Owner != nil {
7064
-
7065
-
if len("owner") > 1000000 {
7066
-
return xerrors.Errorf("Value in field \"owner\" was too long")
7067
-
}
7068
-
7069
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("owner"))); err != nil {
7070
-
return err
7071
-
}
7072
-
if _, err := cw.WriteString(string("owner")); err != nil {
7073
-
return err
7074
-
}
7075
-
7076
-
if t.Owner == nil {
7077
-
if _, err := cw.Write(cbg.CborNull); err != nil {
7078
-
return err
7079
-
}
7080
-
} else {
7081
-
if len(*t.Owner) > 1000000 {
7082
-
return xerrors.Errorf("Value in field t.Owner was too long")
7083
-
}
7084
-
7085
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Owner))); err != nil {
7086
-
return err
7087
-
}
7088
-
if _, err := cw.WriteString(string(*t.Owner)); err != nil {
7089
-
return err
7090
-
}
7091
-
}
7092
-
}
7093
-
7094
-
// t.CommentId (int64) (int64)
7095
-
if t.CommentId != nil {
7096
-
7097
-
if len("commentId") > 1000000 {
7098
-
return xerrors.Errorf("Value in field \"commentId\" was too long")
7099
-
}
7100
-
7101
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("commentId"))); err != nil {
7102
-
return err
7103
-
}
7104
-
if _, err := cw.WriteString(string("commentId")); err != nil {
7105
-
return err
7106
-
}
7107
-
7108
-
if t.CommentId == nil {
7109
-
if _, err := cw.Write(cbg.CborNull); err != nil {
7110
-
return err
7111
-
}
7112
-
} else {
7113
-
if *t.CommentId >= 0 {
7114
-
if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(*t.CommentId)); err != nil {
7115
-
return err
7116
-
}
7117
-
} else {
7118
-
if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-*t.CommentId-1)); err != nil {
7119
-
return err
7120
-
}
7121
-
}
7122
-
}
7123
-
7124
-
}
7125
-
7126
6726
// t.CreatedAt (string) (string)
7127
6727
if len("createdAt") > 1000000 {
7128
6728
return xerrors.Errorf("Value in field \"createdAt\" was too long")
···
7211
6811
7212
6812
t.Pull = string(sval)
7213
6813
}
7214
-
// t.Repo (string) (string)
7215
-
case "repo":
7216
-
7217
-
{
7218
-
b, err := cr.ReadByte()
7219
-
if err != nil {
7220
-
return err
7221
-
}
7222
-
if b != cbg.CborNull[0] {
7223
-
if err := cr.UnreadByte(); err != nil {
7224
-
return err
7225
-
}
7226
-
7227
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
7228
-
if err != nil {
7229
-
return err
7230
-
}
7231
-
7232
-
t.Repo = (*string)(&sval)
7233
-
}
7234
-
}
7235
6814
// t.LexiconTypeID (string) (string)
7236
6815
case "$type":
7237
6816
···
7243
6822
7244
6823
t.LexiconTypeID = string(sval)
7245
6824
}
7246
-
// t.Owner (string) (string)
7247
-
case "owner":
7248
-
7249
-
{
7250
-
b, err := cr.ReadByte()
7251
-
if err != nil {
7252
-
return err
7253
-
}
7254
-
if b != cbg.CborNull[0] {
7255
-
if err := cr.UnreadByte(); err != nil {
7256
-
return err
7257
-
}
7258
-
7259
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
7260
-
if err != nil {
7261
-
return err
7262
-
}
7263
-
7264
-
t.Owner = (*string)(&sval)
7265
-
}
7266
-
}
7267
-
// t.CommentId (int64) (int64)
7268
-
case "commentId":
7269
-
{
7270
-
7271
-
b, err := cr.ReadByte()
7272
-
if err != nil {
7273
-
return err
7274
-
}
7275
-
if b != cbg.CborNull[0] {
7276
-
if err := cr.UnreadByte(); err != nil {
7277
-
return err
7278
-
}
7279
-
maj, extra, err := cr.ReadHeader()
7280
-
if err != nil {
7281
-
return err
7282
-
}
7283
-
var extraI int64
7284
-
switch maj {
7285
-
case cbg.MajUnsignedInt:
7286
-
extraI = int64(extra)
7287
-
if extraI < 0 {
7288
-
return fmt.Errorf("int64 positive overflow")
7289
-
}
7290
-
case cbg.MajNegativeInt:
7291
-
extraI = int64(extra)
7292
-
if extraI < 0 {
7293
-
return fmt.Errorf("int64 negative overflow")
7294
-
}
7295
-
extraI = -1 - extraI
7296
-
default:
7297
-
return fmt.Errorf("wrong type for int64 field: %d", maj)
7298
-
}
7299
-
7300
-
t.CommentId = (*int64)(&extraI)
7301
-
}
7302
-
}
7303
6825
// t.CreatedAt (string) (string)
7304
6826
case "createdAt":
7305
6827
···
7666
7188
}
7667
7189
7668
7190
t.Status = string(sval)
7191
+
}
7192
+
7193
+
default:
7194
+
// Field doesn't exist on this type, so ignore it
7195
+
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
7196
+
return err
7197
+
}
7198
+
}
7199
+
}
7200
+
7201
+
return nil
7202
+
}
7203
+
func (t *RepoPull_Target) MarshalCBOR(w io.Writer) error {
7204
+
if t == nil {
7205
+
_, err := w.Write(cbg.CborNull)
7206
+
return err
7207
+
}
7208
+
7209
+
cw := cbg.NewCborWriter(w)
7210
+
7211
+
if _, err := cw.Write([]byte{162}); err != nil {
7212
+
return err
7213
+
}
7214
+
7215
+
// t.Repo (string) (string)
7216
+
if len("repo") > 1000000 {
7217
+
return xerrors.Errorf("Value in field \"repo\" was too long")
7218
+
}
7219
+
7220
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("repo"))); err != nil {
7221
+
return err
7222
+
}
7223
+
if _, err := cw.WriteString(string("repo")); err != nil {
7224
+
return err
7225
+
}
7226
+
7227
+
if len(t.Repo) > 1000000 {
7228
+
return xerrors.Errorf("Value in field t.Repo was too long")
7229
+
}
7230
+
7231
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Repo))); err != nil {
7232
+
return err
7233
+
}
7234
+
if _, err := cw.WriteString(string(t.Repo)); err != nil {
7235
+
return err
7236
+
}
7237
+
7238
+
// t.Branch (string) (string)
7239
+
if len("branch") > 1000000 {
7240
+
return xerrors.Errorf("Value in field \"branch\" was too long")
7241
+
}
7242
+
7243
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("branch"))); err != nil {
7244
+
return err
7245
+
}
7246
+
if _, err := cw.WriteString(string("branch")); err != nil {
7247
+
return err
7248
+
}
7249
+
7250
+
if len(t.Branch) > 1000000 {
7251
+
return xerrors.Errorf("Value in field t.Branch was too long")
7252
+
}
7253
+
7254
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Branch))); err != nil {
7255
+
return err
7256
+
}
7257
+
if _, err := cw.WriteString(string(t.Branch)); err != nil {
7258
+
return err
7259
+
}
7260
+
return nil
7261
+
}
7262
+
7263
+
func (t *RepoPull_Target) UnmarshalCBOR(r io.Reader) (err error) {
7264
+
*t = RepoPull_Target{}
7265
+
7266
+
cr := cbg.NewCborReader(r)
7267
+
7268
+
maj, extra, err := cr.ReadHeader()
7269
+
if err != nil {
7270
+
return err
7271
+
}
7272
+
defer func() {
7273
+
if err == io.EOF {
7274
+
err = io.ErrUnexpectedEOF
7275
+
}
7276
+
}()
7277
+
7278
+
if maj != cbg.MajMap {
7279
+
return fmt.Errorf("cbor input should be of type map")
7280
+
}
7281
+
7282
+
if extra > cbg.MaxLength {
7283
+
return fmt.Errorf("RepoPull_Target: map struct too large (%d)", extra)
7284
+
}
7285
+
7286
+
n := extra
7287
+
7288
+
nameBuf := make([]byte, 6)
7289
+
for i := uint64(0); i < n; i++ {
7290
+
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
7291
+
if err != nil {
7292
+
return err
7293
+
}
7294
+
7295
+
if !ok {
7296
+
// Field doesn't exist on this type, so ignore it
7297
+
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
7298
+
return err
7299
+
}
7300
+
continue
7301
+
}
7302
+
7303
+
switch string(nameBuf[:nameLen]) {
7304
+
// t.Repo (string) (string)
7305
+
case "repo":
7306
+
7307
+
{
7308
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
7309
+
if err != nil {
7310
+
return err
7311
+
}
7312
+
7313
+
t.Repo = string(sval)
7314
+
}
7315
+
// t.Branch (string) (string)
7316
+
case "branch":
7317
+
7318
+
{
7319
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
7320
+
if err != nil {
7321
+
return err
7322
+
}
7323
+
7324
+
t.Branch = string(sval)
7669
7325
}
7670
7326
7671
7327
default:
+19
-15
api/tangled/gitrefUpdate.go
+19
-15
api/tangled/gitrefUpdate.go
···
33
33
RepoName string `json:"repoName" cborgen:"repoName"`
34
34
}
35
35
36
-
type GitRefUpdate_Meta struct {
37
-
CommitCount *GitRefUpdate_Meta_CommitCount `json:"commitCount" cborgen:"commitCount"`
38
-
IsDefaultRef bool `json:"isDefaultRef" cborgen:"isDefaultRef"`
39
-
LangBreakdown *GitRefUpdate_Meta_LangBreakdown `json:"langBreakdown,omitempty" cborgen:"langBreakdown,omitempty"`
36
+
// GitRefUpdate_CommitCountBreakdown is a "commitCountBreakdown" in the sh.tangled.git.refUpdate schema.
37
+
type GitRefUpdate_CommitCountBreakdown struct {
38
+
ByEmail []*GitRefUpdate_IndividualEmailCommitCount `json:"byEmail,omitempty" cborgen:"byEmail,omitempty"`
40
39
}
41
40
42
-
type GitRefUpdate_Meta_CommitCount struct {
43
-
ByEmail []*GitRefUpdate_Meta_CommitCount_ByEmail_Elem `json:"byEmail,omitempty" cborgen:"byEmail,omitempty"`
44
-
}
45
-
46
-
type GitRefUpdate_Meta_CommitCount_ByEmail_Elem struct {
41
+
// GitRefUpdate_IndividualEmailCommitCount is a "individualEmailCommitCount" in the sh.tangled.git.refUpdate schema.
42
+
type GitRefUpdate_IndividualEmailCommitCount struct {
47
43
Count int64 `json:"count" cborgen:"count"`
48
44
Email string `json:"email" cborgen:"email"`
49
45
}
50
46
51
-
type GitRefUpdate_Meta_LangBreakdown struct {
52
-
Inputs []*GitRefUpdate_Pair `json:"inputs,omitempty" cborgen:"inputs,omitempty"`
53
-
}
54
-
55
-
// GitRefUpdate_Pair is a "pair" in the sh.tangled.git.refUpdate schema.
56
-
type GitRefUpdate_Pair struct {
47
+
// GitRefUpdate_IndividualLanguageSize is a "individualLanguageSize" in the sh.tangled.git.refUpdate schema.
48
+
type GitRefUpdate_IndividualLanguageSize struct {
57
49
Lang string `json:"lang" cborgen:"lang"`
58
50
Size int64 `json:"size" cborgen:"size"`
59
51
}
52
+
53
+
// GitRefUpdate_LangBreakdown is a "langBreakdown" in the sh.tangled.git.refUpdate schema.
54
+
type GitRefUpdate_LangBreakdown struct {
55
+
Inputs []*GitRefUpdate_IndividualLanguageSize `json:"inputs,omitempty" cborgen:"inputs,omitempty"`
56
+
}
57
+
58
+
// GitRefUpdate_Meta is a "meta" in the sh.tangled.git.refUpdate schema.
59
+
type GitRefUpdate_Meta struct {
60
+
CommitCount *GitRefUpdate_CommitCountBreakdown `json:"commitCount" cborgen:"commitCount"`
61
+
IsDefaultRef bool `json:"isDefaultRef" cborgen:"isDefaultRef"`
62
+
LangBreakdown *GitRefUpdate_LangBreakdown `json:"langBreakdown,omitempty" cborgen:"langBreakdown,omitempty"`
63
+
}
+1
-3
api/tangled/issuecomment.go
+1
-3
api/tangled/issuecomment.go
···
19
19
type RepoIssueComment struct {
20
20
LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue.comment" cborgen:"$type,const=sh.tangled.repo.issue.comment"`
21
21
Body string `json:"body" cborgen:"body"`
22
-
CommentId *int64 `json:"commentId,omitempty" cborgen:"commentId,omitempty"`
23
22
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
24
23
Issue string `json:"issue" cborgen:"issue"`
25
-
Owner *string `json:"owner,omitempty" cborgen:"owner,omitempty"`
26
-
Repo *string `json:"repo,omitempty" cborgen:"repo,omitempty"`
24
+
ReplyTo *string `json:"replyTo,omitempty" cborgen:"replyTo,omitempty"`
27
25
}
+53
api/tangled/knotlistKeys.go
+53
api/tangled/knotlistKeys.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.knot.listKeys
6
+
7
+
import (
8
+
"context"
9
+
10
+
"github.com/bluesky-social/indigo/lex/util"
11
+
)
12
+
13
+
const (
14
+
KnotListKeysNSID = "sh.tangled.knot.listKeys"
15
+
)
16
+
17
+
// KnotListKeys_Output is the output of a sh.tangled.knot.listKeys call.
18
+
type KnotListKeys_Output struct {
19
+
// cursor: Pagination cursor for next page
20
+
Cursor *string `json:"cursor,omitempty" cborgen:"cursor,omitempty"`
21
+
Keys []*KnotListKeys_PublicKey `json:"keys" cborgen:"keys"`
22
+
}
23
+
24
+
// KnotListKeys_PublicKey is a "publicKey" in the sh.tangled.knot.listKeys schema.
25
+
type KnotListKeys_PublicKey struct {
26
+
// createdAt: Key upload timestamp
27
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
28
+
// did: DID associated with the public key
29
+
Did string `json:"did" cborgen:"did"`
30
+
// key: Public key contents
31
+
Key string `json:"key" cborgen:"key"`
32
+
}
33
+
34
+
// KnotListKeys calls the XRPC method "sh.tangled.knot.listKeys".
35
+
//
36
+
// cursor: Pagination cursor
37
+
// limit: Maximum number of keys to return
38
+
func KnotListKeys(ctx context.Context, c util.LexClient, cursor string, limit int64) (*KnotListKeys_Output, error) {
39
+
var out KnotListKeys_Output
40
+
41
+
params := map[string]interface{}{}
42
+
if cursor != "" {
43
+
params["cursor"] = cursor
44
+
}
45
+
if limit != 0 {
46
+
params["limit"] = limit
47
+
}
48
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.knot.listKeys", params, nil, &out); err != nil {
49
+
return nil, err
50
+
}
51
+
52
+
return &out, nil
53
+
}
+30
api/tangled/knotversion.go
+30
api/tangled/knotversion.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.knot.version
6
+
7
+
import (
8
+
"context"
9
+
10
+
"github.com/bluesky-social/indigo/lex/util"
11
+
)
12
+
13
+
const (
14
+
KnotVersionNSID = "sh.tangled.knot.version"
15
+
)
16
+
17
+
// KnotVersion_Output is the output of a sh.tangled.knot.version call.
18
+
type KnotVersion_Output struct {
19
+
Version string `json:"version" cborgen:"version"`
20
+
}
21
+
22
+
// KnotVersion calls the XRPC method "sh.tangled.knot.version".
23
+
func KnotVersion(ctx context.Context, c util.LexClient) (*KnotVersion_Output, error) {
24
+
var out KnotVersion_Output
25
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.knot.version", nil, nil, &out); err != nil {
26
+
return nil, err
27
+
}
28
+
29
+
return &out, nil
30
+
}
+4
-7
api/tangled/pullcomment.go
+4
-7
api/tangled/pullcomment.go
···
17
17
} //
18
18
// RECORDTYPE: RepoPullComment
19
19
type RepoPullComment struct {
20
-
LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull.comment" cborgen:"$type,const=sh.tangled.repo.pull.comment"`
21
-
Body string `json:"body" cborgen:"body"`
22
-
CommentId *int64 `json:"commentId,omitempty" cborgen:"commentId,omitempty"`
23
-
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
24
-
Owner *string `json:"owner,omitempty" cborgen:"owner,omitempty"`
25
-
Pull string `json:"pull" cborgen:"pull"`
26
-
Repo *string `json:"repo,omitempty" cborgen:"repo,omitempty"`
20
+
LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull.comment" cborgen:"$type,const=sh.tangled.repo.pull.comment"`
21
+
Body string `json:"body" cborgen:"body"`
22
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
23
+
Pull string `json:"pull" cborgen:"pull"`
27
24
}
+41
api/tangled/repoarchive.go
+41
api/tangled/repoarchive.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.repo.archive
6
+
7
+
import (
8
+
"bytes"
9
+
"context"
10
+
11
+
"github.com/bluesky-social/indigo/lex/util"
12
+
)
13
+
14
+
const (
15
+
RepoArchiveNSID = "sh.tangled.repo.archive"
16
+
)
17
+
18
+
// RepoArchive calls the XRPC method "sh.tangled.repo.archive".
19
+
//
20
+
// format: Archive format
21
+
// prefix: Prefix for files in the archive
22
+
// ref: Git reference (branch, tag, or commit SHA)
23
+
// repo: Repository identifier in format 'did:plc:.../repoName'
24
+
func RepoArchive(ctx context.Context, c util.LexClient, format string, prefix string, ref string, repo string) ([]byte, error) {
25
+
buf := new(bytes.Buffer)
26
+
27
+
params := map[string]interface{}{}
28
+
if format != "" {
29
+
params["format"] = format
30
+
}
31
+
if prefix != "" {
32
+
params["prefix"] = prefix
33
+
}
34
+
params["ref"] = ref
35
+
params["repo"] = repo
36
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.archive", params, nil, buf); err != nil {
37
+
return nil, err
38
+
}
39
+
40
+
return buf.Bytes(), nil
41
+
}
+80
api/tangled/repoblob.go
+80
api/tangled/repoblob.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.repo.blob
6
+
7
+
import (
8
+
"context"
9
+
10
+
"github.com/bluesky-social/indigo/lex/util"
11
+
)
12
+
13
+
const (
14
+
RepoBlobNSID = "sh.tangled.repo.blob"
15
+
)
16
+
17
+
// RepoBlob_LastCommit is a "lastCommit" in the sh.tangled.repo.blob schema.
18
+
type RepoBlob_LastCommit struct {
19
+
Author *RepoBlob_Signature `json:"author,omitempty" cborgen:"author,omitempty"`
20
+
// hash: Commit hash
21
+
Hash string `json:"hash" cborgen:"hash"`
22
+
// message: Commit message
23
+
Message string `json:"message" cborgen:"message"`
24
+
// shortHash: Short commit hash
25
+
ShortHash *string `json:"shortHash,omitempty" cborgen:"shortHash,omitempty"`
26
+
// when: Commit timestamp
27
+
When string `json:"when" cborgen:"when"`
28
+
}
29
+
30
+
// RepoBlob_Output is the output of a sh.tangled.repo.blob call.
31
+
type RepoBlob_Output struct {
32
+
// content: File content (base64 encoded for binary files)
33
+
Content string `json:"content" cborgen:"content"`
34
+
// encoding: Content encoding
35
+
Encoding *string `json:"encoding,omitempty" cborgen:"encoding,omitempty"`
36
+
// isBinary: Whether the file is binary
37
+
IsBinary *bool `json:"isBinary,omitempty" cborgen:"isBinary,omitempty"`
38
+
LastCommit *RepoBlob_LastCommit `json:"lastCommit,omitempty" cborgen:"lastCommit,omitempty"`
39
+
// mimeType: MIME type of the file
40
+
MimeType *string `json:"mimeType,omitempty" cborgen:"mimeType,omitempty"`
41
+
// path: The file path
42
+
Path string `json:"path" cborgen:"path"`
43
+
// ref: The git reference used
44
+
Ref string `json:"ref" cborgen:"ref"`
45
+
// size: File size in bytes
46
+
Size *int64 `json:"size,omitempty" cborgen:"size,omitempty"`
47
+
}
48
+
49
+
// RepoBlob_Signature is a "signature" in the sh.tangled.repo.blob schema.
50
+
type RepoBlob_Signature struct {
51
+
// email: Author email
52
+
Email string `json:"email" cborgen:"email"`
53
+
// name: Author name
54
+
Name string `json:"name" cborgen:"name"`
55
+
// when: Author timestamp
56
+
When string `json:"when" cborgen:"when"`
57
+
}
58
+
59
+
// RepoBlob calls the XRPC method "sh.tangled.repo.blob".
60
+
//
61
+
// path: Path to the file within the repository
62
+
// raw: Return raw file content instead of JSON response
63
+
// ref: Git reference (branch, tag, or commit SHA)
64
+
// repo: Repository identifier in format 'did:plc:.../repoName'
65
+
func RepoBlob(ctx context.Context, c util.LexClient, path string, raw bool, ref string, repo string) (*RepoBlob_Output, error) {
66
+
var out RepoBlob_Output
67
+
68
+
params := map[string]interface{}{}
69
+
params["path"] = path
70
+
if raw {
71
+
params["raw"] = raw
72
+
}
73
+
params["ref"] = ref
74
+
params["repo"] = repo
75
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.blob", params, nil, &out); err != nil {
76
+
return nil, err
77
+
}
78
+
79
+
return &out, nil
80
+
}
+59
api/tangled/repobranch.go
+59
api/tangled/repobranch.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.repo.branch
6
+
7
+
import (
8
+
"context"
9
+
10
+
"github.com/bluesky-social/indigo/lex/util"
11
+
)
12
+
13
+
const (
14
+
RepoBranchNSID = "sh.tangled.repo.branch"
15
+
)
16
+
17
+
// RepoBranch_Output is the output of a sh.tangled.repo.branch call.
18
+
type RepoBranch_Output struct {
19
+
Author *RepoBranch_Signature `json:"author,omitempty" cborgen:"author,omitempty"`
20
+
// hash: Latest commit hash on this branch
21
+
Hash string `json:"hash" cborgen:"hash"`
22
+
// isDefault: Whether this is the default branch
23
+
IsDefault *bool `json:"isDefault,omitempty" cborgen:"isDefault,omitempty"`
24
+
// message: Latest commit message
25
+
Message *string `json:"message,omitempty" cborgen:"message,omitempty"`
26
+
// name: Branch name
27
+
Name string `json:"name" cborgen:"name"`
28
+
// shortHash: Short commit hash
29
+
ShortHash *string `json:"shortHash,omitempty" cborgen:"shortHash,omitempty"`
30
+
// when: Timestamp of latest commit
31
+
When string `json:"when" cborgen:"when"`
32
+
}
33
+
34
+
// RepoBranch_Signature is a "signature" in the sh.tangled.repo.branch schema.
35
+
type RepoBranch_Signature struct {
36
+
// email: Author email
37
+
Email string `json:"email" cborgen:"email"`
38
+
// name: Author name
39
+
Name string `json:"name" cborgen:"name"`
40
+
// when: Author timestamp
41
+
When string `json:"when" cborgen:"when"`
42
+
}
43
+
44
+
// RepoBranch calls the XRPC method "sh.tangled.repo.branch".
45
+
//
46
+
// name: Branch name to get information for
47
+
// repo: Repository identifier in format 'did:plc:.../repoName'
48
+
func RepoBranch(ctx context.Context, c util.LexClient, name string, repo string) (*RepoBranch_Output, error) {
49
+
var out RepoBranch_Output
50
+
51
+
params := map[string]interface{}{}
52
+
params["name"] = name
53
+
params["repo"] = repo
54
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.branch", params, nil, &out); err != nil {
55
+
return nil, err
56
+
}
57
+
58
+
return &out, nil
59
+
}
+39
api/tangled/repobranches.go
+39
api/tangled/repobranches.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.repo.branches
6
+
7
+
import (
8
+
"bytes"
9
+
"context"
10
+
11
+
"github.com/bluesky-social/indigo/lex/util"
12
+
)
13
+
14
+
const (
15
+
RepoBranchesNSID = "sh.tangled.repo.branches"
16
+
)
17
+
18
+
// RepoBranches calls the XRPC method "sh.tangled.repo.branches".
19
+
//
20
+
// cursor: Pagination cursor
21
+
// limit: Maximum number of branches to return
22
+
// repo: Repository identifier in format 'did:plc:.../repoName'
23
+
func RepoBranches(ctx context.Context, c util.LexClient, cursor string, limit int64, repo string) ([]byte, error) {
24
+
buf := new(bytes.Buffer)
25
+
26
+
params := map[string]interface{}{}
27
+
if cursor != "" {
28
+
params["cursor"] = cursor
29
+
}
30
+
if limit != 0 {
31
+
params["limit"] = limit
32
+
}
33
+
params["repo"] = repo
34
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.branches", params, nil, buf); err != nil {
35
+
return nil, err
36
+
}
37
+
38
+
return buf.Bytes(), nil
39
+
}
+35
api/tangled/repocompare.go
+35
api/tangled/repocompare.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.repo.compare
6
+
7
+
import (
8
+
"bytes"
9
+
"context"
10
+
11
+
"github.com/bluesky-social/indigo/lex/util"
12
+
)
13
+
14
+
const (
15
+
RepoCompareNSID = "sh.tangled.repo.compare"
16
+
)
17
+
18
+
// RepoCompare calls the XRPC method "sh.tangled.repo.compare".
19
+
//
20
+
// repo: Repository identifier in format 'did:plc:.../repoName'
21
+
// rev1: First revision (commit, branch, or tag)
22
+
// rev2: Second revision (commit, branch, or tag)
23
+
func RepoCompare(ctx context.Context, c util.LexClient, repo string, rev1 string, rev2 string) ([]byte, error) {
24
+
buf := new(bytes.Buffer)
25
+
26
+
params := map[string]interface{}{}
27
+
params["repo"] = repo
28
+
params["rev1"] = rev1
29
+
params["rev2"] = rev2
30
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.compare", params, nil, buf); err != nil {
31
+
return nil, err
32
+
}
33
+
34
+
return buf.Bytes(), nil
35
+
}
+33
api/tangled/repodiff.go
+33
api/tangled/repodiff.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.repo.diff
6
+
7
+
import (
8
+
"bytes"
9
+
"context"
10
+
11
+
"github.com/bluesky-social/indigo/lex/util"
12
+
)
13
+
14
+
const (
15
+
RepoDiffNSID = "sh.tangled.repo.diff"
16
+
)
17
+
18
+
// RepoDiff calls the XRPC method "sh.tangled.repo.diff".
19
+
//
20
+
// ref: Git reference (branch, tag, or commit SHA)
21
+
// repo: Repository identifier in format 'did:plc:.../repoName'
22
+
func RepoDiff(ctx context.Context, c util.LexClient, ref string, repo string) ([]byte, error) {
23
+
buf := new(bytes.Buffer)
24
+
25
+
params := map[string]interface{}{}
26
+
params["ref"] = ref
27
+
params["repo"] = repo
28
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.diff", params, nil, buf); err != nil {
29
+
return nil, err
30
+
}
31
+
32
+
return buf.Bytes(), nil
33
+
}
+55
api/tangled/repogetDefaultBranch.go
+55
api/tangled/repogetDefaultBranch.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.repo.getDefaultBranch
6
+
7
+
import (
8
+
"context"
9
+
10
+
"github.com/bluesky-social/indigo/lex/util"
11
+
)
12
+
13
+
const (
14
+
RepoGetDefaultBranchNSID = "sh.tangled.repo.getDefaultBranch"
15
+
)
16
+
17
+
// RepoGetDefaultBranch_Output is the output of a sh.tangled.repo.getDefaultBranch call.
18
+
type RepoGetDefaultBranch_Output struct {
19
+
Author *RepoGetDefaultBranch_Signature `json:"author,omitempty" cborgen:"author,omitempty"`
20
+
// hash: Latest commit hash on default branch
21
+
Hash string `json:"hash" cborgen:"hash"`
22
+
// message: Latest commit message
23
+
Message *string `json:"message,omitempty" cborgen:"message,omitempty"`
24
+
// name: Default branch name
25
+
Name string `json:"name" cborgen:"name"`
26
+
// shortHash: Short commit hash
27
+
ShortHash *string `json:"shortHash,omitempty" cborgen:"shortHash,omitempty"`
28
+
// when: Timestamp of latest commit
29
+
When string `json:"when" cborgen:"when"`
30
+
}
31
+
32
+
// RepoGetDefaultBranch_Signature is a "signature" in the sh.tangled.repo.getDefaultBranch schema.
33
+
type RepoGetDefaultBranch_Signature struct {
34
+
// email: Author email
35
+
Email string `json:"email" cborgen:"email"`
36
+
// name: Author name
37
+
Name string `json:"name" cborgen:"name"`
38
+
// when: Author timestamp
39
+
When string `json:"when" cborgen:"when"`
40
+
}
41
+
42
+
// RepoGetDefaultBranch calls the XRPC method "sh.tangled.repo.getDefaultBranch".
43
+
//
44
+
// repo: Repository identifier in format 'did:plc:.../repoName'
45
+
func RepoGetDefaultBranch(ctx context.Context, c util.LexClient, repo string) (*RepoGetDefaultBranch_Output, error) {
46
+
var out RepoGetDefaultBranch_Output
47
+
48
+
params := map[string]interface{}{}
49
+
params["repo"] = repo
50
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.getDefaultBranch", params, nil, &out); err != nil {
51
+
return nil, err
52
+
}
53
+
54
+
return &out, nil
55
+
}
-2
api/tangled/repoissue.go
-2
api/tangled/repoissue.go
···
20
20
LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue" cborgen:"$type,const=sh.tangled.repo.issue"`
21
21
Body *string `json:"body,omitempty" cborgen:"body,omitempty"`
22
22
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
23
-
IssueId int64 `json:"issueId" cborgen:"issueId"`
24
-
Owner string `json:"owner" cborgen:"owner"`
25
23
Repo string `json:"repo" cborgen:"repo"`
26
24
Title string `json:"title" cborgen:"title"`
27
25
}
+61
api/tangled/repolanguages.go
+61
api/tangled/repolanguages.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.repo.languages
6
+
7
+
import (
8
+
"context"
9
+
10
+
"github.com/bluesky-social/indigo/lex/util"
11
+
)
12
+
13
+
const (
14
+
RepoLanguagesNSID = "sh.tangled.repo.languages"
15
+
)
16
+
17
+
// RepoLanguages_Language is a "language" in the sh.tangled.repo.languages schema.
18
+
type RepoLanguages_Language struct {
19
+
// color: Hex color code for this language
20
+
Color *string `json:"color,omitempty" cborgen:"color,omitempty"`
21
+
// extensions: File extensions associated with this language
22
+
Extensions []string `json:"extensions,omitempty" cborgen:"extensions,omitempty"`
23
+
// fileCount: Number of files in this language
24
+
FileCount *int64 `json:"fileCount,omitempty" cborgen:"fileCount,omitempty"`
25
+
// name: Programming language name
26
+
Name string `json:"name" cborgen:"name"`
27
+
// percentage: Percentage of total codebase (0-100)
28
+
Percentage int64 `json:"percentage" cborgen:"percentage"`
29
+
// size: Total size of files in this language (bytes)
30
+
Size int64 `json:"size" cborgen:"size"`
31
+
}
32
+
33
+
// RepoLanguages_Output is the output of a sh.tangled.repo.languages call.
34
+
type RepoLanguages_Output struct {
35
+
Languages []*RepoLanguages_Language `json:"languages" cborgen:"languages"`
36
+
// ref: The git reference used
37
+
Ref string `json:"ref" cborgen:"ref"`
38
+
// totalFiles: Total number of files analyzed
39
+
TotalFiles *int64 `json:"totalFiles,omitempty" cborgen:"totalFiles,omitempty"`
40
+
// totalSize: Total size of all analyzed files in bytes
41
+
TotalSize *int64 `json:"totalSize,omitempty" cborgen:"totalSize,omitempty"`
42
+
}
43
+
44
+
// RepoLanguages calls the XRPC method "sh.tangled.repo.languages".
45
+
//
46
+
// ref: Git reference (branch, tag, or commit SHA)
47
+
// repo: Repository identifier in format 'did:plc:.../repoName'
48
+
func RepoLanguages(ctx context.Context, c util.LexClient, ref string, repo string) (*RepoLanguages_Output, error) {
49
+
var out RepoLanguages_Output
50
+
51
+
params := map[string]interface{}{}
52
+
if ref != "" {
53
+
params["ref"] = ref
54
+
}
55
+
params["repo"] = repo
56
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.languages", params, nil, &out); err != nil {
57
+
return nil, err
58
+
}
59
+
60
+
return &out, nil
61
+
}
+45
api/tangled/repolog.go
+45
api/tangled/repolog.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.repo.log
6
+
7
+
import (
8
+
"bytes"
9
+
"context"
10
+
11
+
"github.com/bluesky-social/indigo/lex/util"
12
+
)
13
+
14
+
const (
15
+
RepoLogNSID = "sh.tangled.repo.log"
16
+
)
17
+
18
+
// RepoLog calls the XRPC method "sh.tangled.repo.log".
19
+
//
20
+
// cursor: Pagination cursor (commit SHA)
21
+
// limit: Maximum number of commits to return
22
+
// path: Path to filter commits by
23
+
// ref: Git reference (branch, tag, or commit SHA)
24
+
// repo: Repository identifier in format 'did:plc:.../repoName'
25
+
func RepoLog(ctx context.Context, c util.LexClient, cursor string, limit int64, path string, ref string, repo string) ([]byte, error) {
26
+
buf := new(bytes.Buffer)
27
+
28
+
params := map[string]interface{}{}
29
+
if cursor != "" {
30
+
params["cursor"] = cursor
31
+
}
32
+
if limit != 0 {
33
+
params["limit"] = limit
34
+
}
35
+
if path != "" {
36
+
params["path"] = path
37
+
}
38
+
params["ref"] = ref
39
+
params["repo"] = repo
40
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.log", params, nil, buf); err != nil {
41
+
return nil, err
42
+
}
43
+
44
+
return buf.Bytes(), nil
45
+
}
+7
-3
api/tangled/repopull.go
+7
-3
api/tangled/repopull.go
···
21
21
Body *string `json:"body,omitempty" cborgen:"body,omitempty"`
22
22
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
23
23
Patch string `json:"patch" cborgen:"patch"`
24
-
PullId int64 `json:"pullId" cborgen:"pullId"`
25
24
Source *RepoPull_Source `json:"source,omitempty" cborgen:"source,omitempty"`
26
-
TargetBranch string `json:"targetBranch" cborgen:"targetBranch"`
27
-
TargetRepo string `json:"targetRepo" cborgen:"targetRepo"`
25
+
Target *RepoPull_Target `json:"target" cborgen:"target"`
28
26
Title string `json:"title" cborgen:"title"`
29
27
}
30
28
···
34
32
Repo *string `json:"repo,omitempty" cborgen:"repo,omitempty"`
35
33
Sha string `json:"sha" cborgen:"sha"`
36
34
}
35
+
36
+
// RepoPull_Target is a "target" in the sh.tangled.repo.pull schema.
37
+
type RepoPull_Target struct {
38
+
Branch string `json:"branch" cborgen:"branch"`
39
+
Repo string `json:"repo" cborgen:"repo"`
40
+
}
+72
api/tangled/repotree.go
+72
api/tangled/repotree.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.repo.tree
6
+
7
+
import (
8
+
"context"
9
+
10
+
"github.com/bluesky-social/indigo/lex/util"
11
+
)
12
+
13
+
const (
14
+
RepoTreeNSID = "sh.tangled.repo.tree"
15
+
)
16
+
17
+
// RepoTree_LastCommit is a "lastCommit" in the sh.tangled.repo.tree schema.
18
+
type RepoTree_LastCommit struct {
19
+
// hash: Commit hash
20
+
Hash string `json:"hash" cborgen:"hash"`
21
+
// message: Commit message
22
+
Message string `json:"message" cborgen:"message"`
23
+
// when: Commit timestamp
24
+
When string `json:"when" cborgen:"when"`
25
+
}
26
+
27
+
// RepoTree_Output is the output of a sh.tangled.repo.tree call.
28
+
type RepoTree_Output struct {
29
+
// dotdot: Parent directory path
30
+
Dotdot *string `json:"dotdot,omitempty" cborgen:"dotdot,omitempty"`
31
+
Files []*RepoTree_TreeEntry `json:"files" cborgen:"files"`
32
+
// parent: The parent path in the tree
33
+
Parent *string `json:"parent,omitempty" cborgen:"parent,omitempty"`
34
+
// ref: The git reference used
35
+
Ref string `json:"ref" cborgen:"ref"`
36
+
}
37
+
38
+
// RepoTree_TreeEntry is a "treeEntry" in the sh.tangled.repo.tree schema.
39
+
type RepoTree_TreeEntry struct {
40
+
// is_file: Whether this entry is a file
41
+
Is_file bool `json:"is_file" cborgen:"is_file"`
42
+
// is_subtree: Whether this entry is a directory/subtree
43
+
Is_subtree bool `json:"is_subtree" cborgen:"is_subtree"`
44
+
Last_commit *RepoTree_LastCommit `json:"last_commit,omitempty" cborgen:"last_commit,omitempty"`
45
+
// mode: File mode
46
+
Mode string `json:"mode" cborgen:"mode"`
47
+
// name: Relative file or directory name
48
+
Name string `json:"name" cborgen:"name"`
49
+
// size: File size in bytes
50
+
Size int64 `json:"size" cborgen:"size"`
51
+
}
52
+
53
+
// RepoTree calls the XRPC method "sh.tangled.repo.tree".
54
+
//
55
+
// path: Path within the repository tree
56
+
// ref: Git reference (branch, tag, or commit SHA)
57
+
// repo: Repository identifier in format 'did:plc:.../repoName'
58
+
func RepoTree(ctx context.Context, c util.LexClient, path string, ref string, repo string) (*RepoTree_Output, error) {
59
+
var out RepoTree_Output
60
+
61
+
params := map[string]interface{}{}
62
+
if path != "" {
63
+
params["path"] = path
64
+
}
65
+
params["ref"] = ref
66
+
params["repo"] = repo
67
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.tree", params, nil, &out); err != nil {
68
+
return nil, err
69
+
}
70
+
71
+
return &out, nil
72
+
}
+30
api/tangled/tangledowner.go
+30
api/tangled/tangledowner.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.owner
6
+
7
+
import (
8
+
"context"
9
+
10
+
"github.com/bluesky-social/indigo/lex/util"
11
+
)
12
+
13
+
const (
14
+
OwnerNSID = "sh.tangled.owner"
15
+
)
16
+
17
+
// Owner_Output is the output of a sh.tangled.owner call.
18
+
type Owner_Output struct {
19
+
Owner string `json:"owner" cborgen:"owner"`
20
+
}
21
+
22
+
// Owner calls the XRPC method "sh.tangled.owner".
23
+
func Owner(ctx context.Context, c util.LexClient) (*Owner_Output, error) {
24
+
var out Owner_Output
25
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.owner", nil, nil, &out); err != nil {
26
+
return nil, err
27
+
}
28
+
29
+
return &out, nil
30
+
}
+1
-1
appview/config/config.go
+1
-1
appview/config/config.go
+169
appview/db/db.go
+169
appview/db/db.go
···
703
703
return err
704
704
})
705
705
706
+
// repurpose the read-only column to "needs-upgrade"
707
+
runMigration(conn, "rename-registrations-read-only-to-needs-upgrade", func(tx *sql.Tx) error {
708
+
_, err := tx.Exec(`
709
+
alter table registrations rename column read_only to needs_upgrade;
710
+
`)
711
+
return err
712
+
})
713
+
714
+
// require all knots to upgrade after the release of total xrpc
715
+
runMigration(conn, "migrate-knots-to-total-xrpc", func(tx *sql.Tx) error {
716
+
_, err := tx.Exec(`
717
+
update registrations set needs_upgrade = 1;
718
+
`)
719
+
return err
720
+
})
721
+
722
+
// require all knots to upgrade after the release of total xrpc
723
+
runMigration(conn, "migrate-spindles-to-xrpc-owner", func(tx *sql.Tx) error {
724
+
_, err := tx.Exec(`
725
+
alter table spindles add column needs_upgrade integer not null default 0;
726
+
`)
727
+
if err != nil {
728
+
return err
729
+
}
730
+
731
+
_, err = tx.Exec(`
732
+
update spindles set needs_upgrade = 1;
733
+
`)
734
+
return err
735
+
})
736
+
737
+
// remove issue_at from issues and replace with generated column
738
+
//
739
+
// this requires a full table recreation because stored columns
740
+
// cannot be added via alter
741
+
//
742
+
// couple other changes:
743
+
// - columns renamed to be more consistent
744
+
// - adds edited and deleted fields
745
+
//
746
+
// disable foreign-keys for the next migration
747
+
conn.ExecContext(ctx, "pragma foreign_keys = off;")
748
+
runMigration(conn, "remove-issue-at-from-issues", func(tx *sql.Tx) error {
749
+
_, err := tx.Exec(`
750
+
create table if not exists issues_new (
751
+
-- identifiers
752
+
id integer primary key autoincrement,
753
+
did text not null,
754
+
rkey text not null,
755
+
at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.repo.issue' || '/' || rkey) stored,
756
+
757
+
-- at identifiers
758
+
repo_at text not null,
759
+
760
+
-- content
761
+
issue_id integer not null,
762
+
title text not null,
763
+
body text not null,
764
+
open integer not null default 1,
765
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
766
+
edited text, -- timestamp
767
+
deleted text, -- timestamp
768
+
769
+
unique(did, rkey),
770
+
unique(repo_at, issue_id),
771
+
unique(at_uri),
772
+
foreign key (repo_at) references repos(at_uri) on delete cascade
773
+
);
774
+
`)
775
+
if err != nil {
776
+
return err
777
+
}
778
+
779
+
// transfer data
780
+
_, err = tx.Exec(`
781
+
insert into issues_new (id, did, rkey, repo_at, issue_id, title, body, open, created)
782
+
select
783
+
i.id,
784
+
i.owner_did,
785
+
i.rkey,
786
+
i.repo_at,
787
+
i.issue_id,
788
+
i.title,
789
+
i.body,
790
+
i.open,
791
+
i.created
792
+
from issues i;
793
+
`)
794
+
if err != nil {
795
+
return err
796
+
}
797
+
798
+
// drop old table
799
+
_, err = tx.Exec(`drop table issues`)
800
+
if err != nil {
801
+
return err
802
+
}
803
+
804
+
// rename new table
805
+
_, err = tx.Exec(`alter table issues_new rename to issues`)
806
+
return err
807
+
})
808
+
conn.ExecContext(ctx, "pragma foreign_keys = on;")
809
+
810
+
// - renames the comments table to 'issue_comments'
811
+
// - rework issue comments to update constraints:
812
+
// * unique(did, rkey)
813
+
// * remove comment-id and just use the global ID
814
+
// * foreign key (repo_at, issue_id)
815
+
// - new columns
816
+
// * column "reply_to" which can be any other comment
817
+
// * column "at-uri" which is a generated column
818
+
runMigration(conn, "rework-issue-comments", func(tx *sql.Tx) error {
819
+
_, err := tx.Exec(`
820
+
create table if not exists issue_comments (
821
+
-- identifiers
822
+
id integer primary key autoincrement,
823
+
did text not null,
824
+
rkey text,
825
+
at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.repo.issue.comment' || '/' || rkey) stored,
826
+
827
+
-- at identifiers
828
+
issue_at text not null,
829
+
reply_to text, -- at_uri of parent comment
830
+
831
+
-- content
832
+
body text not null,
833
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
834
+
edited text,
835
+
deleted text,
836
+
837
+
-- constraints
838
+
unique(did, rkey),
839
+
unique(at_uri),
840
+
foreign key (issue_at) references issues(at_uri) on delete cascade
841
+
);
842
+
`)
843
+
if err != nil {
844
+
return err
845
+
}
846
+
847
+
// transfer data
848
+
_, err = tx.Exec(`
849
+
insert into issue_comments (id, did, rkey, issue_at, body, created, edited, deleted)
850
+
select
851
+
c.id,
852
+
c.owner_did,
853
+
c.rkey,
854
+
i.at_uri, -- get at_uri from issues table
855
+
c.body,
856
+
c.created,
857
+
c.edited,
858
+
c.deleted
859
+
from comments c
860
+
join issues i on c.repo_at = i.repo_at and c.issue_id = i.issue_id;
861
+
`)
862
+
if err != nil {
863
+
return err
864
+
}
865
+
866
+
// drop old table
867
+
_, err = tx.Exec(`drop table comments`)
868
+
return err
869
+
})
870
+
706
871
return &DB{db}, nil
707
872
}
708
873
···
747
912
}
748
913
749
914
return nil
915
+
}
916
+
917
+
func (d *DB) Close() error {
918
+
return d.DB.Close()
750
919
}
751
920
752
921
type filter struct {
+4
-4
appview/db/follow.go
+4
-4
appview/db/follow.go
···
56
56
}
57
57
58
58
type FollowStats struct {
59
-
Followers int
60
-
Following int
59
+
Followers int64
60
+
Following int64
61
61
}
62
62
63
63
func GetFollowerFollowingCount(e Execer, did string) (FollowStats, error) {
64
-
followers, following := 0, 0
64
+
var followers, following int64
65
65
err := e.QueryRow(
66
66
`SELECT
67
67
COUNT(CASE WHEN subject_did = ? THEN 1 END) AS followers,
···
122
122
123
123
for rows.Next() {
124
124
var did string
125
-
var followers, following int
125
+
var followers, following int64
126
126
if err := rows.Scan(&did, &followers, &following); err != nil {
127
127
return nil, err
128
128
}
+430
-368
appview/db/issues.go
+430
-368
appview/db/issues.go
···
3
3
import (
4
4
"database/sql"
5
5
"fmt"
6
+
"maps"
7
+
"slices"
8
+
"sort"
6
9
"strings"
7
10
"time"
8
11
···
12
15
)
13
16
14
17
type Issue struct {
15
-
ID int64
16
-
RepoAt syntax.ATURI
17
-
OwnerDid string
18
-
IssueId int
19
-
Rkey string
20
-
Created time.Time
21
-
Title string
22
-
Body string
23
-
Open bool
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
24
29
25
30
// optionally, populate this when querying for reverse mappings
26
31
// like comment counts, parent repo etc.
27
-
Metadata *IssueMetadata
32
+
Comments []IssueComment
33
+
Repo *Repo
28
34
}
29
35
30
-
type IssueMetadata struct {
31
-
CommentCount int
32
-
Repo *Repo
33
-
// labels, assignee etc.
36
+
func (i *Issue) AtUri() syntax.ATURI {
37
+
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueNSID, i.Rkey))
34
38
}
35
39
36
-
type Comment struct {
37
-
OwnerDid string
38
-
RepoAt syntax.ATURI
39
-
Rkey string
40
-
Issue int
41
-
CommentId int
42
-
Body string
43
-
Created *time.Time
44
-
Deleted *time.Time
45
-
Edited *time.Time
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
+
}
46
47
}
47
48
48
-
func (i *Issue) AtUri() syntax.ATURI {
49
-
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.OwnerDid, tangled.RepoIssueNSID, i.Rkey))
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
50
59
}
51
60
52
-
func NewIssue(tx *sql.Tx, issue *Issue) error {
53
-
defer tx.Rollback()
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
54
65
55
-
_, err := tx.Exec(`
56
-
insert or ignore into repo_issue_seqs (repo_at, next_issue_id)
57
-
values (?, 1)
58
-
`, issue.RepoAt)
59
-
if err != nil {
60
-
return err
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
+
}
61
75
}
62
76
63
-
var nextId int
64
-
err = tx.QueryRow(`
65
-
update repo_issue_seqs
66
-
set next_issue_id = next_issue_id + 1
67
-
where repo_at = ?
68
-
returning next_issue_id - 1
69
-
`, issue.RepoAt).Scan(&nextId)
70
-
if err != nil {
71
-
return err
77
+
for _, r := range replies {
78
+
parentAt := *r.ReplyTo
79
+
if parent, exists := toplevel[parentAt]; exists {
80
+
parent.Replies = append(parent.Replies, r)
81
+
}
72
82
}
73
83
74
-
issue.IssueId = nextId
84
+
var listing []CommentListItem
85
+
for _, v := range toplevel {
86
+
listing = append(listing, *v)
87
+
}
75
88
76
-
res, err := tx.Exec(`
77
-
insert into issues (repo_at, owner_did, rkey, issue_at, issue_id, title, body)
78
-
values (?, ?, ?, ?, ?, ?, ?)
79
-
`, issue.RepoAt, issue.OwnerDid, issue.Rkey, issue.AtUri(), issue.IssueId, issue.Title, issue.Body)
80
-
if err != nil {
81
-
return err
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
+
})
82
100
}
83
101
84
-
lastID, err := res.LastInsertId()
102
+
return listing
103
+
}
104
+
105
+
func IssueFromRecord(did, rkey string, record tangled.RepoIssue) Issue {
106
+
created, err := time.Parse(time.RFC3339, record.CreatedAt)
85
107
if err != nil {
86
-
return err
108
+
created = time.Now()
87
109
}
88
-
issue.ID = lastID
89
110
90
-
if err := tx.Commit(); err != nil {
91
-
return err
111
+
body := ""
112
+
if record.Body != nil {
113
+
body = *record.Body
92
114
}
93
115
94
-
return nil
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
+
}
95
125
}
96
126
97
-
func GetIssueAt(e Execer, repoAt syntax.ATURI, issueId int) (string, error) {
98
-
var issueAt string
99
-
err := e.QueryRow(`select issue_at from issues where repo_at = ? and issue_id = ?`, repoAt, issueId).Scan(&issueAt)
100
-
return issueAt, err
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
101
137
}
102
138
103
-
func GetIssueOwnerDid(e Execer, repoAt syntax.ATURI, issueId int) (string, error) {
104
-
var ownerDid string
105
-
err := e.QueryRow(`select owner_did from issues where repo_at = ? and issue_id = ?`, repoAt, issueId).Scan(&ownerDid)
106
-
return ownerDid, err
139
+
func (i *IssueComment) AtUri() syntax.ATURI {
140
+
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueCommentNSID, i.Rkey))
107
141
}
108
142
109
-
func GetIssuesPaginated(e Execer, repoAt syntax.ATURI, isOpen bool, page pagination.Page) ([]Issue, error) {
110
-
var issues []Issue
111
-
openValue := 0
112
-
if isOpen {
113
-
openValue = 1
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,
114
149
}
150
+
}
115
151
116
-
rows, err := e.Query(
117
-
`
118
-
with numbered_issue as (
119
-
select
120
-
i.id,
121
-
i.owner_did,
122
-
i.rkey,
123
-
i.issue_id,
124
-
i.created,
125
-
i.title,
126
-
i.body,
127
-
i.open,
128
-
count(c.id) as comment_count,
129
-
row_number() over (order by i.created desc) as row_num
130
-
from
131
-
issues i
132
-
left join
133
-
comments c on i.repo_at = c.repo_at and i.issue_id = c.issue_id
134
-
where
135
-
i.repo_at = ? and i.open = ?
136
-
group by
137
-
i.id, i.owner_did, i.issue_id, i.created, i.title, i.body, i.open
138
-
)
139
-
select
140
-
id,
141
-
owner_did,
142
-
rkey,
143
-
issue_id,
144
-
created,
145
-
title,
146
-
body,
147
-
open,
148
-
comment_count
149
-
from
150
-
numbered_issue
151
-
where
152
-
row_num between ? and ?`,
153
-
repoAt, openValue, page.Offset+1, page.Offset+page.Limit)
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)
154
158
if err != nil {
159
+
created = time.Now()
160
+
}
161
+
162
+
ownerDid := did
163
+
164
+
if _, err = syntax.ParseATURI(record.Issue); err != nil {
155
165
return nil, err
156
166
}
157
-
defer rows.Close()
158
167
159
-
for rows.Next() {
160
-
var issue Issue
161
-
var createdAt string
162
-
var metadata IssueMetadata
163
-
err := rows.Scan(&issue.ID, &issue.OwnerDid, &issue.Rkey, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &metadata.CommentCount)
164
-
if err != nil {
165
-
return nil, err
166
-
}
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
+
}
167
179
168
-
createdTime, err := time.Parse(time.RFC3339, createdAt)
169
-
if err != nil {
170
-
return nil, err
180
+
func PutIssue(tx *sql.Tx, issue *Issue) error {
181
+
// ensure sequence exists
182
+
_, err := tx.Exec(`
183
+
insert or ignore into repo_issue_seqs (repo_at, next_issue_id)
184
+
values (?, 1)
185
+
`, issue.RepoAt)
186
+
if err != nil {
187
+
return err
188
+
}
189
+
190
+
issues, err := GetIssues(
191
+
tx,
192
+
FilterEq("did", issue.Did),
193
+
FilterEq("rkey", issue.Rkey),
194
+
)
195
+
switch {
196
+
case err != nil:
197
+
return err
198
+
case len(issues) == 0:
199
+
return createNewIssue(tx, issue)
200
+
case len(issues) != 1: // should be unreachable
201
+
return fmt.Errorf("invalid number of issues returned: %d", len(issues))
202
+
default:
203
+
// if content is identical, do not edit
204
+
existingIssue := issues[0]
205
+
if existingIssue.Title == issue.Title && existingIssue.Body == issue.Body {
206
+
return nil
171
207
}
172
-
issue.Created = createdTime
173
-
issue.Metadata = &metadata
174
208
175
-
issues = append(issues, issue)
209
+
issue.Id = existingIssue.Id
210
+
issue.IssueId = existingIssue.IssueId
211
+
return updateIssue(tx, issue)
176
212
}
213
+
}
177
214
178
-
if err := rows.Err(); err != nil {
179
-
return nil, err
215
+
func createNewIssue(tx *sql.Tx, issue *Issue) error {
216
+
// get next issue_id
217
+
var newIssueId int
218
+
err := tx.QueryRow(`
219
+
update repo_issue_seqs
220
+
set next_issue_id = next_issue_id + 1
221
+
where repo_at = ?
222
+
returning next_issue_id - 1
223
+
`, issue.RepoAt).Scan(&newIssueId)
224
+
if err != nil {
225
+
return err
180
226
}
181
227
182
-
return issues, nil
228
+
// insert new issue
229
+
row := tx.QueryRow(`
230
+
insert into issues (repo_at, did, rkey, issue_id, title, body)
231
+
values (?, ?, ?, ?, ?, ?)
232
+
returning rowid, issue_id
233
+
`, issue.RepoAt, issue.Did, issue.Rkey, newIssueId, issue.Title, issue.Body)
234
+
235
+
return row.Scan(&issue.Id, &issue.IssueId)
236
+
}
237
+
238
+
func updateIssue(tx *sql.Tx, issue *Issue) error {
239
+
// update existing issue
240
+
_, err := tx.Exec(`
241
+
update issues
242
+
set title = ?, body = ?, edited = ?
243
+
where did = ? and rkey = ?
244
+
`, issue.Title, issue.Body, time.Now().Format(time.RFC3339), issue.Did, issue.Rkey)
245
+
return err
183
246
}
184
247
185
-
func GetIssuesWithLimit(e Execer, limit int, filters ...filter) ([]Issue, error) {
186
-
issues := make([]Issue, 0, limit)
248
+
func GetIssuesPaginated(e Execer, page pagination.Page, filters ...filter) ([]Issue, error) {
249
+
issueMap := make(map[string]*Issue) // at-uri -> issue
187
250
188
251
var conditions []string
189
252
var args []any
253
+
190
254
for _, filter := range filters {
191
255
conditions = append(conditions, filter.Condition())
192
256
args = append(args, filter.Arg()...)
···
196
260
if conditions != nil {
197
261
whereClause = " where " + strings.Join(conditions, " and ")
198
262
}
199
-
limitClause := ""
200
-
if limit != 0 {
201
-
limitClause = fmt.Sprintf(" limit %d ", limit)
202
-
}
263
+
264
+
pLower := FilterGte("row_num", page.Offset+1)
265
+
pUpper := FilterLte("row_num", page.Offset+page.Limit)
266
+
267
+
args = append(args, pLower.Arg()...)
268
+
args = append(args, pUpper.Arg()...)
269
+
pagination := " where " + pLower.Condition() + " and " + pUpper.Condition()
203
270
204
271
query := fmt.Sprintf(
205
-
`select
206
-
i.id,
207
-
i.owner_did,
208
-
i.repo_at,
209
-
i.issue_id,
210
-
i.created,
211
-
i.title,
212
-
i.body,
213
-
i.open
214
-
from
215
-
issues i
272
+
`
273
+
select * from (
274
+
select
275
+
id,
276
+
did,
277
+
rkey,
278
+
repo_at,
279
+
issue_id,
280
+
title,
281
+
body,
282
+
open,
283
+
created,
284
+
edited,
285
+
deleted,
286
+
row_number() over (order by created desc) as row_num
287
+
from
288
+
issues
289
+
%s
290
+
) ranked_issues
216
291
%s
217
-
order by
218
-
i.created desc
219
-
%s`,
220
-
whereClause, limitClause)
292
+
`,
293
+
whereClause,
294
+
pagination,
295
+
)
221
296
222
297
rows, err := e.Query(query, args...)
223
298
if err != nil {
224
-
return nil, err
299
+
return nil, fmt.Errorf("failed to query issues table: %w", err)
225
300
}
226
301
defer rows.Close()
227
302
228
303
for rows.Next() {
229
304
var issue Issue
230
-
var issueCreatedAt string
305
+
var createdAt string
306
+
var editedAt, deletedAt sql.Null[string]
307
+
var rowNum int64
231
308
err := rows.Scan(
232
-
&issue.ID,
233
-
&issue.OwnerDid,
309
+
&issue.Id,
310
+
&issue.Did,
311
+
&issue.Rkey,
234
312
&issue.RepoAt,
235
313
&issue.IssueId,
236
-
&issueCreatedAt,
237
314
&issue.Title,
238
315
&issue.Body,
239
316
&issue.Open,
317
+
&createdAt,
318
+
&editedAt,
319
+
&deletedAt,
320
+
&rowNum,
240
321
)
241
322
if err != nil {
242
-
return nil, err
323
+
return nil, fmt.Errorf("failed to scan issue: %w", err)
243
324
}
244
325
245
-
issueCreatedTime, err := time.Parse(time.RFC3339, issueCreatedAt)
246
-
if err != nil {
247
-
return nil, err
326
+
if t, err := time.Parse(time.RFC3339, createdAt); err == nil {
327
+
issue.Created = t
248
328
}
249
-
issue.Created = issueCreatedTime
250
329
251
-
issues = append(issues, issue)
252
-
}
253
-
254
-
if err := rows.Err(); err != nil {
255
-
return nil, err
256
-
}
330
+
if editedAt.Valid {
331
+
if t, err := time.Parse(time.RFC3339, editedAt.V); err == nil {
332
+
issue.Edited = &t
333
+
}
334
+
}
257
335
258
-
return issues, nil
259
-
}
336
+
if deletedAt.Valid {
337
+
if t, err := time.Parse(time.RFC3339, deletedAt.V); err == nil {
338
+
issue.Deleted = &t
339
+
}
340
+
}
260
341
261
-
func GetIssues(e Execer, filters ...filter) ([]Issue, error) {
262
-
return GetIssuesWithLimit(e, 0, filters...)
263
-
}
342
+
atUri := issue.AtUri().String()
343
+
issueMap[atUri] = &issue
344
+
}
264
345
265
-
// timeframe here is directly passed into the sql query filter, and any
266
-
// timeframe in the past should be negative; e.g.: "-3 months"
267
-
func GetIssuesByOwnerDid(e Execer, ownerDid string, timeframe string) ([]Issue, error) {
268
-
var issues []Issue
346
+
// collect reverse repos
347
+
repoAts := make([]string, 0, len(issueMap)) // or just []string{}
348
+
for _, issue := range issueMap {
349
+
repoAts = append(repoAts, string(issue.RepoAt))
350
+
}
269
351
270
-
rows, err := e.Query(
271
-
`select
272
-
i.id,
273
-
i.owner_did,
274
-
i.rkey,
275
-
i.repo_at,
276
-
i.issue_id,
277
-
i.created,
278
-
i.title,
279
-
i.body,
280
-
i.open,
281
-
r.did,
282
-
r.name,
283
-
r.knot,
284
-
r.rkey,
285
-
r.created
286
-
from
287
-
issues i
288
-
join
289
-
repos r on i.repo_at = r.at_uri
290
-
where
291
-
i.owner_did = ? and i.created >= date ('now', ?)
292
-
order by
293
-
i.created desc`,
294
-
ownerDid, timeframe)
352
+
repos, err := GetRepos(e, 0, FilterIn("at_uri", repoAts))
295
353
if err != nil {
296
-
return nil, err
354
+
return nil, fmt.Errorf("failed to build repo mappings: %w", err)
297
355
}
298
-
defer rows.Close()
299
356
300
-
for rows.Next() {
301
-
var issue Issue
302
-
var issueCreatedAt, repoCreatedAt string
303
-
var repo Repo
304
-
err := rows.Scan(
305
-
&issue.ID,
306
-
&issue.OwnerDid,
307
-
&issue.Rkey,
308
-
&issue.RepoAt,
309
-
&issue.IssueId,
310
-
&issueCreatedAt,
311
-
&issue.Title,
312
-
&issue.Body,
313
-
&issue.Open,
314
-
&repo.Did,
315
-
&repo.Name,
316
-
&repo.Knot,
317
-
&repo.Rkey,
318
-
&repoCreatedAt,
319
-
)
320
-
if err != nil {
321
-
return nil, err
322
-
}
357
+
repoMap := make(map[string]*Repo)
358
+
for i := range repos {
359
+
repoMap[string(repos[i].RepoAt())] = &repos[i]
360
+
}
323
361
324
-
issueCreatedTime, err := time.Parse(time.RFC3339, issueCreatedAt)
325
-
if err != nil {
326
-
return nil, err
362
+
for issueAt, i := range issueMap {
363
+
if r, ok := repoMap[string(i.RepoAt)]; ok {
364
+
i.Repo = r
365
+
} else {
366
+
// do not show up the issue if the repo is deleted
367
+
// TODO: foreign key where?
368
+
delete(issueMap, issueAt)
327
369
}
328
-
issue.Created = issueCreatedTime
370
+
}
329
371
330
-
repoCreatedTime, err := time.Parse(time.RFC3339, repoCreatedAt)
331
-
if err != nil {
332
-
return nil, err
333
-
}
334
-
repo.Created = repoCreatedTime
372
+
// collect comments
373
+
issueAts := slices.Collect(maps.Keys(issueMap))
374
+
comments, err := GetIssueComments(e, FilterIn("issue_at", issueAts))
375
+
if err != nil {
376
+
return nil, fmt.Errorf("failed to query comments: %w", err)
377
+
}
335
378
336
-
issue.Metadata = &IssueMetadata{
337
-
Repo: &repo,
379
+
for i := range comments {
380
+
issueAt := comments[i].IssueAt
381
+
if issue, ok := issueMap[issueAt]; ok {
382
+
issue.Comments = append(issue.Comments, comments[i])
338
383
}
384
+
}
339
385
340
-
issues = append(issues, issue)
386
+
var issues []Issue
387
+
for _, i := range issueMap {
388
+
issues = append(issues, *i)
341
389
}
342
390
343
-
if err := rows.Err(); err != nil {
344
-
return nil, err
345
-
}
391
+
sort.Slice(issues, func(i, j int) bool {
392
+
return issues[i].Created.After(issues[j].Created)
393
+
})
346
394
347
395
return issues, nil
396
+
}
397
+
398
+
func GetIssues(e Execer, filters ...filter) ([]Issue, error) {
399
+
return GetIssuesPaginated(e, pagination.FirstPage(), filters...)
348
400
}
349
401
350
402
func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, error) {
···
353
405
354
406
var issue Issue
355
407
var createdAt string
356
-
err := row.Scan(&issue.ID, &issue.OwnerDid, &issue.Rkey, &createdAt, &issue.Title, &issue.Body, &issue.Open)
408
+
err := row.Scan(&issue.Id, &issue.Did, &issue.Rkey, &createdAt, &issue.Title, &issue.Body, &issue.Open)
357
409
if err != nil {
358
410
return nil, err
359
411
}
···
367
419
return &issue, nil
368
420
}
369
421
370
-
func GetIssueWithComments(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, []Comment, error) {
371
-
query := `select id, owner_did, rkey, issue_id, created, title, body, open from issues where repo_at = ? and issue_id = ?`
372
-
row := e.QueryRow(query, repoAt, issueId)
373
-
374
-
var issue Issue
375
-
var createdAt string
376
-
err := row.Scan(&issue.ID, &issue.OwnerDid, &issue.Rkey, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open)
422
+
func AddIssueComment(e Execer, c IssueComment) (int64, error) {
423
+
result, err := e.Exec(
424
+
`insert into issue_comments (
425
+
did,
426
+
rkey,
427
+
issue_at,
428
+
body,
429
+
reply_to,
430
+
created,
431
+
edited
432
+
)
433
+
values (?, ?, ?, ?, ?, ?, null)
434
+
on conflict(did, rkey) do update set
435
+
issue_at = excluded.issue_at,
436
+
body = excluded.body,
437
+
edited = case
438
+
when
439
+
issue_comments.issue_at != excluded.issue_at
440
+
or issue_comments.body != excluded.body
441
+
or issue_comments.reply_to != excluded.reply_to
442
+
then ?
443
+
else issue_comments.edited
444
+
end`,
445
+
c.Did,
446
+
c.Rkey,
447
+
c.IssueAt,
448
+
c.Body,
449
+
c.ReplyTo,
450
+
c.Created.Format(time.RFC3339),
451
+
time.Now().Format(time.RFC3339),
452
+
)
377
453
if err != nil {
378
-
return nil, nil, err
454
+
return 0, err
379
455
}
380
456
381
-
createdTime, err := time.Parse(time.RFC3339, createdAt)
457
+
id, err := result.LastInsertId()
382
458
if err != nil {
383
-
return nil, nil, err
459
+
return 0, err
384
460
}
385
-
issue.Created = createdTime
386
461
387
-
comments, err := GetComments(e, repoAt, issueId)
388
-
if err != nil {
389
-
return nil, nil, err
462
+
return id, nil
463
+
}
464
+
465
+
func DeleteIssueComments(e Execer, filters ...filter) error {
466
+
var conditions []string
467
+
var args []any
468
+
for _, filter := range filters {
469
+
conditions = append(conditions, filter.Condition())
470
+
args = append(args, filter.Arg()...)
390
471
}
391
472
392
-
return &issue, comments, nil
393
-
}
473
+
whereClause := ""
474
+
if conditions != nil {
475
+
whereClause = " where " + strings.Join(conditions, " and ")
476
+
}
394
477
395
-
func NewIssueComment(e Execer, comment *Comment) error {
396
-
query := `insert into comments (owner_did, repo_at, rkey, issue_id, comment_id, body) values (?, ?, ?, ?, ?, ?)`
397
-
_, err := e.Exec(
398
-
query,
399
-
comment.OwnerDid,
400
-
comment.RepoAt,
401
-
comment.Rkey,
402
-
comment.Issue,
403
-
comment.CommentId,
404
-
comment.Body,
405
-
)
478
+
query := fmt.Sprintf(`update issue_comments set body = "", deleted = strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', 'now') %s`, whereClause)
479
+
480
+
_, err := e.Exec(query, args...)
406
481
return err
407
482
}
408
483
409
-
func GetComments(e Execer, repoAt syntax.ATURI, issueId int) ([]Comment, error) {
410
-
var comments []Comment
484
+
func GetIssueComments(e Execer, filters ...filter) ([]IssueComment, error) {
485
+
var comments []IssueComment
486
+
487
+
var conditions []string
488
+
var args []any
489
+
for _, filter := range filters {
490
+
conditions = append(conditions, filter.Condition())
491
+
args = append(args, filter.Arg()...)
492
+
}
411
493
412
-
rows, err := e.Query(`
494
+
whereClause := ""
495
+
if conditions != nil {
496
+
whereClause = " where " + strings.Join(conditions, " and ")
497
+
}
498
+
499
+
query := fmt.Sprintf(`
413
500
select
414
-
owner_did,
415
-
issue_id,
416
-
comment_id,
501
+
id,
502
+
did,
417
503
rkey,
504
+
issue_at,
505
+
reply_to,
418
506
body,
419
507
created,
420
508
edited,
421
509
deleted
422
510
from
423
-
comments
424
-
where
425
-
repo_at = ? and issue_id = ?
426
-
order by
427
-
created asc`,
428
-
repoAt,
429
-
issueId,
430
-
)
431
-
if err == sql.ErrNoRows {
432
-
return []Comment{}, nil
433
-
}
511
+
issue_comments
512
+
%s
513
+
`, whereClause)
514
+
515
+
rows, err := e.Query(query, args...)
434
516
if err != nil {
435
517
return nil, err
436
518
}
437
-
defer rows.Close()
438
519
439
520
for rows.Next() {
440
-
var comment Comment
441
-
var createdAt string
442
-
var deletedAt, editedAt, rkey sql.NullString
443
-
err := rows.Scan(&comment.OwnerDid, &comment.Issue, &comment.CommentId, &rkey, &comment.Body, &createdAt, &editedAt, &deletedAt)
521
+
var comment IssueComment
522
+
var created string
523
+
var rkey, edited, deleted, replyTo sql.Null[string]
524
+
err := rows.Scan(
525
+
&comment.Id,
526
+
&comment.Did,
527
+
&rkey,
528
+
&comment.IssueAt,
529
+
&replyTo,
530
+
&comment.Body,
531
+
&created,
532
+
&edited,
533
+
&deleted,
534
+
)
444
535
if err != nil {
445
536
return nil, err
446
537
}
447
538
448
-
createdAtTime, err := time.Parse(time.RFC3339, createdAt)
449
-
if err != nil {
450
-
return nil, err
539
+
// this is a remnant from old times, newer comments always have rkey
540
+
if rkey.Valid {
541
+
comment.Rkey = rkey.V
451
542
}
452
-
comment.Created = &createdAtTime
453
543
454
-
if deletedAt.Valid {
455
-
deletedTime, err := time.Parse(time.RFC3339, deletedAt.String)
456
-
if err != nil {
457
-
return nil, err
544
+
if t, err := time.Parse(time.RFC3339, created); err == nil {
545
+
comment.Created = t
546
+
}
547
+
548
+
if edited.Valid {
549
+
if t, err := time.Parse(time.RFC3339, edited.V); err == nil {
550
+
comment.Edited = &t
458
551
}
459
-
comment.Deleted = &deletedTime
460
552
}
461
553
462
-
if editedAt.Valid {
463
-
editedTime, err := time.Parse(time.RFC3339, editedAt.String)
464
-
if err != nil {
465
-
return nil, err
554
+
if deleted.Valid {
555
+
if t, err := time.Parse(time.RFC3339, deleted.V); err == nil {
556
+
comment.Deleted = &t
466
557
}
467
-
comment.Edited = &editedTime
468
558
}
469
559
470
-
if rkey.Valid {
471
-
comment.Rkey = rkey.String
560
+
if replyTo.Valid {
561
+
comment.ReplyTo = &replyTo.V
472
562
}
473
563
474
564
comments = append(comments, comment)
475
565
}
476
566
477
-
if err := rows.Err(); err != nil {
567
+
if err = rows.Err(); err != nil {
478
568
return nil, err
479
569
}
480
570
481
571
return comments, nil
482
572
}
483
573
484
-
func GetComment(e Execer, repoAt syntax.ATURI, issueId, commentId int) (*Comment, error) {
485
-
query := `
486
-
select
487
-
owner_did, body, rkey, created, deleted, edited
488
-
from
489
-
comments where repo_at = ? and issue_id = ? and comment_id = ?
490
-
`
491
-
row := e.QueryRow(query, repoAt, issueId, commentId)
492
-
493
-
var comment Comment
494
-
var createdAt string
495
-
var deletedAt, editedAt, rkey sql.NullString
496
-
err := row.Scan(&comment.OwnerDid, &comment.Body, &rkey, &createdAt, &deletedAt, &editedAt)
497
-
if err != nil {
498
-
return nil, err
574
+
func DeleteIssues(e Execer, filters ...filter) error {
575
+
var conditions []string
576
+
var args []any
577
+
for _, filter := range filters {
578
+
conditions = append(conditions, filter.Condition())
579
+
args = append(args, filter.Arg()...)
499
580
}
500
581
501
-
createdTime, err := time.Parse(time.RFC3339, createdAt)
502
-
if err != nil {
503
-
return nil, err
582
+
whereClause := ""
583
+
if conditions != nil {
584
+
whereClause = " where " + strings.Join(conditions, " and ")
504
585
}
505
-
comment.Created = &createdTime
506
586
507
-
if deletedAt.Valid {
508
-
deletedTime, err := time.Parse(time.RFC3339, deletedAt.String)
509
-
if err != nil {
510
-
return nil, err
511
-
}
512
-
comment.Deleted = &deletedTime
513
-
}
587
+
query := fmt.Sprintf(`delete from issues %s`, whereClause)
588
+
_, err := e.Exec(query, args...)
589
+
return err
590
+
}
514
591
515
-
if editedAt.Valid {
516
-
editedTime, err := time.Parse(time.RFC3339, editedAt.String)
517
-
if err != nil {
518
-
return nil, err
519
-
}
520
-
comment.Edited = &editedTime
592
+
func CloseIssues(e Execer, filters ...filter) error {
593
+
var conditions []string
594
+
var args []any
595
+
for _, filter := range filters {
596
+
conditions = append(conditions, filter.Condition())
597
+
args = append(args, filter.Arg()...)
521
598
}
522
599
523
-
if rkey.Valid {
524
-
comment.Rkey = rkey.String
600
+
whereClause := ""
601
+
if conditions != nil {
602
+
whereClause = " where " + strings.Join(conditions, " and ")
525
603
}
526
604
527
-
comment.RepoAt = repoAt
528
-
comment.Issue = issueId
529
-
comment.CommentId = commentId
530
-
531
-
return &comment, nil
532
-
}
533
-
534
-
func EditComment(e Execer, repoAt syntax.ATURI, issueId, commentId int, newBody string) error {
535
-
_, err := e.Exec(
536
-
`
537
-
update comments
538
-
set body = ?,
539
-
edited = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
540
-
where repo_at = ? and issue_id = ? and comment_id = ?
541
-
`, newBody, repoAt, issueId, commentId)
605
+
query := fmt.Sprintf(`update issues set open = 0 %s`, whereClause)
606
+
_, err := e.Exec(query, args...)
542
607
return err
543
608
}
544
609
545
-
func DeleteComment(e Execer, repoAt syntax.ATURI, issueId, commentId int) error {
546
-
_, err := e.Exec(
547
-
`
548
-
update comments
549
-
set body = "",
550
-
deleted = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
551
-
where repo_at = ? and issue_id = ? and comment_id = ?
552
-
`, repoAt, issueId, commentId)
553
-
return err
554
-
}
610
+
func ReopenIssues(e Execer, filters ...filter) error {
611
+
var conditions []string
612
+
var args []any
613
+
for _, filter := range filters {
614
+
conditions = append(conditions, filter.Condition())
615
+
args = append(args, filter.Arg()...)
616
+
}
555
617
556
-
func CloseIssue(e Execer, repoAt syntax.ATURI, issueId int) error {
557
-
_, err := e.Exec(`update issues set open = 0 where repo_at = ? and issue_id = ?`, repoAt, issueId)
558
-
return err
559
-
}
618
+
whereClause := ""
619
+
if conditions != nil {
620
+
whereClause = " where " + strings.Join(conditions, " and ")
621
+
}
560
622
561
-
func ReopenIssue(e Execer, repoAt syntax.ATURI, issueId int) error {
562
-
_, err := e.Exec(`update issues set open = 1 where repo_at = ? and issue_id = ?`, repoAt, issueId)
623
+
query := fmt.Sprintf(`update issues set open = 1 %s`, whereClause)
624
+
_, err := e.Exec(query, args...)
563
625
return err
564
626
}
565
627
+23
-5
appview/db/profile.go
+23
-5
appview/db/profile.go
···
22
22
ByMonth []ByMonth
23
23
}
24
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
+
25
39
type ByMonth struct {
26
40
RepoEvents []RepoEvent
27
41
IssueEvents IssueEvents
···
118
132
*items = append(*items, &pull)
119
133
}
120
134
121
-
issues, err := GetIssuesByOwnerDid(e, forDid, timeframe)
135
+
issues, err := GetIssues(
136
+
e,
137
+
FilterEq("did", forDid),
138
+
FilterGte("created", time.Now().AddDate(0, -TimeframeMonths, 0)),
139
+
)
122
140
if err != nil {
123
141
return nil, fmt.Errorf("error getting issues by owner did: %w", err)
124
142
}
···
137
155
*items = append(*items, &issue)
138
156
}
139
157
140
-
repos, err := GetAllReposByDid(e, forDid)
158
+
repos, err := GetRepos(e, 0, FilterEq("did", forDid))
141
159
if err != nil {
142
160
return nil, fmt.Errorf("error getting all repos by did: %w", err)
143
161
}
···
535
553
query = `select count(id) from pulls where owner_did = ? and state = ?`
536
554
args = append(args, did, PullOpen)
537
555
case VanityStatOpenIssueCount:
538
-
query = `select count(id) from issues where owner_did = ? and open = 1`
556
+
query = `select count(id) from issues where did = ? and open = 1`
539
557
args = append(args, did)
540
558
case VanityStatClosedIssueCount:
541
-
query = `select count(id) from issues where owner_did = ? and open = 0`
559
+
query = `select count(id) from issues where did = ? and open = 0`
542
560
args = append(args, did)
543
561
case VanityStatRepositoryCount:
544
562
query = `select count(id) from repos where did = ?`
···
572
590
}
573
591
574
592
// ensure all pinned repos are either own repos or collaborating repos
575
-
repos, err := GetAllReposByDid(e, profile.Did)
593
+
repos, err := GetRepos(e, 0, FilterEq("did", profile.Did))
576
594
if err != nil {
577
595
log.Printf("getting repos for %s: %s", profile.Did, err)
578
596
}
+9
-8
appview/db/pulls.go
+9
-8
appview/db/pulls.go
···
91
91
}
92
92
93
93
record := tangled.RepoPull{
94
-
Title: p.Title,
95
-
Body: &p.Body,
96
-
CreatedAt: p.Created.Format(time.RFC3339),
97
-
PullId: int64(p.PullId),
98
-
TargetRepo: p.RepoAt.String(),
99
-
TargetBranch: p.TargetBranch,
100
-
Patch: p.LatestPatch(),
101
-
Source: source,
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,
102
103
}
103
104
return record
104
105
}
+4
-4
appview/db/punchcard.go
+4
-4
appview/db/punchcard.go
···
29
29
Punches []Punch
30
30
}
31
31
32
-
func MakePunchcard(e Execer, filters ...filter) (Punchcard, error) {
33
-
punchcard := Punchcard{}
32
+
func MakePunchcard(e Execer, filters ...filter) (*Punchcard, error) {
33
+
punchcard := &Punchcard{}
34
34
now := time.Now()
35
35
startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC)
36
36
endOfYear := time.Date(now.Year(), 12, 31, 0, 0, 0, 0, time.UTC)
···
63
63
64
64
rows, err := e.Query(query, args...)
65
65
if err != nil {
66
-
return punchcard, err
66
+
return nil, err
67
67
}
68
68
defer rows.Close()
69
69
···
72
72
var date string
73
73
var count sql.NullInt64
74
74
if err := rows.Scan(&date, &count); err != nil {
75
-
return punchcard, err
75
+
return nil, err
76
76
}
77
77
78
78
punch.Date, err = time.Parse(time.DateOnly, date)
+17
-17
appview/db/registration.go
+17
-17
appview/db/registration.go
···
10
10
// Registration represents a knot registration. Knot would've been a better
11
11
// name but we're stuck with this for historical reasons.
12
12
type Registration struct {
13
-
Id int64
14
-
Domain string
15
-
ByDid string
16
-
Created *time.Time
17
-
Registered *time.Time
18
-
ReadOnly bool
13
+
Id int64
14
+
Domain string
15
+
ByDid string
16
+
Created *time.Time
17
+
Registered *time.Time
18
+
NeedsUpgrade bool
19
19
}
20
20
21
21
func (r *Registration) Status() Status {
22
-
if r.ReadOnly {
23
-
return ReadOnly
22
+
if r.NeedsUpgrade {
23
+
return NeedsUpgrade
24
24
} else if r.Registered != nil {
25
25
return Registered
26
26
} else {
···
32
32
return r.Status() == Registered
33
33
}
34
34
35
-
func (r *Registration) IsReadOnly() bool {
36
-
return r.Status() == ReadOnly
35
+
func (r *Registration) IsNeedsUpgrade() bool {
36
+
return r.Status() == NeedsUpgrade
37
37
}
38
38
39
39
func (r *Registration) IsPending() bool {
···
45
45
const (
46
46
Registered Status = iota
47
47
Pending
48
-
ReadOnly
48
+
NeedsUpgrade
49
49
)
50
50
51
51
func GetRegistrations(e Execer, filters ...filter) ([]Registration, error) {
···
64
64
}
65
65
66
66
query := fmt.Sprintf(`
67
-
select id, domain, did, created, registered, read_only
67
+
select id, domain, did, created, registered, needs_upgrade
68
68
from registrations
69
69
%s
70
70
order by created
···
80
80
for rows.Next() {
81
81
var createdAt string
82
82
var registeredAt sql.Null[string]
83
-
var readOnly int
83
+
var needsUpgrade int
84
84
var reg Registration
85
85
86
-
err = rows.Scan(®.Id, ®.Domain, ®.ByDid, &createdAt, ®isteredAt, &readOnly)
86
+
err = rows.Scan(®.Id, ®.Domain, ®.ByDid, &createdAt, ®isteredAt, &needsUpgrade)
87
87
if err != nil {
88
88
return nil, err
89
89
}
···
98
98
}
99
99
}
100
100
101
-
if readOnly != 0 {
102
-
reg.ReadOnly = true
101
+
if needsUpgrade != 0 {
102
+
reg.NeedsUpgrade = true
103
103
}
104
104
105
105
registrations = append(registrations, reg)
···
116
116
args = append(args, filter.Arg()...)
117
117
}
118
118
119
-
query := "update registrations set registered = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), read_only = 0"
119
+
query := "update registrations set registered = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), needs_upgrade = 0"
120
120
if len(conditions) > 0 {
121
121
query += " where " + strings.Join(conditions, " and ")
122
122
}
+27
-130
appview/db/repos.go
+27
-130
appview/db/repos.go
···
2
2
3
3
import (
4
4
"database/sql"
5
+
"errors"
5
6
"fmt"
6
7
"log"
7
8
"slices"
···
36
37
func (r Repo) DidSlashRepo() string {
37
38
p, _ := securejoin.SecureJoin(r.Did, r.Name)
38
39
return p
39
-
}
40
-
41
-
func GetAllRepos(e Execer, limit int) ([]Repo, error) {
42
-
var repos []Repo
43
-
44
-
rows, err := e.Query(
45
-
`select did, name, knot, rkey, description, created, source
46
-
from repos
47
-
order by created desc
48
-
limit ?
49
-
`,
50
-
limit,
51
-
)
52
-
if err != nil {
53
-
return nil, err
54
-
}
55
-
defer rows.Close()
56
-
57
-
for rows.Next() {
58
-
var repo Repo
59
-
err := scanRepo(
60
-
rows, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &repo.Description, &repo.Created, &repo.Source,
61
-
)
62
-
if err != nil {
63
-
return nil, err
64
-
}
65
-
repos = append(repos, repo)
66
-
}
67
-
68
-
if err := rows.Err(); err != nil {
69
-
return nil, err
70
-
}
71
-
72
-
return repos, nil
73
40
}
74
41
75
42
func GetRepos(e Execer, limit int, filters ...filter) ([]Repo, error) {
···
310
277
311
278
slices.SortFunc(repos, func(a, b Repo) int {
312
279
if a.Created.After(b.Created) {
313
-
return 1
280
+
return -1
314
281
}
315
-
return -1
282
+
return 1
316
283
})
317
284
318
285
return repos, nil
319
286
}
320
287
321
-
func GetAllReposByDid(e Execer, did string) ([]Repo, error) {
322
-
var repos []Repo
288
+
func CountRepos(e Execer, filters ...filter) (int64, error) {
289
+
var conditions []string
290
+
var args []any
291
+
for _, filter := range filters {
292
+
conditions = append(conditions, filter.Condition())
293
+
args = append(args, filter.Arg()...)
294
+
}
323
295
324
-
rows, err := e.Query(
325
-
`select
326
-
r.did,
327
-
r.name,
328
-
r.knot,
329
-
r.rkey,
330
-
r.description,
331
-
r.created,
332
-
count(s.id) as star_count,
333
-
r.source
334
-
from
335
-
repos r
336
-
left join
337
-
stars s on r.at_uri = s.repo_at
338
-
where
339
-
r.did = ?
340
-
group by
341
-
r.at_uri
342
-
order by r.created desc`,
343
-
did)
344
-
if err != nil {
345
-
return nil, err
296
+
whereClause := ""
297
+
if conditions != nil {
298
+
whereClause = " where " + strings.Join(conditions, " and ")
346
299
}
347
-
defer rows.Close()
348
300
349
-
for rows.Next() {
350
-
var repo Repo
351
-
var repoStats RepoStats
352
-
var createdAt string
353
-
var nullableDescription sql.NullString
354
-
var nullableSource sql.NullString
355
-
356
-
err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &repoStats.StarCount, &nullableSource)
357
-
if err != nil {
358
-
return nil, err
359
-
}
301
+
repoQuery := fmt.Sprintf(`select count(1) from repos %s`, whereClause)
302
+
var count int64
303
+
err := e.QueryRow(repoQuery, args...).Scan(&count)
360
304
361
-
if nullableDescription.Valid {
362
-
repo.Description = nullableDescription.String
363
-
}
364
-
365
-
if nullableSource.Valid {
366
-
repo.Source = nullableSource.String
367
-
}
368
-
369
-
createdAtTime, err := time.Parse(time.RFC3339, createdAt)
370
-
if err != nil {
371
-
repo.Created = time.Now()
372
-
} else {
373
-
repo.Created = createdAtTime
374
-
}
375
-
376
-
repo.RepoStats = &repoStats
377
-
378
-
repos = append(repos, repo)
305
+
if !errors.Is(err, sql.ErrNoRows) && err != nil {
306
+
return 0, err
379
307
}
380
308
381
-
if err := rows.Err(); err != nil {
382
-
return nil, err
383
-
}
384
-
385
-
return repos, nil
309
+
return count, nil
386
310
}
387
311
388
312
func GetRepo(e Execer, did, name string) (*Repo, error) {
···
466
390
var repos []Repo
467
391
468
392
rows, err := e.Query(
469
-
`select did, name, knot, rkey, description, created, source
470
-
from repos
471
-
where did = ? and source is not null and source != ''
472
-
order by created desc`,
473
-
did,
393
+
`select distinct r.did, r.name, r.knot, r.rkey, r.description, r.created, r.source
394
+
from repos r
395
+
left join collaborators c on r.at_uri = c.repo_at
396
+
where (r.did = ? or c.subject_did = ?)
397
+
and r.source is not null
398
+
and r.source != ''
399
+
order by r.created desc`,
400
+
did, did,
474
401
)
475
402
if err != nil {
476
403
return nil, err
···
567
494
IssueCount IssueCount
568
495
PullCount PullCount
569
496
}
570
-
571
-
func scanRepo(rows *sql.Rows, did, name, knot, rkey, description *string, created *time.Time, source *string) error {
572
-
var createdAt string
573
-
var nullableDescription sql.NullString
574
-
var nullableSource sql.NullString
575
-
if err := rows.Scan(did, name, knot, rkey, &nullableDescription, &createdAt, &nullableSource); err != nil {
576
-
return err
577
-
}
578
-
579
-
if nullableDescription.Valid {
580
-
*description = nullableDescription.String
581
-
} else {
582
-
*description = ""
583
-
}
584
-
585
-
createdAtTime, err := time.Parse(time.RFC3339, createdAt)
586
-
if err != nil {
587
-
*created = time.Now()
588
-
} else {
589
-
*created = createdAtTime
590
-
}
591
-
592
-
if nullableSource.Valid {
593
-
*source = nullableSource.String
594
-
} else {
595
-
*source = ""
596
-
}
597
-
598
-
return nil
599
-
}
+14
-7
appview/db/spindle.go
+14
-7
appview/db/spindle.go
···
10
10
)
11
11
12
12
type Spindle struct {
13
-
Id int
14
-
Owner syntax.DID
15
-
Instance string
16
-
Verified *time.Time
17
-
Created time.Time
13
+
Id int
14
+
Owner syntax.DID
15
+
Instance string
16
+
Verified *time.Time
17
+
Created time.Time
18
+
NeedsUpgrade bool
18
19
}
19
20
20
21
type SpindleMember struct {
···
42
43
}
43
44
44
45
query := fmt.Sprintf(
45
-
`select id, owner, instance, verified, created
46
+
`select id, owner, instance, verified, created, needs_upgrade
46
47
from spindles
47
48
%s
48
49
order by created
···
61
62
var spindle Spindle
62
63
var createdAt string
63
64
var verified sql.NullString
65
+
var needsUpgrade int
64
66
65
67
if err := rows.Scan(
66
68
&spindle.Id,
···
68
70
&spindle.Instance,
69
71
&verified,
70
72
&createdAt,
73
+
&needsUpgrade,
71
74
); err != nil {
72
75
return nil, err
73
76
}
···
86
89
spindle.Verified = &t
87
90
}
88
91
92
+
if needsUpgrade != 0 {
93
+
spindle.NeedsUpgrade = true
94
+
}
95
+
89
96
spindles = append(spindles, spindle)
90
97
}
91
98
···
115
122
whereClause = " where " + strings.Join(conditions, " and ")
116
123
}
117
124
118
-
query := fmt.Sprintf(`update spindles set verified = strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', 'now') %s`, whereClause)
125
+
query := fmt.Sprintf(`update spindles set verified = strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', 'now'), needs_upgrade = 0 %s`, whereClause)
119
126
120
127
res, err := e.Exec(query, args...)
121
128
if err != nil {
+26
appview/db/star.go
+26
appview/db/star.go
···
1
1
package db
2
2
3
3
import (
4
+
"database/sql"
5
+
"errors"
4
6
"fmt"
5
7
"log"
6
8
"strings"
···
181
183
}
182
184
183
185
return stars, nil
186
+
}
187
+
188
+
func CountStars(e Execer, filters ...filter) (int64, error) {
189
+
var conditions []string
190
+
var args []any
191
+
for _, filter := range filters {
192
+
conditions = append(conditions, filter.Condition())
193
+
args = append(args, filter.Arg()...)
194
+
}
195
+
196
+
whereClause := ""
197
+
if conditions != nil {
198
+
whereClause = " where " + strings.Join(conditions, " and ")
199
+
}
200
+
201
+
repoQuery := fmt.Sprintf(`select count(1) from stars %s`, whereClause)
202
+
var count int64
203
+
err := e.QueryRow(repoQuery, args...).Scan(&count)
204
+
205
+
if !errors.Is(err, sql.ErrNoRows) && err != nil {
206
+
return 0, err
207
+
}
208
+
209
+
return count, nil
184
210
}
185
211
186
212
func GetAllStars(e Execer, limit int) ([]Star, error) {
+24
appview/db/strings.go
+24
appview/db/strings.go
···
206
206
return all, nil
207
207
}
208
208
209
+
func CountStrings(e Execer, filters ...filter) (int64, error) {
210
+
var conditions []string
211
+
var args []any
212
+
for _, filter := range filters {
213
+
conditions = append(conditions, filter.Condition())
214
+
args = append(args, filter.Arg()...)
215
+
}
216
+
217
+
whereClause := ""
218
+
if conditions != nil {
219
+
whereClause = " where " + strings.Join(conditions, " and ")
220
+
}
221
+
222
+
repoQuery := fmt.Sprintf(`select count(1) from strings %s`, whereClause)
223
+
var count int64
224
+
err := e.QueryRow(repoQuery, args...).Scan(&count)
225
+
226
+
if !errors.Is(err, sql.ErrNoRows) && err != nil {
227
+
return 0, err
228
+
}
229
+
230
+
return count, nil
231
+
}
232
+
209
233
func DeleteString(e Execer, filters ...filter) error {
210
234
var conditions []string
211
235
var args []any
+12
-14
appview/db/timeline.go
+12
-14
appview/db/timeline.go
···
20
20
*FollowStats
21
21
}
22
22
23
-
const Limit = 50
24
-
25
23
// TODO: this gathers heterogenous events from different sources and aggregates
26
24
// them in code; if we did this entirely in sql, we could order and limit and paginate easily
27
-
func MakeTimeline(e Execer) ([]TimelineEvent, error) {
25
+
func MakeTimeline(e Execer, limit int) ([]TimelineEvent, error) {
28
26
var events []TimelineEvent
29
27
30
-
repos, err := getTimelineRepos(e)
28
+
repos, err := getTimelineRepos(e, limit)
31
29
if err != nil {
32
30
return nil, err
33
31
}
34
32
35
-
stars, err := getTimelineStars(e)
33
+
stars, err := getTimelineStars(e, limit)
36
34
if err != nil {
37
35
return nil, err
38
36
}
39
37
40
-
follows, err := getTimelineFollows(e)
38
+
follows, err := getTimelineFollows(e, limit)
41
39
if err != nil {
42
40
return nil, err
43
41
}
···
51
49
})
52
50
53
51
// Limit the slice to 100 events
54
-
if len(events) > Limit {
55
-
events = events[:Limit]
52
+
if len(events) > limit {
53
+
events = events[:limit]
56
54
}
57
55
58
56
return events, nil
59
57
}
60
58
61
-
func getTimelineRepos(e Execer) ([]TimelineEvent, error) {
62
-
repos, err := GetRepos(e, Limit)
59
+
func getTimelineRepos(e Execer, limit int) ([]TimelineEvent, error) {
60
+
repos, err := GetRepos(e, limit)
63
61
if err != nil {
64
62
return nil, err
65
63
}
···
104
102
return events, nil
105
103
}
106
104
107
-
func getTimelineStars(e Execer) ([]TimelineEvent, error) {
108
-
stars, err := GetStars(e, Limit)
105
+
func getTimelineStars(e Execer, limit int) ([]TimelineEvent, error) {
106
+
stars, err := GetStars(e, limit)
109
107
if err != nil {
110
108
return nil, err
111
109
}
···
131
129
return events, nil
132
130
}
133
131
134
-
func getTimelineFollows(e Execer) ([]TimelineEvent, error) {
135
-
follows, err := GetFollows(e, Limit)
132
+
func getTimelineFollows(e Execer, limit int) ([]TimelineEvent, error) {
133
+
follows, err := GetFollows(e, limit)
136
134
if err != nil {
137
135
return nil, err
138
136
}
+133
-6
appview/ingester.go
+133
-6
appview/ingester.go
···
5
5
"encoding/json"
6
6
"fmt"
7
7
"log/slog"
8
+
8
9
"time"
9
10
10
11
"github.com/bluesky-social/indigo/atproto/syntax"
···
15
16
"tangled.sh/tangled.sh/core/appview/config"
16
17
"tangled.sh/tangled.sh/core/appview/db"
17
18
"tangled.sh/tangled.sh/core/appview/serververify"
19
+
"tangled.sh/tangled.sh/core/appview/validator"
18
20
"tangled.sh/tangled.sh/core/idresolver"
19
21
"tangled.sh/tangled.sh/core/rbac"
20
22
)
···
25
27
IdResolver *idresolver.Resolver
26
28
Config *config.Config
27
29
Logger *slog.Logger
30
+
Validator *validator.Validator
28
31
}
29
32
30
33
type processFunc func(ctx context.Context, e *models.Event) error
···
61
64
case tangled.ActorProfileNSID:
62
65
err = i.ingestProfile(e)
63
66
case tangled.SpindleMemberNSID:
64
-
err = i.ingestSpindleMember(e)
67
+
err = i.ingestSpindleMember(ctx, e)
65
68
case tangled.SpindleNSID:
66
-
err = i.ingestSpindle(e)
69
+
err = i.ingestSpindle(ctx, e)
67
70
case tangled.KnotMemberNSID:
68
71
err = i.ingestKnotMember(e)
69
72
case tangled.KnotNSID:
70
73
err = i.ingestKnot(e)
71
74
case tangled.StringNSID:
72
75
err = i.ingestString(e)
76
+
case tangled.RepoIssueNSID:
77
+
err = i.ingestIssue(ctx, e)
78
+
case tangled.RepoIssueCommentNSID:
79
+
err = i.ingestIssueComment(e)
73
80
}
74
81
l = i.Logger.With("nsid", e.Commit.Collection)
75
82
}
···
340
347
return nil
341
348
}
342
349
343
-
func (i *Ingester) ingestSpindleMember(e *models.Event) error {
350
+
func (i *Ingester) ingestSpindleMember(ctx context.Context, e *models.Event) error {
344
351
did := e.Did
345
352
var err error
346
353
···
363
370
return fmt.Errorf("failed to enforce permissions: %w", err)
364
371
}
365
372
366
-
memberId, err := i.IdResolver.ResolveIdent(context.Background(), record.Subject)
373
+
memberId, err := i.IdResolver.ResolveIdent(ctx, record.Subject)
367
374
if err != nil {
368
375
return err
369
376
}
···
446
453
return nil
447
454
}
448
455
449
-
func (i *Ingester) ingestSpindle(e *models.Event) error {
456
+
func (i *Ingester) ingestSpindle(ctx context.Context, e *models.Event) error {
450
457
did := e.Did
451
458
var err error
452
459
···
479
486
return err
480
487
}
481
488
482
-
err = serververify.RunVerification(context.Background(), instance, did, i.Config.Core.Dev)
489
+
err = serververify.RunVerification(ctx, instance, did, i.Config.Core.Dev)
483
490
if err != nil {
484
491
l.Error("failed to add spindle to db", "err", err, "instance", instance)
485
492
return err
···
769
776
770
777
return nil
771
778
}
779
+
func (i *Ingester) ingestIssue(ctx context.Context, e *models.Event) error {
780
+
did := e.Did
781
+
rkey := e.Commit.RKey
782
+
783
+
var err error
784
+
785
+
l := i.Logger.With("handler", "ingestIssue", "nsid", e.Commit.Collection, "did", did, "rkey", rkey)
786
+
l.Info("ingesting record")
787
+
788
+
ddb, ok := i.Db.Execer.(*db.DB)
789
+
if !ok {
790
+
return fmt.Errorf("failed to index issue record, invalid db cast")
791
+
}
792
+
793
+
switch e.Commit.Operation {
794
+
case models.CommitOperationCreate, models.CommitOperationUpdate:
795
+
raw := json.RawMessage(e.Commit.Record)
796
+
record := tangled.RepoIssue{}
797
+
err = json.Unmarshal(raw, &record)
798
+
if err != nil {
799
+
l.Error("invalid record", "err", err)
800
+
return err
801
+
}
802
+
803
+
issue := db.IssueFromRecord(did, rkey, record)
804
+
805
+
if err := i.Validator.ValidateIssue(&issue); err != nil {
806
+
return fmt.Errorf("failed to validate issue: %w", err)
807
+
}
808
+
809
+
tx, err := ddb.BeginTx(ctx, nil)
810
+
if err != nil {
811
+
l.Error("failed to begin transaction", "err", err)
812
+
return err
813
+
}
814
+
defer tx.Rollback()
815
+
816
+
err = db.PutIssue(tx, &issue)
817
+
if err != nil {
818
+
l.Error("failed to create issue", "err", err)
819
+
return err
820
+
}
821
+
822
+
err = tx.Commit()
823
+
if err != nil {
824
+
l.Error("failed to commit txn", "err", err)
825
+
return err
826
+
}
827
+
828
+
return nil
829
+
830
+
case models.CommitOperationDelete:
831
+
if err := db.DeleteIssues(
832
+
ddb,
833
+
db.FilterEq("did", did),
834
+
db.FilterEq("rkey", rkey),
835
+
); err != nil {
836
+
l.Error("failed to delete", "err", err)
837
+
return fmt.Errorf("failed to delete issue record: %w", err)
838
+
}
839
+
840
+
return nil
841
+
}
842
+
843
+
return nil
844
+
}
845
+
846
+
func (i *Ingester) ingestIssueComment(e *models.Event) error {
847
+
did := e.Did
848
+
rkey := e.Commit.RKey
849
+
850
+
var err error
851
+
852
+
l := i.Logger.With("handler", "ingestIssueComment", "nsid", e.Commit.Collection, "did", did, "rkey", rkey)
853
+
l.Info("ingesting record")
854
+
855
+
ddb, ok := i.Db.Execer.(*db.DB)
856
+
if !ok {
857
+
return fmt.Errorf("failed to index issue comment record, invalid db cast")
858
+
}
859
+
860
+
switch e.Commit.Operation {
861
+
case models.CommitOperationCreate, models.CommitOperationUpdate:
862
+
raw := json.RawMessage(e.Commit.Record)
863
+
record := tangled.RepoIssueComment{}
864
+
err = json.Unmarshal(raw, &record)
865
+
if err != nil {
866
+
return fmt.Errorf("invalid record: %w", err)
867
+
}
868
+
869
+
comment, err := db.IssueCommentFromRecord(ddb, did, rkey, record)
870
+
if err != nil {
871
+
return fmt.Errorf("failed to parse comment from record: %w", err)
872
+
}
873
+
874
+
if err := i.Validator.ValidateIssueComment(comment); err != nil {
875
+
return fmt.Errorf("failed to validate comment: %w", err)
876
+
}
877
+
878
+
_, err = db.AddIssueComment(ddb, *comment)
879
+
if err != nil {
880
+
return fmt.Errorf("failed to create issue comment: %w", err)
881
+
}
882
+
883
+
return nil
884
+
885
+
case models.CommitOperationDelete:
886
+
if err := db.DeleteIssueComments(
887
+
ddb,
888
+
db.FilterEq("did", did),
889
+
db.FilterEq("rkey", rkey),
890
+
); err != nil {
891
+
return fmt.Errorf("failed to delete issue comment record: %w", err)
892
+
}
893
+
894
+
return nil
895
+
}
896
+
897
+
return nil
898
+
}
+477
-286
appview/issues/issues.go
+477
-286
appview/issues/issues.go
···
1
1
package issues
2
2
3
3
import (
4
+
"context"
5
+
"database/sql"
6
+
"errors"
4
7
"fmt"
5
8
"log"
6
-
mathrand "math/rand/v2"
9
+
"log/slog"
7
10
"net/http"
8
11
"slices"
9
-
"strconv"
10
-
"strings"
11
12
"time"
12
13
13
14
comatproto "github.com/bluesky-social/indigo/api/atproto"
14
-
"github.com/bluesky-social/indigo/atproto/data"
15
+
"github.com/bluesky-social/indigo/atproto/syntax"
15
16
lexutil "github.com/bluesky-social/indigo/lex/util"
16
17
"github.com/go-chi/chi/v5"
17
18
···
21
22
"tangled.sh/tangled.sh/core/appview/notify"
22
23
"tangled.sh/tangled.sh/core/appview/oauth"
23
24
"tangled.sh/tangled.sh/core/appview/pages"
24
-
"tangled.sh/tangled.sh/core/appview/pages/markup"
25
25
"tangled.sh/tangled.sh/core/appview/pagination"
26
26
"tangled.sh/tangled.sh/core/appview/reporesolver"
27
+
"tangled.sh/tangled.sh/core/appview/validator"
28
+
"tangled.sh/tangled.sh/core/appview/xrpcclient"
27
29
"tangled.sh/tangled.sh/core/idresolver"
30
+
tlog "tangled.sh/tangled.sh/core/log"
28
31
"tangled.sh/tangled.sh/core/tid"
29
32
)
30
33
···
36
39
db *db.DB
37
40
config *config.Config
38
41
notifier notify.Notifier
42
+
logger *slog.Logger
43
+
validator *validator.Validator
39
44
}
40
45
41
46
func New(
···
46
51
db *db.DB,
47
52
config *config.Config,
48
53
notifier notify.Notifier,
54
+
validator *validator.Validator,
49
55
) *Issues {
50
56
return &Issues{
51
57
oauth: oauth,
···
55
61
db: db,
56
62
config: config,
57
63
notifier: notifier,
64
+
logger: tlog.New("issues"),
65
+
validator: validator,
58
66
}
59
67
}
60
68
61
69
func (rp *Issues) RepoSingleIssue(w http.ResponseWriter, r *http.Request) {
70
+
l := rp.logger.With("handler", "RepoSingleIssue")
62
71
user := rp.oauth.GetUser(r)
63
72
f, err := rp.repoResolver.Resolve(r)
64
73
if err != nil {
···
66
75
return
67
76
}
68
77
69
-
issueId := chi.URLParam(r, "issue")
70
-
issueIdInt, err := strconv.Atoi(issueId)
71
-
if err != nil {
72
-
http.Error(w, "bad issue id", http.StatusBadRequest)
73
-
log.Println("failed to parse issue id", err)
74
-
return
75
-
}
76
-
77
-
issue, comments, err := db.GetIssueWithComments(rp.db, f.RepoAt(), issueIdInt)
78
-
if err != nil {
79
-
log.Println("failed to get issue and comments", err)
80
-
rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
78
+
issue, ok := r.Context().Value("issue").(*db.Issue)
79
+
if !ok {
80
+
l.Error("failed to get issue")
81
+
rp.pages.Error404(w)
81
82
return
82
83
}
83
84
84
85
reactionCountMap, err := db.GetReactionCountMap(rp.db, issue.AtUri())
85
86
if err != nil {
86
-
log.Println("failed to get issue reactions")
87
-
rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
87
+
l.Error("failed to get issue reactions", "err", err)
88
88
}
89
89
90
90
userReactions := map[db.ReactionKind]bool{}
···
92
92
userReactions = db.GetReactionStatusMap(rp.db, user.Did, issue.AtUri())
93
93
}
94
94
95
-
issueOwnerIdent, err := rp.idResolver.ResolveIdent(r.Context(), issue.OwnerDid)
96
-
if err != nil {
97
-
log.Println("failed to resolve issue owner", err)
98
-
}
99
-
100
95
rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{
101
-
LoggedInUser: user,
102
-
RepoInfo: f.RepoInfo(user),
103
-
Issue: issue,
104
-
Comments: comments,
105
-
106
-
IssueOwnerHandle: issueOwnerIdent.Handle.String(),
107
-
96
+
LoggedInUser: user,
97
+
RepoInfo: f.RepoInfo(user),
98
+
Issue: issue,
99
+
CommentList: issue.CommentList(),
108
100
OrderedReactionKinds: db.OrderedReactionKinds,
109
101
Reactions: reactionCountMap,
110
102
UserReacted: userReactions,
111
103
})
112
-
113
104
}
114
105
115
-
func (rp *Issues) CloseIssue(w http.ResponseWriter, r *http.Request) {
106
+
func (rp *Issues) EditIssue(w http.ResponseWriter, r *http.Request) {
107
+
l := rp.logger.With("handler", "EditIssue")
116
108
user := rp.oauth.GetUser(r)
117
109
f, err := rp.repoResolver.Resolve(r)
118
110
if err != nil {
···
120
112
return
121
113
}
122
114
123
-
issueId := chi.URLParam(r, "issue")
124
-
issueIdInt, err := strconv.Atoi(issueId)
125
-
if err != nil {
126
-
http.Error(w, "bad issue id", http.StatusBadRequest)
127
-
log.Println("failed to parse issue id", err)
115
+
issue, ok := r.Context().Value("issue").(*db.Issue)
116
+
if !ok {
117
+
l.Error("failed to get issue")
118
+
rp.pages.Error404(w)
128
119
return
129
120
}
130
121
131
-
issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt)
132
-
if err != nil {
133
-
log.Println("failed to get issue", err)
134
-
rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
135
-
return
136
-
}
122
+
switch r.Method {
123
+
case http.MethodGet:
124
+
rp.pages.EditIssueFragment(w, pages.EditIssueParams{
125
+
LoggedInUser: user,
126
+
RepoInfo: f.RepoInfo(user),
127
+
Issue: issue,
128
+
})
129
+
case http.MethodPost:
130
+
noticeId := "issues"
131
+
newIssue := issue
132
+
newIssue.Title = r.FormValue("title")
133
+
newIssue.Body = r.FormValue("body")
137
134
138
-
collaborators, err := f.Collaborators(r.Context())
139
-
if err != nil {
140
-
log.Println("failed to fetch repo collaborators: %w", err)
141
-
}
142
-
isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool {
143
-
return user.Did == collab.Did
144
-
})
145
-
isIssueOwner := user.Did == issue.OwnerDid
135
+
if err := rp.validator.ValidateIssue(newIssue); err != nil {
136
+
l.Error("validation error", "err", err)
137
+
rp.pages.Notice(w, noticeId, fmt.Sprintf("Failed to edit issue: %s", err))
138
+
return
139
+
}
146
140
147
-
// TODO: make this more granular
148
-
if isIssueOwner || isCollaborator {
149
-
150
-
closed := tangled.RepoIssueStateClosed
141
+
newRecord := newIssue.AsRecord()
151
142
143
+
// edit an atproto record
152
144
client, err := rp.oauth.AuthorizedClient(r)
153
145
if err != nil {
154
-
log.Println("failed to get authorized client", err)
146
+
l.Error("failed to get authorized client", "err", err)
147
+
rp.pages.Notice(w, noticeId, "Failed to edit issue.")
155
148
return
156
149
}
150
+
151
+
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueNSID, user.Did, newIssue.Rkey)
152
+
if err != nil {
153
+
l.Error("failed to get record", "err", err)
154
+
rp.pages.Notice(w, noticeId, "Failed to edit issue, no record found on PDS.")
155
+
return
156
+
}
157
+
157
158
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
158
-
Collection: tangled.RepoIssueStateNSID,
159
+
Collection: tangled.RepoIssueNSID,
159
160
Repo: user.Did,
160
-
Rkey: tid.TID(),
161
+
Rkey: newIssue.Rkey,
162
+
SwapRecord: ex.Cid,
161
163
Record: &lexutil.LexiconTypeDecoder{
162
-
Val: &tangled.RepoIssueState{
163
-
Issue: issue.AtUri().String(),
164
-
State: closed,
165
-
},
164
+
Val: &newRecord,
166
165
},
167
166
})
167
+
if err != nil {
168
+
l.Error("failed to edit record on PDS", "err", err)
169
+
rp.pages.Notice(w, noticeId, "Failed to edit issue on PDS.")
170
+
return
171
+
}
168
172
173
+
// modify on DB -- TODO: transact this cleverly
174
+
tx, err := rp.db.Begin()
169
175
if err != nil {
170
-
log.Println("failed to update issue state", err)
171
-
rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
176
+
l.Error("failed to edit issue on DB", "err", err)
177
+
rp.pages.Notice(w, noticeId, "Failed to edit issue.")
172
178
return
173
179
}
180
+
defer tx.Rollback()
174
181
175
-
err = db.CloseIssue(rp.db, f.RepoAt(), issueIdInt)
182
+
err = db.PutIssue(tx, newIssue)
183
+
if err != nil {
184
+
log.Println("failed to edit issue", err)
185
+
rp.pages.Notice(w, "issues", "Failed to edit issue.")
186
+
return
187
+
}
188
+
189
+
if err = tx.Commit(); err != nil {
190
+
l.Error("failed to edit issue", "err", err)
191
+
rp.pages.Notice(w, "issues", "Failed to cedit issue.")
192
+
return
193
+
}
194
+
195
+
rp.pages.HxRefresh(w)
196
+
}
197
+
}
198
+
199
+
func (rp *Issues) DeleteIssue(w http.ResponseWriter, r *http.Request) {
200
+
l := rp.logger.With("handler", "DeleteIssue")
201
+
noticeId := "issue-actions-error"
202
+
203
+
user := rp.oauth.GetUser(r)
204
+
205
+
f, err := rp.repoResolver.Resolve(r)
206
+
if err != nil {
207
+
l.Error("failed to get repo and knot", "err", err)
208
+
return
209
+
}
210
+
211
+
issue, ok := r.Context().Value("issue").(*db.Issue)
212
+
if !ok {
213
+
l.Error("failed to get issue")
214
+
rp.pages.Notice(w, noticeId, "Failed to delete issue.")
215
+
return
216
+
}
217
+
l = l.With("did", issue.Did, "rkey", issue.Rkey)
218
+
219
+
// delete from PDS
220
+
client, err := rp.oauth.AuthorizedClient(r)
221
+
if err != nil {
222
+
log.Println("failed to get authorized client", err)
223
+
rp.pages.Notice(w, "issue-comment", "Failed to delete comment.")
224
+
return
225
+
}
226
+
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
227
+
Collection: tangled.RepoIssueNSID,
228
+
Repo: issue.Did,
229
+
Rkey: issue.Rkey,
230
+
})
231
+
if err != nil {
232
+
// TODO: transact this better
233
+
l.Error("failed to delete issue from PDS", "err", err)
234
+
rp.pages.Notice(w, noticeId, "Failed to delete issue.")
235
+
return
236
+
}
237
+
238
+
// delete from db
239
+
if err := db.DeleteIssues(rp.db, db.FilterEq("id", issue.Id)); err != nil {
240
+
l.Error("failed to delete issue", "err", err)
241
+
rp.pages.Notice(w, noticeId, "Failed to delete issue.")
242
+
return
243
+
}
244
+
245
+
// return to all issues page
246
+
rp.pages.HxRedirect(w, "/"+f.RepoInfo(user).FullName()+"/issues")
247
+
}
248
+
249
+
func (rp *Issues) CloseIssue(w http.ResponseWriter, r *http.Request) {
250
+
l := rp.logger.With("handler", "CloseIssue")
251
+
user := rp.oauth.GetUser(r)
252
+
f, err := rp.repoResolver.Resolve(r)
253
+
if err != nil {
254
+
l.Error("failed to get repo and knot", "err", err)
255
+
return
256
+
}
257
+
258
+
issue, ok := r.Context().Value("issue").(*db.Issue)
259
+
if !ok {
260
+
l.Error("failed to get issue")
261
+
rp.pages.Error404(w)
262
+
return
263
+
}
264
+
265
+
collaborators, err := f.Collaborators(r.Context())
266
+
if err != nil {
267
+
log.Println("failed to fetch repo collaborators: %w", err)
268
+
}
269
+
isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool {
270
+
return user.Did == collab.Did
271
+
})
272
+
isIssueOwner := user.Did == issue.Did
273
+
274
+
// TODO: make this more granular
275
+
if isIssueOwner || isCollaborator {
276
+
err = db.CloseIssues(
277
+
rp.db,
278
+
db.FilterEq("id", issue.Id),
279
+
)
176
280
if err != nil {
177
281
log.Println("failed to close issue", err)
178
282
rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
179
283
return
180
284
}
181
285
182
-
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt))
286
+
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
183
287
return
184
288
} else {
185
289
log.Println("user is not permitted to close issue")
···
189
293
}
190
294
191
295
func (rp *Issues) ReopenIssue(w http.ResponseWriter, r *http.Request) {
296
+
l := rp.logger.With("handler", "ReopenIssue")
192
297
user := rp.oauth.GetUser(r)
193
298
f, err := rp.repoResolver.Resolve(r)
194
299
if err != nil {
···
196
301
return
197
302
}
198
303
199
-
issueId := chi.URLParam(r, "issue")
200
-
issueIdInt, err := strconv.Atoi(issueId)
201
-
if err != nil {
202
-
http.Error(w, "bad issue id", http.StatusBadRequest)
203
-
log.Println("failed to parse issue id", err)
204
-
return
205
-
}
206
-
207
-
issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt)
208
-
if err != nil {
209
-
log.Println("failed to get issue", err)
210
-
rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
304
+
issue, ok := r.Context().Value("issue").(*db.Issue)
305
+
if !ok {
306
+
l.Error("failed to get issue")
307
+
rp.pages.Error404(w)
211
308
return
212
309
}
213
310
···
218
315
isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool {
219
316
return user.Did == collab.Did
220
317
})
221
-
isIssueOwner := user.Did == issue.OwnerDid
318
+
isIssueOwner := user.Did == issue.Did
222
319
223
320
if isCollaborator || isIssueOwner {
224
-
err := db.ReopenIssue(rp.db, f.RepoAt(), issueIdInt)
321
+
err := db.ReopenIssues(
322
+
rp.db,
323
+
db.FilterEq("id", issue.Id),
324
+
)
225
325
if err != nil {
226
326
log.Println("failed to reopen issue", err)
227
327
rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.")
228
328
return
229
329
}
230
-
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt))
330
+
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
231
331
return
232
332
} else {
233
333
log.Println("user is not the owner of the repo")
···
237
337
}
238
338
239
339
func (rp *Issues) NewIssueComment(w http.ResponseWriter, r *http.Request) {
340
+
l := rp.logger.With("handler", "NewIssueComment")
240
341
user := rp.oauth.GetUser(r)
241
342
f, err := rp.repoResolver.Resolve(r)
242
343
if err != nil {
243
-
log.Println("failed to get repo and knot", err)
344
+
l.Error("failed to get repo and knot", "err", err)
244
345
return
245
346
}
246
347
247
-
issueId := chi.URLParam(r, "issue")
248
-
issueIdInt, err := strconv.Atoi(issueId)
249
-
if err != nil {
250
-
http.Error(w, "bad issue id", http.StatusBadRequest)
251
-
log.Println("failed to parse issue id", err)
348
+
issue, ok := r.Context().Value("issue").(*db.Issue)
349
+
if !ok {
350
+
l.Error("failed to get issue")
351
+
rp.pages.Error404(w)
252
352
return
253
353
}
254
354
255
-
switch r.Method {
256
-
case http.MethodPost:
257
-
body := r.FormValue("body")
258
-
if body == "" {
259
-
rp.pages.Notice(w, "issue", "Body is required")
260
-
return
261
-
}
355
+
body := r.FormValue("body")
356
+
if body == "" {
357
+
rp.pages.Notice(w, "issue", "Body is required")
358
+
return
359
+
}
262
360
263
-
commentId := mathrand.IntN(1000000)
264
-
rkey := tid.TID()
361
+
replyToUri := r.FormValue("reply-to")
362
+
var replyTo *string
363
+
if replyToUri != "" {
364
+
replyTo = &replyToUri
365
+
}
265
366
266
-
err := db.NewIssueComment(rp.db, &db.Comment{
267
-
OwnerDid: user.Did,
268
-
RepoAt: f.RepoAt(),
269
-
Issue: issueIdInt,
270
-
CommentId: commentId,
271
-
Body: body,
272
-
Rkey: rkey,
273
-
})
274
-
if err != nil {
275
-
log.Println("failed to create comment", err)
276
-
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
277
-
return
278
-
}
367
+
comment := db.IssueComment{
368
+
Did: user.Did,
369
+
Rkey: tid.TID(),
370
+
IssueAt: issue.AtUri().String(),
371
+
ReplyTo: replyTo,
372
+
Body: body,
373
+
Created: time.Now(),
374
+
}
375
+
if err = rp.validator.ValidateIssueComment(&comment); err != nil {
376
+
l.Error("failed to validate comment", "err", err)
377
+
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
378
+
return
379
+
}
380
+
record := comment.AsRecord()
279
381
280
-
createdAt := time.Now().Format(time.RFC3339)
281
-
commentIdInt64 := int64(commentId)
282
-
ownerDid := user.Did
283
-
issueAt, err := db.GetIssueAt(rp.db, f.RepoAt(), issueIdInt)
284
-
if err != nil {
285
-
log.Println("failed to get issue at", err)
286
-
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
287
-
return
288
-
}
382
+
client, err := rp.oauth.AuthorizedClient(r)
383
+
if err != nil {
384
+
l.Error("failed to get authorized client", "err", err)
385
+
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
386
+
return
387
+
}
289
388
290
-
atUri := f.RepoAt().String()
291
-
client, err := rp.oauth.AuthorizedClient(r)
292
-
if err != nil {
293
-
log.Println("failed to get authorized client", err)
294
-
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
295
-
return
389
+
// create a record first
390
+
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
391
+
Collection: tangled.RepoIssueCommentNSID,
392
+
Repo: comment.Did,
393
+
Rkey: comment.Rkey,
394
+
Record: &lexutil.LexiconTypeDecoder{
395
+
Val: &record,
396
+
},
397
+
})
398
+
if err != nil {
399
+
l.Error("failed to create comment", "err", err)
400
+
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
401
+
return
402
+
}
403
+
atUri := resp.Uri
404
+
defer func() {
405
+
if err := rollbackRecord(context.Background(), atUri, client); err != nil {
406
+
l.Error("rollback failed", "err", err)
296
407
}
297
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
298
-
Collection: tangled.RepoIssueCommentNSID,
299
-
Repo: user.Did,
300
-
Rkey: rkey,
301
-
Record: &lexutil.LexiconTypeDecoder{
302
-
Val: &tangled.RepoIssueComment{
303
-
Repo: &atUri,
304
-
Issue: issueAt,
305
-
CommentId: &commentIdInt64,
306
-
Owner: &ownerDid,
307
-
Body: body,
308
-
CreatedAt: createdAt,
309
-
},
310
-
},
311
-
})
312
-
if err != nil {
313
-
log.Println("failed to create comment", err)
314
-
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
315
-
return
316
-
}
408
+
}()
317
409
318
-
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issueIdInt, commentId))
410
+
commentId, err := db.AddIssueComment(rp.db, comment)
411
+
if err != nil {
412
+
l.Error("failed to create comment", "err", err)
413
+
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
319
414
return
320
415
}
416
+
417
+
// reset atUri to make rollback a no-op
418
+
atUri = ""
419
+
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issue.IssueId, commentId))
321
420
}
322
421
323
422
func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) {
423
+
l := rp.logger.With("handler", "IssueComment")
324
424
user := rp.oauth.GetUser(r)
325
425
f, err := rp.repoResolver.Resolve(r)
326
426
if err != nil {
327
-
log.Println("failed to get repo and knot", err)
328
-
return
329
-
}
330
-
331
-
issueId := chi.URLParam(r, "issue")
332
-
issueIdInt, err := strconv.Atoi(issueId)
333
-
if err != nil {
334
-
http.Error(w, "bad issue id", http.StatusBadRequest)
335
-
log.Println("failed to parse issue id", err)
427
+
l.Error("failed to get repo and knot", "err", err)
336
428
return
337
429
}
338
430
339
-
commentId := chi.URLParam(r, "comment_id")
340
-
commentIdInt, err := strconv.Atoi(commentId)
341
-
if err != nil {
342
-
http.Error(w, "bad comment id", http.StatusBadRequest)
343
-
log.Println("failed to parse issue id", err)
431
+
issue, ok := r.Context().Value("issue").(*db.Issue)
432
+
if !ok {
433
+
l.Error("failed to get issue")
434
+
rp.pages.Error404(w)
344
435
return
345
436
}
346
437
347
-
issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt)
438
+
commentId := chi.URLParam(r, "commentId")
439
+
comments, err := db.GetIssueComments(
440
+
rp.db,
441
+
db.FilterEq("id", commentId),
442
+
)
348
443
if err != nil {
349
-
log.Println("failed to get issue", err)
350
-
rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
444
+
l.Error("failed to fetch comment", "id", commentId)
445
+
http.Error(w, "failed to fetch comment id", http.StatusBadRequest)
351
446
return
352
447
}
353
-
354
-
comment, err := db.GetComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt)
355
-
if err != nil {
356
-
http.Error(w, "bad comment id", http.StatusBadRequest)
448
+
if len(comments) != 1 {
449
+
l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments))
450
+
http.Error(w, "invalid comment id", http.StatusBadRequest)
357
451
return
358
452
}
453
+
comment := comments[0]
359
454
360
-
rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
455
+
rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{
361
456
LoggedInUser: user,
362
457
RepoInfo: f.RepoInfo(user),
363
458
Issue: issue,
364
-
Comment: comment,
459
+
Comment: &comment,
365
460
})
366
461
}
367
462
368
463
func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) {
464
+
l := rp.logger.With("handler", "EditIssueComment")
369
465
user := rp.oauth.GetUser(r)
370
466
f, err := rp.repoResolver.Resolve(r)
371
467
if err != nil {
372
-
log.Println("failed to get repo and knot", err)
373
-
return
374
-
}
375
-
376
-
issueId := chi.URLParam(r, "issue")
377
-
issueIdInt, err := strconv.Atoi(issueId)
378
-
if err != nil {
379
-
http.Error(w, "bad issue id", http.StatusBadRequest)
380
-
log.Println("failed to parse issue id", err)
468
+
l.Error("failed to get repo and knot", "err", err)
381
469
return
382
470
}
383
471
384
-
commentId := chi.URLParam(r, "comment_id")
385
-
commentIdInt, err := strconv.Atoi(commentId)
386
-
if err != nil {
387
-
http.Error(w, "bad comment id", http.StatusBadRequest)
388
-
log.Println("failed to parse issue id", err)
472
+
issue, ok := r.Context().Value("issue").(*db.Issue)
473
+
if !ok {
474
+
l.Error("failed to get issue")
475
+
rp.pages.Error404(w)
389
476
return
390
477
}
391
478
392
-
issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt)
479
+
commentId := chi.URLParam(r, "commentId")
480
+
comments, err := db.GetIssueComments(
481
+
rp.db,
482
+
db.FilterEq("id", commentId),
483
+
)
393
484
if err != nil {
394
-
log.Println("failed to get issue", err)
395
-
rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
485
+
l.Error("failed to fetch comment", "id", commentId)
486
+
http.Error(w, "failed to fetch comment id", http.StatusBadRequest)
396
487
return
397
488
}
398
-
399
-
comment, err := db.GetComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt)
400
-
if err != nil {
401
-
http.Error(w, "bad comment id", http.StatusBadRequest)
489
+
if len(comments) != 1 {
490
+
l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments))
491
+
http.Error(w, "invalid comment id", http.StatusBadRequest)
402
492
return
403
493
}
494
+
comment := comments[0]
404
495
405
-
if comment.OwnerDid != user.Did {
496
+
if comment.Did != user.Did {
497
+
l.Error("unauthorized comment edit", "expectedDid", comment.Did, "gotDid", user.Did)
406
498
http.Error(w, "you are not the author of this comment", http.StatusUnauthorized)
407
499
return
408
500
}
···
413
505
LoggedInUser: user,
414
506
RepoInfo: f.RepoInfo(user),
415
507
Issue: issue,
416
-
Comment: comment,
508
+
Comment: &comment,
417
509
})
418
510
case http.MethodPost:
419
511
// extract form value
···
424
516
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
425
517
return
426
518
}
427
-
rkey := comment.Rkey
519
+
520
+
now := time.Now()
521
+
newComment := comment
522
+
newComment.Body = newBody
523
+
newComment.Edited = &now
524
+
record := newComment.AsRecord()
428
525
429
-
// optimistic update
430
-
edited := time.Now()
431
-
err = db.EditComment(rp.db, comment.RepoAt, comment.Issue, comment.CommentId, newBody)
526
+
_, err = db.AddIssueComment(rp.db, newComment)
432
527
if err != nil {
433
528
log.Println("failed to perferom update-description query", err)
434
529
rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
···
436
531
}
437
532
438
533
// rkey is optional, it was introduced later
439
-
if comment.Rkey != "" {
534
+
if newComment.Rkey != "" {
440
535
// update the record on pds
441
-
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, rkey)
536
+
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey)
442
537
if err != nil {
443
-
// failed to get record
444
-
log.Println(err, rkey)
538
+
log.Println("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey)
445
539
rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.")
446
540
return
447
541
}
448
-
value, _ := ex.Value.MarshalJSON() // we just did get record; it is valid json
449
-
record, _ := data.UnmarshalJSON(value)
450
-
451
-
repoAt := record["repo"].(string)
452
-
issueAt := record["issue"].(string)
453
-
createdAt := record["createdAt"].(string)
454
-
commentIdInt64 := int64(commentIdInt)
455
542
456
543
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
457
544
Collection: tangled.RepoIssueCommentNSID,
458
545
Repo: user.Did,
459
-
Rkey: rkey,
546
+
Rkey: newComment.Rkey,
460
547
SwapRecord: ex.Cid,
461
548
Record: &lexutil.LexiconTypeDecoder{
462
-
Val: &tangled.RepoIssueComment{
463
-
Repo: &repoAt,
464
-
Issue: issueAt,
465
-
CommentId: &commentIdInt64,
466
-
Owner: &comment.OwnerDid,
467
-
Body: newBody,
468
-
CreatedAt: createdAt,
469
-
},
549
+
Val: &record,
470
550
},
471
551
})
472
552
if err != nil {
473
-
log.Println(err)
553
+
l.Error("failed to update record on PDS", "err", err)
474
554
}
475
555
}
476
556
477
-
// optimistic update for htmx
478
-
comment.Body = newBody
479
-
comment.Edited = &edited
480
-
481
557
// return new comment body with htmx
482
-
rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
558
+
rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{
483
559
LoggedInUser: user,
484
560
RepoInfo: f.RepoInfo(user),
485
561
Issue: issue,
486
-
Comment: comment,
562
+
Comment: &newComment,
487
563
})
564
+
}
565
+
}
566
+
567
+
func (rp *Issues) ReplyIssueCommentPlaceholder(w http.ResponseWriter, r *http.Request) {
568
+
l := rp.logger.With("handler", "ReplyIssueCommentPlaceholder")
569
+
user := rp.oauth.GetUser(r)
570
+
f, err := rp.repoResolver.Resolve(r)
571
+
if err != nil {
572
+
l.Error("failed to get repo and knot", "err", err)
488
573
return
574
+
}
489
575
576
+
issue, ok := r.Context().Value("issue").(*db.Issue)
577
+
if !ok {
578
+
l.Error("failed to get issue")
579
+
rp.pages.Error404(w)
580
+
return
490
581
}
491
582
583
+
commentId := chi.URLParam(r, "commentId")
584
+
comments, err := db.GetIssueComments(
585
+
rp.db,
586
+
db.FilterEq("id", commentId),
587
+
)
588
+
if err != nil {
589
+
l.Error("failed to fetch comment", "id", commentId)
590
+
http.Error(w, "failed to fetch comment id", http.StatusBadRequest)
591
+
return
592
+
}
593
+
if len(comments) != 1 {
594
+
l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments))
595
+
http.Error(w, "invalid comment id", http.StatusBadRequest)
596
+
return
597
+
}
598
+
comment := comments[0]
599
+
600
+
rp.pages.ReplyIssueCommentPlaceholderFragment(w, pages.ReplyIssueCommentPlaceholderParams{
601
+
LoggedInUser: user,
602
+
RepoInfo: f.RepoInfo(user),
603
+
Issue: issue,
604
+
Comment: &comment,
605
+
})
492
606
}
493
607
494
-
func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) {
608
+
func (rp *Issues) ReplyIssueComment(w http.ResponseWriter, r *http.Request) {
609
+
l := rp.logger.With("handler", "ReplyIssueComment")
495
610
user := rp.oauth.GetUser(r)
496
611
f, err := rp.repoResolver.Resolve(r)
497
612
if err != nil {
498
-
log.Println("failed to get repo and knot", err)
613
+
l.Error("failed to get repo and knot", "err", err)
614
+
return
615
+
}
616
+
617
+
issue, ok := r.Context().Value("issue").(*db.Issue)
618
+
if !ok {
619
+
l.Error("failed to get issue")
620
+
rp.pages.Error404(w)
499
621
return
500
622
}
501
623
502
-
issueId := chi.URLParam(r, "issue")
503
-
issueIdInt, err := strconv.Atoi(issueId)
624
+
commentId := chi.URLParam(r, "commentId")
625
+
comments, err := db.GetIssueComments(
626
+
rp.db,
627
+
db.FilterEq("id", commentId),
628
+
)
504
629
if err != nil {
505
-
http.Error(w, "bad issue id", http.StatusBadRequest)
506
-
log.Println("failed to parse issue id", err)
630
+
l.Error("failed to fetch comment", "id", commentId)
631
+
http.Error(w, "failed to fetch comment id", http.StatusBadRequest)
507
632
return
508
633
}
634
+
if len(comments) != 1 {
635
+
l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments))
636
+
http.Error(w, "invalid comment id", http.StatusBadRequest)
637
+
return
638
+
}
639
+
comment := comments[0]
509
640
510
-
issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt)
641
+
rp.pages.ReplyIssueCommentFragment(w, pages.ReplyIssueCommentParams{
642
+
LoggedInUser: user,
643
+
RepoInfo: f.RepoInfo(user),
644
+
Issue: issue,
645
+
Comment: &comment,
646
+
})
647
+
}
648
+
649
+
func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) {
650
+
l := rp.logger.With("handler", "DeleteIssueComment")
651
+
user := rp.oauth.GetUser(r)
652
+
f, err := rp.repoResolver.Resolve(r)
511
653
if err != nil {
512
-
log.Println("failed to get issue", err)
513
-
rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
654
+
l.Error("failed to get repo and knot", "err", err)
514
655
return
515
656
}
516
657
517
-
commentId := chi.URLParam(r, "comment_id")
518
-
commentIdInt, err := strconv.Atoi(commentId)
519
-
if err != nil {
520
-
http.Error(w, "bad comment id", http.StatusBadRequest)
521
-
log.Println("failed to parse issue id", err)
658
+
issue, ok := r.Context().Value("issue").(*db.Issue)
659
+
if !ok {
660
+
l.Error("failed to get issue")
661
+
rp.pages.Error404(w)
522
662
return
523
663
}
524
664
525
-
comment, err := db.GetComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt)
665
+
commentId := chi.URLParam(r, "commentId")
666
+
comments, err := db.GetIssueComments(
667
+
rp.db,
668
+
db.FilterEq("id", commentId),
669
+
)
526
670
if err != nil {
527
-
http.Error(w, "bad comment id", http.StatusBadRequest)
671
+
l.Error("failed to fetch comment", "id", commentId)
672
+
http.Error(w, "failed to fetch comment id", http.StatusBadRequest)
528
673
return
529
674
}
675
+
if len(comments) != 1 {
676
+
l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments))
677
+
http.Error(w, "invalid comment id", http.StatusBadRequest)
678
+
return
679
+
}
680
+
comment := comments[0]
530
681
531
-
if comment.OwnerDid != user.Did {
682
+
if comment.Did != user.Did {
683
+
l.Error("unauthorized action", "expectedDid", comment.Did, "gotDid", user.Did)
532
684
http.Error(w, "you are not the author of this comment", http.StatusUnauthorized)
533
685
return
534
686
}
···
540
692
541
693
// optimistic deletion
542
694
deleted := time.Now()
543
-
err = db.DeleteComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt)
695
+
err = db.DeleteIssueComments(rp.db, db.FilterEq("id", comment.Id))
544
696
if err != nil {
545
-
log.Println("failed to delete comment")
697
+
l.Error("failed to delete comment", "err", err)
546
698
rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment")
547
699
return
548
700
}
···
556
708
return
557
709
}
558
710
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
559
-
Collection: tangled.GraphFollowNSID,
711
+
Collection: tangled.RepoIssueCommentNSID,
560
712
Repo: user.Did,
561
713
Rkey: comment.Rkey,
562
714
})
···
570
722
comment.Deleted = &deleted
571
723
572
724
// htmx fragment of comment after deletion
573
-
rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
725
+
rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{
574
726
LoggedInUser: user,
575
727
RepoInfo: f.RepoInfo(user),
576
728
Issue: issue,
577
-
Comment: comment,
729
+
Comment: &comment,
578
730
})
579
731
}
580
732
···
604
756
return
605
757
}
606
758
607
-
issues, err := db.GetIssuesPaginated(rp.db, f.RepoAt(), isOpen, page)
759
+
openVal := 0
760
+
if isOpen {
761
+
openVal = 1
762
+
}
763
+
issues, err := db.GetIssuesPaginated(
764
+
rp.db,
765
+
page,
766
+
db.FilterEq("repo_at", f.RepoAt()),
767
+
db.FilterEq("open", openVal),
768
+
)
608
769
if err != nil {
609
770
log.Println("failed to get issues", err)
610
771
rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
···
621
782
}
622
783
623
784
func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) {
785
+
l := rp.logger.With("handler", "NewIssue")
624
786
user := rp.oauth.GetUser(r)
625
787
626
788
f, err := rp.repoResolver.Resolve(r)
627
789
if err != nil {
628
-
log.Println("failed to get repo and knot", err)
790
+
l.Error("failed to get repo and knot", "err", err)
629
791
return
630
792
}
631
793
···
636
798
RepoInfo: f.RepoInfo(user),
637
799
})
638
800
case http.MethodPost:
639
-
title := r.FormValue("title")
640
-
body := r.FormValue("body")
801
+
issue := &db.Issue{
802
+
RepoAt: f.RepoAt(),
803
+
Rkey: tid.TID(),
804
+
Title: r.FormValue("title"),
805
+
Body: r.FormValue("body"),
806
+
Did: user.Did,
807
+
Created: time.Now(),
808
+
}
641
809
642
-
if title == "" || body == "" {
643
-
rp.pages.Notice(w, "issues", "Title and body are required")
810
+
if err := rp.validator.ValidateIssue(issue); err != nil {
811
+
l.Error("validation error", "err", err)
812
+
rp.pages.Notice(w, "issues", fmt.Sprintf("Failed to create issue: %s", err))
644
813
return
645
814
}
646
815
647
-
sanitizer := markup.NewSanitizer()
648
-
if st := strings.TrimSpace(sanitizer.SanitizeDescription(title)); st == "" {
649
-
rp.pages.Notice(w, "issues", "Title is empty after HTML sanitization")
816
+
record := issue.AsRecord()
817
+
818
+
// create an atproto record
819
+
client, err := rp.oauth.AuthorizedClient(r)
820
+
if err != nil {
821
+
l.Error("failed to get authorized client", "err", err)
822
+
rp.pages.Notice(w, "issues", "Failed to create issue.")
650
823
return
651
824
}
652
-
if sb := strings.TrimSpace(sanitizer.SanitizeDefault(body)); sb == "" {
653
-
rp.pages.Notice(w, "issues", "Body is empty after HTML sanitization")
825
+
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
826
+
Collection: tangled.RepoIssueNSID,
827
+
Repo: user.Did,
828
+
Rkey: issue.Rkey,
829
+
Record: &lexutil.LexiconTypeDecoder{
830
+
Val: &record,
831
+
},
832
+
})
833
+
if err != nil {
834
+
l.Error("failed to create issue", "err", err)
835
+
rp.pages.Notice(w, "issues", "Failed to create issue.")
654
836
return
655
837
}
838
+
atUri := resp.Uri
656
839
657
840
tx, err := rp.db.BeginTx(r.Context(), nil)
658
841
if err != nil {
659
842
rp.pages.Notice(w, "issues", "Failed to create issue, try again later")
660
843
return
661
844
}
845
+
rollback := func() {
846
+
err1 := tx.Rollback()
847
+
err2 := rollbackRecord(context.Background(), atUri, client)
662
848
663
-
issue := &db.Issue{
664
-
RepoAt: f.RepoAt(),
665
-
Rkey: tid.TID(),
666
-
Title: title,
667
-
Body: body,
668
-
OwnerDid: user.Did,
849
+
if errors.Is(err1, sql.ErrTxDone) {
850
+
err1 = nil
851
+
}
852
+
853
+
if err := errors.Join(err1, err2); err != nil {
854
+
l.Error("failed to rollback txn", "err", err)
855
+
}
669
856
}
670
-
err = db.NewIssue(tx, issue)
857
+
defer rollback()
858
+
859
+
err = db.PutIssue(tx, issue)
671
860
if err != nil {
672
861
log.Println("failed to create issue", err)
673
862
rp.pages.Notice(w, "issues", "Failed to create issue.")
674
863
return
675
864
}
676
865
677
-
client, err := rp.oauth.AuthorizedClient(r)
678
-
if err != nil {
679
-
log.Println("failed to get authorized client", err)
680
-
rp.pages.Notice(w, "issues", "Failed to create issue.")
681
-
return
682
-
}
683
-
atUri := f.RepoAt().String()
684
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
685
-
Collection: tangled.RepoIssueNSID,
686
-
Repo: user.Did,
687
-
Rkey: issue.Rkey,
688
-
Record: &lexutil.LexiconTypeDecoder{
689
-
Val: &tangled.RepoIssue{
690
-
Repo: atUri,
691
-
Title: title,
692
-
Body: &body,
693
-
Owner: user.Did,
694
-
IssueId: int64(issue.IssueId),
695
-
},
696
-
},
697
-
})
698
-
if err != nil {
866
+
if err = tx.Commit(); err != nil {
699
867
log.Println("failed to create issue", err)
700
868
rp.pages.Notice(w, "issues", "Failed to create issue.")
701
869
return
702
870
}
703
871
872
+
// everything is successful, do not rollback the atproto record
873
+
atUri = ""
704
874
rp.notifier.NewIssue(r.Context(), issue)
705
-
706
875
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
707
876
return
708
877
}
709
878
}
879
+
880
+
// this is used to rollback changes made to the PDS
881
+
//
882
+
// it is a no-op if the provided ATURI is empty
883
+
func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error {
884
+
if aturi == "" {
885
+
return nil
886
+
}
887
+
888
+
parsed := syntax.ATURI(aturi)
889
+
890
+
collection := parsed.Collection().String()
891
+
repo := parsed.Authority().String()
892
+
rkey := parsed.RecordKey().String()
893
+
894
+
_, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{
895
+
Collection: collection,
896
+
Repo: repo,
897
+
Rkey: rkey,
898
+
})
899
+
return err
900
+
}
+24
-10
appview/issues/router.go
+24
-10
appview/issues/router.go
···
12
12
13
13
r.Route("/", func(r chi.Router) {
14
14
r.With(middleware.Paginate).Get("/", i.RepoIssues)
15
-
r.Get("/{issue}", i.RepoSingleIssue)
15
+
16
+
r.Route("/{issue}", func(r chi.Router) {
17
+
r.Use(mw.ResolveIssue())
18
+
r.Get("/", i.RepoSingleIssue)
19
+
20
+
// authenticated routes
21
+
r.Group(func(r chi.Router) {
22
+
r.Use(middleware.AuthMiddleware(i.oauth))
23
+
r.Post("/comment", i.NewIssueComment)
24
+
r.Route("/comment/{commentId}/", func(r chi.Router) {
25
+
r.Get("/", i.IssueComment)
26
+
r.Delete("/", i.DeleteIssueComment)
27
+
r.Get("/edit", i.EditIssueComment)
28
+
r.Post("/edit", i.EditIssueComment)
29
+
r.Get("/reply", i.ReplyIssueComment)
30
+
r.Get("/replyPlaceholder", i.ReplyIssueCommentPlaceholder)
31
+
})
32
+
r.Get("/edit", i.EditIssue)
33
+
r.Post("/edit", i.EditIssue)
34
+
r.Delete("/", i.DeleteIssue)
35
+
r.Post("/close", i.CloseIssue)
36
+
r.Post("/reopen", i.ReopenIssue)
37
+
})
38
+
})
16
39
17
40
r.Group(func(r chi.Router) {
18
41
r.Use(middleware.AuthMiddleware(i.oauth))
19
42
r.Get("/new", i.NewIssue)
20
43
r.Post("/new", i.NewIssue)
21
-
r.Post("/{issue}/comment", i.NewIssueComment)
22
-
r.Route("/{issue}/comment/{comment_id}/", func(r chi.Router) {
23
-
r.Get("/", i.IssueComment)
24
-
r.Delete("/", i.DeleteIssueComment)
25
-
r.Get("/edit", i.EditIssueComment)
26
-
r.Post("/edit", i.EditIssueComment)
27
-
})
28
-
r.Post("/{issue}/close", i.CloseIssue)
29
-
r.Post("/{issue}/reopen", i.ReopenIssue)
30
44
})
31
45
})
32
46
+5
-34
appview/knots/knots.go
+5
-34
appview/knots/knots.go
···
3
3
import (
4
4
"errors"
5
5
"fmt"
6
-
"log"
7
6
"log/slog"
8
7
"net/http"
9
8
"slices"
···
17
16
"tangled.sh/tangled.sh/core/appview/oauth"
18
17
"tangled.sh/tangled.sh/core/appview/pages"
19
18
"tangled.sh/tangled.sh/core/appview/serververify"
19
+
"tangled.sh/tangled.sh/core/appview/xrpcclient"
20
20
"tangled.sh/tangled.sh/core/eventconsumer"
21
21
"tangled.sh/tangled.sh/core/idresolver"
22
22
"tangled.sh/tangled.sh/core/rbac"
···
49
49
r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/retry", k.retry)
50
50
r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/add", k.addMember)
51
51
r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/remove", k.removeMember)
52
-
53
-
r.With(middleware.AuthMiddleware(k.OAuth)).Get("/upgradeBanner", k.banner)
54
52
55
53
return r
56
54
}
···
399
397
if err != nil {
400
398
l.Error("verification failed", "err", err)
401
399
402
-
if errors.Is(err, serververify.FetchError) {
403
-
k.Pages.Notice(w, noticeId, "Failed to verify knot, unable to fetch owner.")
400
+
if errors.Is(err, xrpcclient.ErrXrpcUnsupported) {
401
+
k.Pages.Notice(w, noticeId, "Failed to verify knot, XRPC queries are unsupported on this knot, consider upgrading!")
404
402
return
405
403
}
406
404
···
420
418
return
421
419
}
422
420
423
-
// if this knot was previously read-only, then emit a record too
421
+
// if this knot requires upgrade, then emit a record too
424
422
//
425
423
// this is part of migrating from the old knot system to the new one
426
-
if registration.ReadOnly {
424
+
if registration.NeedsUpgrade {
427
425
// re-announce by registering under same rkey
428
426
client, err := k.OAuth.AuthorizedClient(r)
429
427
if err != nil {
···
484
482
return
485
483
}
486
484
updatedRegistration := registrations[0]
487
-
488
-
log.Println(updatedRegistration)
489
485
490
486
w.Header().Set("HX-Reswap", "outerHTML")
491
487
k.Pages.KnotListing(w, pages.KnotListingParams{
···
678
674
// ok
679
675
k.Pages.HxRefresh(w)
680
676
}
681
-
682
-
func (k *Knots) banner(w http.ResponseWriter, r *http.Request) {
683
-
user := k.OAuth.GetUser(r)
684
-
l := k.Logger.With("handler", "removeMember")
685
-
l = l.With("did", user.Did)
686
-
l = l.With("handle", user.Handle)
687
-
688
-
registrations, err := db.GetRegistrations(
689
-
k.Db,
690
-
db.FilterEq("did", user.Did),
691
-
db.FilterEq("read_only", 1),
692
-
)
693
-
if err != nil {
694
-
l.Error("non-fatal: failed to get registrations")
695
-
return
696
-
}
697
-
698
-
if registrations == nil {
699
-
return
700
-
}
701
-
702
-
k.Pages.KnotBanner(w, pages.KnotBannerParams{
703
-
Registrations: registrations,
704
-
})
705
-
}
+40
appview/middleware/middleware.go
+40
appview/middleware/middleware.go
···
275
275
}
276
276
}
277
277
278
+
// middleware that is tacked on top of /{user}/{repo}/issues/{issue}
279
+
func (mw Middleware) ResolveIssue() middlewareFunc {
280
+
return func(next http.Handler) http.Handler {
281
+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
282
+
f, err := mw.repoResolver.Resolve(r)
283
+
if err != nil {
284
+
log.Println("failed to fully resolve repo", err)
285
+
mw.pages.ErrorKnot404(w)
286
+
return
287
+
}
288
+
289
+
issueIdStr := chi.URLParam(r, "issue")
290
+
issueId, err := strconv.Atoi(issueIdStr)
291
+
if err != nil {
292
+
log.Println("failed to fully resolve issue ID", err)
293
+
mw.pages.ErrorKnot404(w)
294
+
return
295
+
}
296
+
297
+
issues, err := db.GetIssues(
298
+
mw.db,
299
+
db.FilterEq("repo_at", f.RepoAt()),
300
+
db.FilterEq("issue_id", issueId),
301
+
)
302
+
if err != nil {
303
+
log.Println("failed to get issues", "err", err)
304
+
return
305
+
}
306
+
if len(issues) != 1 {
307
+
log.Println("got incorrect number of issues", "len(issuse)", len(issues))
308
+
return
309
+
}
310
+
issue := issues[0]
311
+
312
+
ctx := context.WithValue(r.Context(), "issue", &issue)
313
+
next.ServeHTTP(w, r.WithContext(ctx))
314
+
})
315
+
}
316
+
}
317
+
278
318
// this should serve the go-import meta tag even if the path is technically
279
319
// a 404 like tangled.sh/oppi.li/go-git/v5
280
320
func (mw Middleware) GoImport() middlewareFunc {
+15
-13
appview/oauth/handler/handler.go
+15
-13
appview/oauth/handler/handler.go
···
354
354
}
355
355
356
356
var (
357
-
tangledHandle = "tangled.sh"
358
-
tangledDid = "did:plc:wshs7t2adsemcrrd4snkeqli"
359
-
360
-
icyHandle = "icyphox.sh"
361
-
icyDid = "did:plc:hwevmowznbiukdf6uk5dwrrq"
357
+
tangledDid = "did:plc:wshs7t2adsemcrrd4snkeqli"
358
+
icyDid = "did:plc:hwevmowznbiukdf6uk5dwrrq"
362
359
363
360
defaultSpindle = "spindle.tangled.sh"
364
361
defaultKnot = "knot1.tangled.sh"
···
383
380
}
384
381
385
382
log.Printf("adding %s to default spindle", did)
386
-
session, err := o.createAppPasswordSession(o.config.Core.AppPassword, tangledHandle, tangledDid)
383
+
session, err := o.createAppPasswordSession(o.config.Core.AppPassword, tangledDid)
387
384
if err != nil {
388
385
log.Printf("failed to create session: %s", err)
389
386
return
···
396
393
CreatedAt: time.Now().Format(time.RFC3339),
397
394
}
398
395
399
-
if err := session.putRecord(record); err != nil {
396
+
if err := session.putRecord(record, tangled.SpindleMemberNSID); err != nil {
400
397
log.Printf("failed to add member to default spindle: %s", err)
401
398
return
402
399
}
···
420
417
}
421
418
422
419
log.Printf("adding %s to default knot", did)
423
-
session, err := o.createAppPasswordSession(o.config.Core.TmpAltAppPassword, icyHandle, icyDid)
420
+
session, err := o.createAppPasswordSession(o.config.Core.TmpAltAppPassword, icyDid)
424
421
if err != nil {
425
422
log.Printf("failed to create session: %s", err)
426
423
return
···
433
430
CreatedAt: time.Now().Format(time.RFC3339),
434
431
}
435
432
436
-
if err := session.putRecord(record); err != nil {
433
+
if err := session.putRecord(record, tangled.KnotMemberNSID); err != nil {
437
434
log.Printf("failed to add member to default knot: %s", err)
435
+
return
436
+
}
437
+
438
+
if err := o.enforcer.AddKnotMember(defaultKnot, did); err != nil {
439
+
log.Printf("failed to set up enforcer rules: %s", err)
438
440
return
439
441
}
440
442
···
448
450
Did string
449
451
}
450
452
451
-
func (o *OAuthHandler) createAppPasswordSession(appPassword, handle, did string) (*session, error) {
453
+
func (o *OAuthHandler) createAppPasswordSession(appPassword, did string) (*session, error) {
452
454
if appPassword == "" {
453
455
return nil, fmt.Errorf("no app password configured, skipping member addition")
454
456
}
···
464
466
}
465
467
466
468
sessionPayload := map[string]string{
467
-
"identifier": handle,
469
+
"identifier": did,
468
470
"password": appPassword,
469
471
}
470
472
sessionBytes, err := json.Marshal(sessionPayload)
···
501
503
return &session, nil
502
504
}
503
505
504
-
func (s *session) putRecord(record any) error {
506
+
func (s *session) putRecord(record any, collection string) error {
505
507
recordBytes, err := json.Marshal(record)
506
508
if err != nil {
507
509
return fmt.Errorf("failed to marshal knot member record: %w", err)
···
509
511
510
512
payload := map[string]any{
511
513
"repo": s.Did,
512
-
"collection": tangled.KnotMemberNSID,
514
+
"collection": collection,
513
515
"rkey": tid.TID(),
514
516
"record": json.RawMessage(recordBytes),
515
517
}
+35
appview/pages/cache.go
+35
appview/pages/cache.go
···
1
+
package pages
2
+
3
+
import (
4
+
"sync"
5
+
)
6
+
7
+
type TmplCache[K comparable, V any] struct {
8
+
data map[K]V
9
+
mutex sync.RWMutex
10
+
}
11
+
12
+
func NewTmplCache[K comparable, V any]() *TmplCache[K, V] {
13
+
return &TmplCache[K, V]{
14
+
data: make(map[K]V),
15
+
}
16
+
}
17
+
18
+
func (c *TmplCache[K, V]) Get(key K) (V, bool) {
19
+
c.mutex.RLock()
20
+
defer c.mutex.RUnlock()
21
+
val, exists := c.data[key]
22
+
return val, exists
23
+
}
24
+
25
+
func (c *TmplCache[K, V]) Set(key K, value V) {
26
+
c.mutex.Lock()
27
+
defer c.mutex.Unlock()
28
+
c.data[key] = value
29
+
}
30
+
31
+
func (c *TmplCache[K, V]) Size() int {
32
+
c.mutex.RLock()
33
+
defer c.mutex.RUnlock()
34
+
return len(c.data)
35
+
}
+3
appview/pages/funcmap.go
+3
appview/pages/funcmap.go
···
29
29
"split": func(s string) []string {
30
30
return strings.Split(s, "\n")
31
31
},
32
+
"contains": func(s string, target string) bool {
33
+
return strings.Contains(s, target)
34
+
},
32
35
"resolve": func(s string) string {
33
36
identity, err := p.resolver.ResolveIdent(context.Background(), s)
34
37
+12
appview/pages/markup/format.go
+12
appview/pages/markup/format.go
···
13
13
FormatMarkdown: []string{".md", ".markdown", ".mdown", ".mkdn", ".mkd"},
14
14
}
15
15
16
+
// ReadmeFilenames contains the list of common README filenames to search for,
17
+
// in order of preference. Only includes well-supported formats.
18
+
var ReadmeFilenames = []string{
19
+
"README.md", "readme.md",
20
+
"README",
21
+
"readme",
22
+
"README.markdown",
23
+
"readme.markdown",
24
+
"README.txt",
25
+
"readme.txt",
26
+
}
27
+
16
28
func GetFormat(filename string) Format {
17
29
for format, extensions := range FileTypes {
18
30
for _, extension := range extensions {
+12
-8
appview/pages/markup/markdown.go
+12
-8
appview/pages/markup/markdown.go
···
11
11
12
12
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
13
13
"github.com/alecthomas/chroma/v2/styles"
14
+
treeblood "github.com/wyatt915/goldmark-treeblood"
14
15
"github.com/yuin/goldmark"
15
16
highlighting "github.com/yuin/goldmark-highlighting/v2"
16
17
"github.com/yuin/goldmark/ast"
···
21
22
"github.com/yuin/goldmark/util"
22
23
htmlparse "golang.org/x/net/html"
23
24
25
+
"tangled.sh/tangled.sh/core/api/tangled"
24
26
"tangled.sh/tangled.sh/core/appview/pages/repoinfo"
25
27
)
26
28
···
59
61
extension.NewFootnote(
60
62
extension.WithFootnoteIDPrefix([]byte("footnote")),
61
63
),
64
+
treeblood.MathML(),
62
65
),
63
66
goldmark.WithParserOptions(
64
67
parser.WithAutoHeadingID(),
···
229
232
230
233
actualPath := rctx.actualPath(dst)
231
234
235
+
repoName := fmt.Sprintf("%s/%s", rctx.RepoInfo.OwnerDid, rctx.RepoInfo.Name)
236
+
237
+
query := fmt.Sprintf("repo=%s&ref=%s&path=%s&raw=true",
238
+
url.PathEscape(repoName), url.PathEscape(rctx.RepoInfo.Ref), actualPath)
239
+
232
240
parsedURL := &url.URL{
233
-
Scheme: scheme,
234
-
Host: rctx.Knot,
235
-
Path: path.Join("/",
236
-
rctx.RepoInfo.OwnerDid,
237
-
rctx.RepoInfo.Name,
238
-
"raw",
239
-
url.PathEscape(rctx.RepoInfo.Ref),
240
-
actualPath),
241
+
Scheme: scheme,
242
+
Host: rctx.Knot,
243
+
Path: path.Join("/xrpc", tangled.RepoBlobNSID),
244
+
RawQuery: query,
241
245
}
242
246
newPath := parsedURL.String()
243
247
return newPath
+17
appview/pages/markup/sanitizer.go
+17
appview/pages/markup/sanitizer.go
···
97
97
"margin-bottom",
98
98
)
99
99
100
+
// math
101
+
mathAttrs := []string{
102
+
"accent", "columnalign", "columnlines", "columnspan", "dir", "display",
103
+
"displaystyle", "encoding", "fence", "form", "largeop", "linebreak",
104
+
"linethickness", "lspace", "mathcolor", "mathsize", "mathvariant", "minsize",
105
+
"movablelimits", "notation", "rowalign", "rspace", "rowspacing", "rowspan",
106
+
"scriptlevel", "stretchy", "symmetric", "title", "voffset", "width",
107
+
}
108
+
mathElements := []string{
109
+
"annotation", "math", "menclose", "merror", "mfrac", "mi", "mmultiscripts",
110
+
"mn", "mo", "mover", "mpadded", "mprescripts", "mroot", "mrow", "mspace",
111
+
"msqrt", "mstyle", "msub", "msubsup", "msup", "mtable", "mtd", "mtext",
112
+
"mtr", "munder", "munderover", "semantics",
113
+
}
114
+
policy.AllowNoAttrs().OnElements(mathElements...)
115
+
policy.AllowAttrs(mathAttrs...).OnElements(mathElements...)
116
+
100
117
return policy
101
118
}
102
119
+270
-190
appview/pages/pages.go
+270
-190
appview/pages/pages.go
···
9
9
"html/template"
10
10
"io"
11
11
"io/fs"
12
-
"log"
12
+
"log/slog"
13
13
"net/http"
14
14
"os"
15
15
"path/filepath"
···
42
42
var Files embed.FS
43
43
44
44
type Pages struct {
45
-
mu sync.RWMutex
46
-
t map[string]*template.Template
45
+
mu sync.RWMutex
46
+
cache *TmplCache[string, *template.Template]
47
47
48
48
avatar config.AvatarConfig
49
49
resolver *idresolver.Resolver
50
50
dev bool
51
-
embedFS embed.FS
51
+
embedFS fs.FS
52
52
templateDir string // Path to templates on disk for dev mode
53
53
rctx *markup.RenderContext
54
+
logger *slog.Logger
54
55
}
55
56
56
57
func NewPages(config *config.Config, res *idresolver.Resolver) *Pages {
···
64
65
65
66
p := &Pages{
66
67
mu: sync.RWMutex{},
67
-
t: make(map[string]*template.Template),
68
+
cache: NewTmplCache[string, *template.Template](),
68
69
dev: config.Core.Dev,
69
70
avatar: config.Avatar,
70
-
embedFS: Files,
71
71
rctx: rctx,
72
72
resolver: res,
73
73
templateDir: "appview/pages",
74
+
logger: slog.Default().With("component", "pages"),
74
75
}
75
76
76
-
// Initial load of all templates
77
-
p.loadAllTemplates()
77
+
if p.dev {
78
+
p.embedFS = os.DirFS(p.templateDir)
79
+
} else {
80
+
p.embedFS = Files
81
+
}
78
82
79
83
return p
80
84
}
81
85
82
-
func (p *Pages) loadAllTemplates() {
83
-
templates := make(map[string]*template.Template)
84
-
var fragmentPaths []string
86
+
func (p *Pages) pathToName(s string) string {
87
+
return strings.TrimSuffix(strings.TrimPrefix(s, "templates/"), ".html")
88
+
}
85
89
86
-
// Use embedded FS for initial loading
87
-
// First, collect all fragment paths
90
+
// reverse of pathToName
91
+
func (p *Pages) nameToPath(s string) string {
92
+
return "templates/" + s + ".html"
93
+
}
94
+
95
+
func (p *Pages) fragmentPaths() ([]string, error) {
96
+
var fragmentPaths []string
88
97
err := fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error {
89
98
if err != nil {
90
99
return err
···
98
107
if !strings.Contains(path, "fragments/") {
99
108
return nil
100
109
}
101
-
name := strings.TrimPrefix(path, "templates/")
102
-
name = strings.TrimSuffix(name, ".html")
103
-
tmpl, err := template.New(name).
104
-
Funcs(p.funcMap()).
105
-
ParseFS(p.embedFS, path)
106
-
if err != nil {
107
-
log.Fatalf("setting up fragment: %v", err)
108
-
}
109
-
templates[name] = tmpl
110
110
fragmentPaths = append(fragmentPaths, path)
111
-
log.Printf("loaded fragment: %s", name)
112
111
return nil
113
112
})
114
113
if err != nil {
115
-
log.Fatalf("walking template dir for fragments: %v", err)
114
+
return nil, err
116
115
}
117
116
118
-
// Then walk through and setup the rest of the templates
119
-
err = fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error {
120
-
if err != nil {
121
-
return err
122
-
}
123
-
if d.IsDir() {
124
-
return nil
125
-
}
126
-
if !strings.HasSuffix(path, "html") {
127
-
return nil
128
-
}
129
-
// Skip fragments as they've already been loaded
130
-
if strings.Contains(path, "fragments/") {
131
-
return nil
132
-
}
133
-
// Skip layouts
134
-
if strings.Contains(path, "layouts/") {
135
-
return nil
136
-
}
137
-
name := strings.TrimPrefix(path, "templates/")
138
-
name = strings.TrimSuffix(name, ".html")
139
-
// Add the page template on top of the base
140
-
allPaths := []string{}
141
-
allPaths = append(allPaths, "templates/layouts/*.html")
142
-
allPaths = append(allPaths, fragmentPaths...)
143
-
allPaths = append(allPaths, path)
144
-
tmpl, err := template.New(name).
145
-
Funcs(p.funcMap()).
146
-
ParseFS(p.embedFS, allPaths...)
147
-
if err != nil {
148
-
return fmt.Errorf("setting up template: %w", err)
149
-
}
150
-
templates[name] = tmpl
151
-
log.Printf("loaded template: %s", name)
152
-
return nil
153
-
})
117
+
return fragmentPaths, nil
118
+
}
119
+
120
+
// parse without memoization
121
+
func (p *Pages) rawParse(stack ...string) (*template.Template, error) {
122
+
paths, err := p.fragmentPaths()
154
123
if err != nil {
155
-
log.Fatalf("walking template dir: %v", err)
124
+
return nil, err
156
125
}
157
-
158
-
log.Printf("total templates loaded: %d", len(templates))
159
-
p.mu.Lock()
160
-
defer p.mu.Unlock()
161
-
p.t = templates
162
-
}
163
-
164
-
// loadTemplateFromDisk loads a template from the filesystem in dev mode
165
-
func (p *Pages) loadTemplateFromDisk(name string) error {
166
-
if !p.dev {
167
-
return nil
126
+
for _, s := range stack {
127
+
paths = append(paths, p.nameToPath(s))
168
128
}
169
129
170
-
log.Printf("reloading template from disk: %s", name)
171
-
172
-
// Find all fragments first
173
-
var fragmentPaths []string
174
-
err := filepath.WalkDir(filepath.Join(p.templateDir, "templates"), func(path string, d fs.DirEntry, err error) error {
175
-
if err != nil {
176
-
return err
177
-
}
178
-
if d.IsDir() {
179
-
return nil
180
-
}
181
-
if !strings.HasSuffix(path, ".html") {
182
-
return nil
183
-
}
184
-
if !strings.Contains(path, "fragments/") {
185
-
return nil
186
-
}
187
-
fragmentPaths = append(fragmentPaths, path)
188
-
return nil
189
-
})
130
+
funcs := p.funcMap()
131
+
top := stack[len(stack)-1]
132
+
parsed, err := template.New(top).
133
+
Funcs(funcs).
134
+
ParseFS(p.embedFS, paths...)
190
135
if err != nil {
191
-
return fmt.Errorf("walking disk template dir for fragments: %w", err)
136
+
return nil, err
192
137
}
193
138
194
-
// Find the template path on disk
195
-
templatePath := filepath.Join(p.templateDir, "templates", name+".html")
196
-
if _, err := os.Stat(templatePath); os.IsNotExist(err) {
197
-
return fmt.Errorf("template not found on disk: %s", name)
198
-
}
139
+
return parsed, nil
140
+
}
199
141
200
-
// Create a new template
201
-
tmpl := template.New(name).Funcs(p.funcMap())
142
+
func (p *Pages) parse(stack ...string) (*template.Template, error) {
143
+
key := strings.Join(stack, "|")
202
144
203
-
// Parse layouts
204
-
layoutGlob := filepath.Join(p.templateDir, "templates", "layouts", "*.html")
205
-
layouts, err := filepath.Glob(layoutGlob)
145
+
// never cache in dev mode
146
+
if cached, exists := p.cache.Get(key); !p.dev && exists {
147
+
return cached, nil
148
+
}
149
+
150
+
result, err := p.rawParse(stack...)
206
151
if err != nil {
207
-
return fmt.Errorf("finding layout templates: %w", err)
152
+
return nil, err
208
153
}
209
154
210
-
// Create paths for parsing
211
-
allFiles := append(layouts, fragmentPaths...)
212
-
allFiles = append(allFiles, templatePath)
155
+
p.cache.Set(key, result)
156
+
return result, nil
157
+
}
213
158
214
-
// Parse all templates
215
-
tmpl, err = tmpl.ParseFiles(allFiles...)
216
-
if err != nil {
217
-
return fmt.Errorf("parsing template files: %w", err)
159
+
func (p *Pages) parseBase(top string) (*template.Template, error) {
160
+
stack := []string{
161
+
"layouts/base",
162
+
top,
218
163
}
164
+
return p.parse(stack...)
165
+
}
219
166
220
-
// Update the template in the map
221
-
p.mu.Lock()
222
-
defer p.mu.Unlock()
223
-
p.t[name] = tmpl
224
-
log.Printf("template reloaded from disk: %s", name)
225
-
return nil
167
+
func (p *Pages) parseRepoBase(top string) (*template.Template, error) {
168
+
stack := []string{
169
+
"layouts/base",
170
+
"layouts/repobase",
171
+
top,
172
+
}
173
+
return p.parse(stack...)
226
174
}
227
175
228
-
func (p *Pages) executeOrReload(templateName string, w io.Writer, base string, params any) error {
229
-
// In dev mode, reload the template from disk before executing
230
-
if p.dev {
231
-
if err := p.loadTemplateFromDisk(templateName); err != nil {
232
-
log.Printf("warning: failed to reload template %s from disk: %v", templateName, err)
233
-
// Continue with the existing template
234
-
}
176
+
func (p *Pages) parseProfileBase(top string) (*template.Template, error) {
177
+
stack := []string{
178
+
"layouts/base",
179
+
"layouts/profilebase",
180
+
top,
235
181
}
182
+
return p.parse(stack...)
183
+
}
236
184
237
-
p.mu.RLock()
238
-
defer p.mu.RUnlock()
239
-
tmpl, exists := p.t[templateName]
240
-
if !exists {
241
-
return fmt.Errorf("template not found: %s", templateName)
185
+
func (p *Pages) executePlain(name string, w io.Writer, params any) error {
186
+
tpl, err := p.parse(name)
187
+
if err != nil {
188
+
return err
242
189
}
243
190
244
-
if base == "" {
245
-
return tmpl.Execute(w, params)
246
-
} else {
247
-
return tmpl.ExecuteTemplate(w, base, params)
248
-
}
191
+
return tpl.Execute(w, params)
249
192
}
250
193
251
194
func (p *Pages) execute(name string, w io.Writer, params any) error {
252
-
return p.executeOrReload(name, w, "layouts/base", params)
253
-
}
195
+
tpl, err := p.parseBase(name)
196
+
if err != nil {
197
+
return err
198
+
}
254
199
255
-
func (p *Pages) executePlain(name string, w io.Writer, params any) error {
256
-
return p.executeOrReload(name, w, "", params)
200
+
return tpl.ExecuteTemplate(w, "layouts/base", params)
257
201
}
258
202
259
203
func (p *Pages) executeRepo(name string, w io.Writer, params any) error {
260
-
return p.executeOrReload(name, w, "layouts/repobase", params)
204
+
tpl, err := p.parseRepoBase(name)
205
+
if err != nil {
206
+
return err
207
+
}
208
+
209
+
return tpl.ExecuteTemplate(w, "layouts/base", params)
210
+
}
211
+
212
+
func (p *Pages) executeProfile(name string, w io.Writer, params any) error {
213
+
tpl, err := p.parseProfileBase(name)
214
+
if err != nil {
215
+
return err
216
+
}
217
+
218
+
return tpl.ExecuteTemplate(w, "layouts/base", params)
261
219
}
262
220
263
221
func (p *Pages) Favicon(w io.Writer) error {
···
282
240
283
241
type TermsOfServiceParams struct {
284
242
LoggedInUser *oauth.User
243
+
Content template.HTML
285
244
}
286
245
287
246
func (p *Pages) TermsOfService(w io.Writer, params TermsOfServiceParams) error {
247
+
filename := "terms.md"
248
+
filePath := filepath.Join("legal", filename)
249
+
markdownBytes, err := os.ReadFile(filePath)
250
+
if err != nil {
251
+
return fmt.Errorf("failed to read %s: %w", filename, err)
252
+
}
253
+
254
+
p.rctx.RendererType = markup.RendererTypeDefault
255
+
htmlString := p.rctx.RenderMarkdown(string(markdownBytes))
256
+
sanitized := p.rctx.SanitizeDefault(htmlString)
257
+
params.Content = template.HTML(sanitized)
258
+
288
259
return p.execute("legal/terms", w, params)
289
260
}
290
261
291
262
type PrivacyPolicyParams struct {
292
263
LoggedInUser *oauth.User
264
+
Content template.HTML
293
265
}
294
266
295
267
func (p *Pages) PrivacyPolicy(w io.Writer, params PrivacyPolicyParams) error {
268
+
filename := "privacy.md"
269
+
filePath := filepath.Join("legal", filename)
270
+
markdownBytes, err := os.ReadFile(filePath)
271
+
if err != nil {
272
+
return fmt.Errorf("failed to read %s: %w", filename, err)
273
+
}
274
+
275
+
p.rctx.RendererType = markup.RendererTypeDefault
276
+
htmlString := p.rctx.RenderMarkdown(string(markdownBytes))
277
+
sanitized := p.rctx.SanitizeDefault(htmlString)
278
+
params.Content = template.HTML(sanitized)
279
+
296
280
return p.execute("legal/privacy", w, params)
297
281
}
298
282
···
338
322
return p.execute("user/settings/emails", w, params)
339
323
}
340
324
341
-
type KnotBannerParams struct {
325
+
type UpgradeBannerParams struct {
342
326
Registrations []db.Registration
327
+
Spindles []db.Spindle
343
328
}
344
329
345
-
func (p *Pages) KnotBanner(w io.Writer, params KnotBannerParams) error {
346
-
return p.executePlain("knots/fragments/banner", w, params)
330
+
func (p *Pages) UpgradeBanner(w io.Writer, params UpgradeBannerParams) error {
331
+
return p.executePlain("banner", w, params)
347
332
}
348
333
349
334
type KnotsParams struct {
···
422
407
return p.execute("repo/fork", w, params)
423
408
}
424
409
425
-
type ProfileHomePageParams struct {
410
+
type ProfileCard struct {
411
+
UserDid string
412
+
UserHandle string
413
+
FollowStatus db.FollowStatus
414
+
Punchcard *db.Punchcard
415
+
Profile *db.Profile
416
+
Stats ProfileStats
417
+
Active string
418
+
}
419
+
420
+
type ProfileStats struct {
421
+
RepoCount int64
422
+
StarredCount int64
423
+
StringCount int64
424
+
FollowersCount int64
425
+
FollowingCount int64
426
+
}
427
+
428
+
func (p *ProfileCard) GetTabs() [][]any {
429
+
tabs := [][]any{
430
+
{"overview", "overview", "square-chart-gantt", nil},
431
+
{"repos", "repos", "book-marked", p.Stats.RepoCount},
432
+
{"starred", "starred", "star", p.Stats.StarredCount},
433
+
{"strings", "strings", "line-squiggle", p.Stats.StringCount},
434
+
}
435
+
436
+
return tabs
437
+
}
438
+
439
+
type ProfileOverviewParams struct {
426
440
LoggedInUser *oauth.User
427
441
Repos []db.Repo
428
442
CollaboratingRepos []db.Repo
429
443
ProfileTimeline *db.ProfileTimeline
430
-
Card ProfileCard
431
-
Punchcard db.Punchcard
444
+
Card *ProfileCard
445
+
Active string
432
446
}
433
447
434
-
type ProfileCard struct {
435
-
UserDid string
436
-
UserHandle string
437
-
FollowStatus db.FollowStatus
438
-
FollowersCount int
439
-
FollowingCount int
448
+
func (p *Pages) ProfileOverview(w io.Writer, params ProfileOverviewParams) error {
449
+
params.Active = "overview"
450
+
return p.executeProfile("user/overview", w, params)
451
+
}
440
452
441
-
Profile *db.Profile
453
+
type ProfileReposParams struct {
454
+
LoggedInUser *oauth.User
455
+
Repos []db.Repo
456
+
Card *ProfileCard
457
+
Active string
442
458
}
443
459
444
-
func (p *Pages) ProfileHomePage(w io.Writer, params ProfileHomePageParams) error {
445
-
return p.execute("user/profile", w, params)
460
+
func (p *Pages) ProfileRepos(w io.Writer, params ProfileReposParams) error {
461
+
params.Active = "repos"
462
+
return p.executeProfile("user/repos", w, params)
446
463
}
447
464
448
-
type ReposPageParams struct {
465
+
type ProfileStarredParams struct {
449
466
LoggedInUser *oauth.User
450
467
Repos []db.Repo
451
-
Card ProfileCard
468
+
Card *ProfileCard
469
+
Active string
452
470
}
453
471
454
-
func (p *Pages) ReposPage(w io.Writer, params ReposPageParams) error {
455
-
return p.execute("user/repos", w, params)
472
+
func (p *Pages) ProfileStarred(w io.Writer, params ProfileStarredParams) error {
473
+
params.Active = "starred"
474
+
return p.executeProfile("user/starred", w, params)
475
+
}
476
+
477
+
type ProfileStringsParams struct {
478
+
LoggedInUser *oauth.User
479
+
Strings []db.String
480
+
Card *ProfileCard
481
+
Active string
482
+
}
483
+
484
+
func (p *Pages) ProfileStrings(w io.Writer, params ProfileStringsParams) error {
485
+
params.Active = "strings"
486
+
return p.executeProfile("user/strings", w, params)
456
487
}
457
488
458
489
type FollowCard struct {
459
490
UserDid string
460
491
FollowStatus db.FollowStatus
461
-
FollowersCount int
462
-
FollowingCount int
492
+
FollowersCount int64
493
+
FollowingCount int64
463
494
Profile *db.Profile
464
495
}
465
496
466
-
type FollowersPageParams struct {
497
+
type ProfileFollowersParams struct {
467
498
LoggedInUser *oauth.User
468
499
Followers []FollowCard
469
-
Card ProfileCard
500
+
Card *ProfileCard
501
+
Active string
470
502
}
471
503
472
-
func (p *Pages) FollowersPage(w io.Writer, params FollowersPageParams) error {
473
-
return p.execute("user/followers", w, params)
504
+
func (p *Pages) ProfileFollowers(w io.Writer, params ProfileFollowersParams) error {
505
+
params.Active = "overview"
506
+
return p.executeProfile("user/followers", w, params)
474
507
}
475
508
476
-
type FollowingPageParams struct {
509
+
type ProfileFollowingParams struct {
477
510
LoggedInUser *oauth.User
478
511
Following []FollowCard
479
-
Card ProfileCard
512
+
Card *ProfileCard
513
+
Active string
480
514
}
481
515
482
-
func (p *Pages) FollowingPage(w io.Writer, params FollowingPageParams) error {
483
-
return p.execute("user/following", w, params)
516
+
func (p *Pages) ProfileFollowing(w io.Writer, params ProfileFollowingParams) error {
517
+
params.Active = "overview"
518
+
return p.executeProfile("user/following", w, params)
484
519
}
485
520
486
521
type FollowFragmentParams struct {
···
553
588
VerifiedCommits commitverify.VerifiedCommits
554
589
Languages []types.RepoLanguageDetails
555
590
Pipelines map[string]db.Pipeline
591
+
NeedsKnotUpgrade bool
556
592
types.RepoIndexResponse
557
593
}
558
594
···
562
598
return p.executeRepo("repo/empty", w, params)
563
599
}
564
600
601
+
if params.NeedsKnotUpgrade {
602
+
return p.executeRepo("repo/needsUpgrade", w, params)
603
+
}
604
+
565
605
p.rctx.RepoInfo = params.RepoInfo
566
606
p.rctx.RepoInfo.Ref = params.Ref
567
607
p.rctx.RendererType = markup.RendererTypeRepoMarkdown
···
649
689
650
690
func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error {
651
691
params.Active = "overview"
652
-
return p.execute("repo/tree", w, params)
692
+
return p.executeRepo("repo/tree", w, params)
653
693
}
654
694
655
695
type RepoBranchesParams struct {
···
700
740
ShowRendered bool
701
741
RenderToggle bool
702
742
RenderedContents template.HTML
703
-
types.RepoBlobResponse
743
+
*tangled.RepoBlob_Output
744
+
// Computed fields for template compatibility
745
+
Contents string
746
+
Lines int
747
+
SizeHint uint64
748
+
IsBinary bool
704
749
}
705
750
706
751
func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error {
···
835
880
RepoInfo repoinfo.RepoInfo
836
881
Active string
837
882
Issue *db.Issue
838
-
Comments []db.Comment
883
+
CommentList []db.CommentListItem
839
884
IssueOwnerHandle string
840
885
841
886
OrderedReactionKinds []db.ReactionKind
842
887
Reactions map[db.ReactionKind]int
843
888
UserReacted map[db.ReactionKind]bool
889
+
}
844
890
845
-
State string
891
+
func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error {
892
+
params.Active = "issues"
893
+
return p.executeRepo("repo/issues/issue", w, params)
894
+
}
895
+
896
+
type EditIssueParams struct {
897
+
LoggedInUser *oauth.User
898
+
RepoInfo repoinfo.RepoInfo
899
+
Issue *db.Issue
900
+
Action string
901
+
}
902
+
903
+
func (p *Pages) EditIssueFragment(w io.Writer, params EditIssueParams) error {
904
+
params.Action = "edit"
905
+
return p.executePlain("repo/issues/fragments/putIssue", w, params)
846
906
}
847
907
848
908
type ThreadReactionFragmentParams struct {
···
856
916
return p.executePlain("repo/fragments/reaction", w, params)
857
917
}
858
918
859
-
func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error {
860
-
params.Active = "issues"
861
-
if params.Issue.Open {
862
-
params.State = "open"
863
-
} else {
864
-
params.State = "closed"
865
-
}
866
-
return p.execute("repo/issues/issue", w, params)
867
-
}
868
-
869
919
type RepoNewIssueParams struct {
870
920
LoggedInUser *oauth.User
871
921
RepoInfo repoinfo.RepoInfo
922
+
Issue *db.Issue // existing issue if any -- passed when editing
872
923
Active string
924
+
Action string
873
925
}
874
926
875
927
func (p *Pages) RepoNewIssue(w io.Writer, params RepoNewIssueParams) error {
876
928
params.Active = "issues"
929
+
params.Action = "create"
877
930
return p.executeRepo("repo/issues/new", w, params)
878
931
}
879
932
···
881
934
LoggedInUser *oauth.User
882
935
RepoInfo repoinfo.RepoInfo
883
936
Issue *db.Issue
884
-
Comment *db.Comment
937
+
Comment *db.IssueComment
885
938
}
886
939
887
940
func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error {
888
941
return p.executePlain("repo/issues/fragments/editIssueComment", w, params)
889
942
}
890
943
891
-
type SingleIssueCommentParams struct {
944
+
type ReplyIssueCommentPlaceholderParams struct {
892
945
LoggedInUser *oauth.User
893
946
RepoInfo repoinfo.RepoInfo
894
947
Issue *db.Issue
895
-
Comment *db.Comment
948
+
Comment *db.IssueComment
896
949
}
897
950
898
-
func (p *Pages) SingleIssueCommentFragment(w io.Writer, params SingleIssueCommentParams) error {
899
-
return p.executePlain("repo/issues/fragments/issueComment", w, params)
951
+
func (p *Pages) ReplyIssueCommentPlaceholderFragment(w io.Writer, params ReplyIssueCommentPlaceholderParams) error {
952
+
return p.executePlain("repo/issues/fragments/replyIssueCommentPlaceholder", w, params)
953
+
}
954
+
955
+
type ReplyIssueCommentParams struct {
956
+
LoggedInUser *oauth.User
957
+
RepoInfo repoinfo.RepoInfo
958
+
Issue *db.Issue
959
+
Comment *db.IssueComment
960
+
}
961
+
962
+
func (p *Pages) ReplyIssueCommentFragment(w io.Writer, params ReplyIssueCommentParams) error {
963
+
return p.executePlain("repo/issues/fragments/replyComment", w, params)
964
+
}
965
+
966
+
type IssueCommentBodyParams struct {
967
+
LoggedInUser *oauth.User
968
+
RepoInfo repoinfo.RepoInfo
969
+
Issue *db.Issue
970
+
Comment *db.IssueComment
971
+
}
972
+
973
+
func (p *Pages) IssueCommentBodyFragment(w io.Writer, params IssueCommentBodyParams) error {
974
+
return p.executePlain("repo/issues/fragments/issueCommentBody", w, params)
900
975
}
901
976
902
977
type RepoNewPullParams struct {
···
1262
1337
return p.execute("strings/string", w, params)
1263
1338
}
1264
1339
1340
+
func (p *Pages) Home(w io.Writer, params TimelineParams) error {
1341
+
return p.execute("timeline/home", w, params)
1342
+
}
1343
+
1265
1344
func (p *Pages) Static() http.Handler {
1266
1345
if p.dev {
1267
1346
return http.StripPrefix("/static/", http.FileServer(http.Dir("appview/pages/static")))
···
1269
1348
1270
1349
sub, err := fs.Sub(Files, "static")
1271
1350
if err != nil {
1272
-
log.Fatalf("no static dir found? that's crazy: %v", err)
1351
+
p.logger.Error("no static dir found? that's crazy", "err", err)
1352
+
panic(err)
1273
1353
}
1274
1354
// Custom handler to apply Cache-Control headers for font files
1275
1355
return Cache(http.StripPrefix("/static/", http.FileServer(http.FS(sub))))
···
1292
1372
func CssContentHash() string {
1293
1373
cssFile, err := Files.Open("static/tw.css")
1294
1374
if err != nil {
1295
-
log.Printf("Error opening CSS file: %v", err)
1375
+
slog.Debug("Error opening CSS file", "err", err)
1296
1376
return ""
1297
1377
}
1298
1378
defer cssFile.Close()
1299
1379
1300
1380
hasher := sha256.New()
1301
1381
if _, err := io.Copy(hasher, cssFile); err != nil {
1302
-
log.Printf("Error hashing CSS file: %v", err)
1382
+
slog.Debug("Error hashing CSS file", "err", err)
1303
1383
return ""
1304
1384
}
1305
1385
+2
-7
appview/pages/repoinfo/repoinfo.go
+2
-7
appview/pages/repoinfo/repoinfo.go
···
78
78
func (r RepoInfo) TabMetadata() map[string]any {
79
79
meta := make(map[string]any)
80
80
81
-
if r.Stats.PullCount.Open > 0 {
82
-
meta["pulls"] = r.Stats.PullCount.Open
83
-
}
84
-
85
-
if r.Stats.IssueCount.Open > 0 {
86
-
meta["issues"] = r.Stats.IssueCount.Open
87
-
}
81
+
meta["pulls"] = r.Stats.PullCount.Open
82
+
meta["issues"] = r.Stats.IssueCount.Open
88
83
89
84
// more stuff?
90
85
+1
-1
appview/pages/templates/errors/404.html
+1
-1
appview/pages/templates/errors/404.html
···
17
17
The page you're looking for doesn't exist. It may have been moved, deleted, or you have the wrong URL.
18
18
</p>
19
19
<div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6">
20
-
<a href="javascript:history.back()" class="btn px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline text-gray-600 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700">
20
+
<a href="javascript:history.back()" class="btn no-underline hover:no-underline gap-2">
21
21
{{ i "arrow-left" "w-4 h-4" }}
22
22
go back
23
23
</a>
+4
-4
appview/pages/templates/errors/500.html
+4
-4
appview/pages/templates/errors/500.html
···
8
8
{{ i "alert-triangle" "w-8 h-8 text-red-500 dark:text-red-400" }}
9
9
</div>
10
10
</div>
11
-
11
+
12
12
<div class="space-y-4">
13
13
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white">
14
14
500 — internal server error
···
24
24
<p class="mt-1">Our team has been automatically notified about this error.</p>
25
25
</div>
26
26
<div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6">
27
-
<button onclick="location.reload()" class="btn-create px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline hover:text-white">
27
+
<button onclick="location.reload()" class="btn-create gap-2">
28
28
{{ i "refresh-cw" "w-4 h-4" }}
29
29
try again
30
30
</button>
31
-
<a href="/" class="btn px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline text-gray-600 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700">
31
+
<a href="/" class="btn no-underline hover:no-underline gap-2">
32
32
{{ i "home" "w-4 h-4" }}
33
33
back to home
34
34
</a>
···
36
36
</div>
37
37
</div>
38
38
</div>
39
-
{{ end }}
39
+
{{ end }}
+2
-2
appview/pages/templates/errors/503.html
+2
-2
appview/pages/templates/errors/503.html
···
17
17
We were unable to reach the knot hosting this repository. The service may be temporarily unavailable.
18
18
</p>
19
19
<div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6">
20
-
<button onclick="location.reload()" class="btn-create px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline hover:text-white">
20
+
<button onclick="location.reload()" class="btn-create gap-2">
21
21
{{ i "refresh-cw" "w-4 h-4" }}
22
22
try again
23
23
</button>
24
-
<a href="/" class="btn px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline text-gray-600 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700">
24
+
<a href="/" class="btn gap-2 no-underline hover:no-underline">
25
25
{{ i "arrow-left" "w-4 h-4" }}
26
26
back to timeline
27
27
</a>
+1
-1
appview/pages/templates/errors/knot404.html
+1
-1
appview/pages/templates/errors/knot404.html
···
17
17
The repository you were looking for could not be found. The knot serving the repository may be unavailable.
18
18
</p>
19
19
<div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6">
20
-
<a href="/" class="btn px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline">
20
+
<a href="/" class="btn flex items-center gap-2 no-underline hover:no-underline">
21
21
{{ i "arrow-left" "w-4 h-4" }}
22
22
back to timeline
23
23
</a>
+8
appview/pages/templates/fragments/logotype.html
+8
appview/pages/templates/fragments/logotype.html
+2
-2
appview/pages/templates/knots/fragments/knotListing.html
+2
-2
appview/pages/templates/knots/fragments/knotListing.html
···
36
36
</span>
37
37
{{ template "knots/fragments/addMemberModal" . }}
38
38
{{ block "knotDeleteButton" . }} {{ end }}
39
-
{{ else if .IsReadOnly }}
39
+
{{ else if .IsNeedsUpgrade }}
40
40
<span class="bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 {{$style}}">
41
-
{{ i "shield-alert" "w-4 h-4" }} read-only
41
+
{{ i "shield-alert" "w-4 h-4" }} needs upgrade
42
42
</span>
43
43
{{ block "knotRetryButton" . }} {{ end }}
44
44
{{ block "knotDeleteButton" . }} {{ end }}
+12
-10
appview/pages/templates/knots/index.html
+12
-10
appview/pages/templates/knots/index.html
···
1
1
{{ define "title" }}knots{{ end }}
2
2
3
3
{{ define "content" }}
4
-
<div class="px-6 py-4">
4
+
<div class="px-6 py-4 flex items-center justify-between gap-4 align-bottom">
5
5
<h1 class="text-xl font-bold dark:text-white">Knots</h1>
6
+
<span class="flex items-center gap-1">
7
+
{{ i "book" "w-3 h-3" }}
8
+
<a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/knot-hosting.md">docs</a>
9
+
</span>
6
10
</div>
7
11
8
12
<section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
···
15
19
{{ end }}
16
20
17
21
{{ define "about" }}
18
-
<section class="rounded flex flex-col gap-2">
19
-
<p class="dark:text-gray-300">
20
-
Knots are lightweight headless servers that enable users to host Git repositories with ease.
21
-
Knots are designed for either single or multi-tenant use which is perfect for self-hosting on a Raspberry Pi at home, or larger โcommunityโ servers.
22
-
When creating a repository, you can choose a knot to store it on.
23
-
<a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/knot-hosting.md">
24
-
Checkout the documentation if you're interested in self-hosting.
25
-
</a>
22
+
<section class="rounded">
23
+
<p class="text-gray-500 dark:text-gray-400">
24
+
Knots are lightweight headless servers that enable users to host Git repositories with ease.
25
+
When creating a repository, you can choose a knot to store it on.
26
26
</p>
27
-
</section>
27
+
28
+
29
+
</section>
28
30
{{ end }}
29
31
30
32
{{ define "list" }}
+27
-12
appview/pages/templates/layouts/base.html
+27
-12
appview/pages/templates/layouts/base.html
···
3
3
<html lang="en" class="dark:bg-gray-900">
4
4
<head>
5
5
<meta charset="UTF-8" />
6
-
<meta
7
-
name="viewport"
8
-
content="width=device-width, initial-scale=1.0"
9
-
/>
6
+
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
7
+
<meta name="description" content="Social coding, but for real this time!"/>
10
8
<meta name="htmx-config" content='{"includeIndicatorStyles": false}'>
11
-
<script src="/static/htmx.min.js"></script>
12
-
<script src="/static/htmx-ext-ws.min.js"></script>
9
+
10
+
<script defer src="/static/htmx.min.js"></script>
11
+
<script defer src="/static/htmx-ext-ws.min.js"></script>
12
+
13
+
<!-- preconnect to image cdn -->
14
+
<link rel="preconnect" href="https://avatar.tangled.sh" />
15
+
<link rel="preconnect" href="https://camo.tangled.sh" />
16
+
17
+
<!-- preload main font -->
18
+
<link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin />
19
+
13
20
<link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" />
14
21
<title>{{ block "title" . }}{{ end }} ยท tangled</title>
15
22
{{ block "extrameta" . }}{{ end }}
16
23
</head>
17
-
<body class="min-h-screen grid grid-cols-1 grid-rows-[min-content_auto_min-content] md:grid-cols-12 gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200">
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">
18
25
{{ block "topbarLayout" . }}
19
-
<header class="px-1 col-span-1 md:col-start-3 md:col-span-8" style="z-index: 20;">
20
-
{{ template "layouts/topbar" . }}
26
+
<header class="px-1 col-span-1 md:col-start-2 md:col-span-8 lg:col-start-3" style="z-index: 20;">
27
+
28
+
{{ if .LoggedInUser }}
29
+
<div id="upgrade-banner"
30
+
hx-get="/upgradeBanner"
31
+
hx-trigger="load"
32
+
hx-swap="innerHTML">
33
+
</div>
34
+
{{ end }}
35
+
{{ template "layouts/fragments/topbar" . }}
21
36
</header>
22
37
{{ end }}
23
38
24
39
{{ block "mainLayout" . }}
25
-
<div class="px-1 col-span-1 md:col-start-3 md:col-span-8 flex flex-col gap-4">
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">
26
41
{{ block "contentLayout" . }}
27
42
<main class="col-span-1 md:col-span-8">
28
43
{{ block "content" . }}{{ end }}
···
38
53
{{ end }}
39
54
40
55
{{ block "footerLayout" . }}
41
-
<footer class="px-1 col-span-1 md:col-start-3 md:col-span-8 mt-12">
42
-
{{ template "layouts/footer" . }}
56
+
<footer class="px-1 col-span-1 md:col-start-2 md:col-span-8 lg:col-start-3 mt-12">
57
+
{{ template "layouts/fragments/footer" . }}
43
58
</footer>
44
59
{{ end }}
45
60
</body>
+78
appview/pages/templates/layouts/fragments/topbar.html
+78
appview/pages/templates/layouts/fragments/topbar.html
···
1
+
{{ define "layouts/fragments/topbar" }}
2
+
<nav class="space-x-4 px-6 py-2 rounded bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm">
3
+
<div class="flex justify-between p-0 items-center">
4
+
<div id="left-items">
5
+
<a href="/" hx-boost="true" class="text-lg">{{ template "fragments/logotype" }}</a>
6
+
</div>
7
+
8
+
<div id="right-items" class="flex items-center gap-2">
9
+
{{ with .LoggedInUser }}
10
+
{{ block "newButton" . }} {{ end }}
11
+
{{ block "dropDown" . }} {{ end }}
12
+
{{ else }}
13
+
<a href="/login">login</a>
14
+
<span class="text-gray-500 dark:text-gray-400">or</span>
15
+
<a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2">
16
+
join now {{ i "arrow-right" "size-4" }}
17
+
</a>
18
+
{{ end }}
19
+
</div>
20
+
</div>
21
+
</nav>
22
+
{{ end }}
23
+
24
+
{{ define "newButton" }}
25
+
<details class="relative inline-block text-left nav-dropdown">
26
+
<summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2">
27
+
{{ i "plus" "w-4 h-4" }} new
28
+
</summary>
29
+
<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
+
<a href="/repo/new" class="flex items-center gap-2">
31
+
{{ i "book-plus" "w-4 h-4" }}
32
+
new repository
33
+
</a>
34
+
<a href="/strings/new" class="flex items-center gap-2">
35
+
{{ i "line-squiggle" "w-4 h-4" }}
36
+
new string
37
+
</a>
38
+
</div>
39
+
</details>
40
+
{{ end }}
41
+
42
+
{{ define "dropDown" }}
43
+
<details class="relative inline-block text-left nav-dropdown">
44
+
<summary
45
+
class="cursor-pointer list-none flex items-center"
46
+
>
47
+
{{ $user := didOrHandle .Did .Handle }}
48
+
{{ template "user/fragments/picHandle" $user }}
49
+
</summary>
50
+
<div
51
+
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"
52
+
>
53
+
<a href="/{{ $user }}">profile</a>
54
+
<a href="/{{ $user }}?tab=repos">repositories</a>
55
+
<a href="/{{ $user }}?tab=strings">strings</a>
56
+
<a href="/knots">knots</a>
57
+
<a href="/spindles">spindles</a>
58
+
<a href="/settings">settings</a>
59
+
<a href="#"
60
+
hx-post="/logout"
61
+
hx-swap="none"
62
+
class="text-red-400 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300">
63
+
logout
64
+
</a>
65
+
</div>
66
+
</details>
67
+
68
+
<script>
69
+
document.addEventListener('click', function(event) {
70
+
const dropdowns = document.querySelectorAll('.nav-dropdown');
71
+
dropdowns.forEach(function(dropdown) {
72
+
if (!dropdown.contains(event.target)) {
73
+
dropdown.removeAttribute('open');
74
+
}
75
+
});
76
+
});
77
+
</script>
78
+
{{ end }}
+104
appview/pages/templates/layouts/profilebase.html
+104
appview/pages/templates/layouts/profilebase.html
···
1
+
{{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }}{{ end }}
2
+
3
+
{{ define "extrameta" }}
4
+
<meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" />
5
+
<meta property="og:type" content="profile" />
6
+
<meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}?tab={{ .Active }}" />
7
+
<meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" />
8
+
{{ end }}
9
+
10
+
{{ define "content" }}
11
+
{{ template "profileTabs" . }}
12
+
<section class="bg-white dark:bg-gray-800 p-6 rounded w-full dark:text-white drop-shadow-sm">
13
+
<div class="grid grid-cols-1 md:grid-cols-11 gap-4">
14
+
<div class="md:col-span-3 order-1 md:order-1">
15
+
<div class="flex flex-col gap-4">
16
+
{{ template "user/fragments/profileCard" .Card }}
17
+
{{ block "punchcard" .Card.Punchcard }} {{ end }}
18
+
</div>
19
+
</div>
20
+
{{ block "profileContent" . }} {{ end }}
21
+
</div>
22
+
</section>
23
+
{{ end }}
24
+
25
+
{{ define "profileTabs" }}
26
+
<nav class="w-full pl-4 overflow-x-auto overflow-y-hidden">
27
+
<div class="flex z-60">
28
+
{{ $activeTabStyles := "-mb-px bg-white dark:bg-gray-800" }}
29
+
{{ $tabs := .Card.GetTabs }}
30
+
{{ $tabmeta := dict "x" "y" }}
31
+
{{ range $item := $tabs }}
32
+
{{ $key := index $item 0 }}
33
+
{{ $value := index $item 1 }}
34
+
{{ $icon := index $item 2 }}
35
+
{{ $meta := index $item 3 }}
36
+
<a
37
+
href="?tab={{ $value }}"
38
+
class="relative -mr-px group no-underline hover:no-underline"
39
+
hx-boost="true">
40
+
<div
41
+
class="px-4 py-1 mr-1 text-black dark:text-white min-w-[80px] text-center relative rounded-t whitespace-nowrap
42
+
{{ if eq $.Active $key }}
43
+
{{ $activeTabStyles }}
44
+
{{ else }}
45
+
group-hover:bg-gray-100/25 group-hover:dark:bg-gray-700/25
46
+
{{ end }}
47
+
">
48
+
<span class="flex items-center justify-center">
49
+
{{ i $icon "w-4 h-4 mr-2" }}
50
+
{{ $key }}
51
+
{{ if $meta }}
52
+
<span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-sm ml-1">{{ $meta }}</span>
53
+
{{ end }}
54
+
</span>
55
+
</div>
56
+
</a>
57
+
{{ end }}
58
+
</div>
59
+
</nav>
60
+
{{ end }}
61
+
62
+
{{ define "punchcard" }}
63
+
{{ $now := now }}
64
+
<div>
65
+
<p class="px-2 pb-4 flex gap-2 text-sm font-bold dark:text-white">
66
+
PUNCHCARD
67
+
<span class="font-mono font-normal text-sm text-gray-500 dark:text-gray-400 ">
68
+
{{ .Total | int64 | commaFmt }} commits
69
+
</span>
70
+
</p>
71
+
<div class="grid grid-cols-28 md:grid-cols-14 gap-y-3 w-full h-full">
72
+
{{ range .Punches }}
73
+
{{ $count := .Count }}
74
+
{{ $theme := "bg-gray-200 dark:bg-gray-700 size-[4px]" }}
75
+
{{ if lt $count 1 }}
76
+
{{ $theme = "bg-gray-200 dark:bg-gray-700 size-[4px]" }}
77
+
{{ else if lt $count 2 }}
78
+
{{ $theme = "bg-green-200 dark:bg-green-900 size-[5px]" }}
79
+
{{ else if lt $count 4 }}
80
+
{{ $theme = "bg-green-300 dark:bg-green-800 size-[5px]" }}
81
+
{{ else if lt $count 8 }}
82
+
{{ $theme = "bg-green-400 dark:bg-green-700 size-[6px]" }}
83
+
{{ else }}
84
+
{{ $theme = "bg-green-500 dark:bg-green-600 size-[7px]" }}
85
+
{{ end }}
86
+
87
+
{{ if .Date.After $now }}
88
+
{{ $theme = "border border-gray-200 dark:border-gray-700 size-[4px]" }}
89
+
{{ end }}
90
+
<div class="w-full h-full flex justify-center items-center">
91
+
<div
92
+
class="aspect-square rounded-full transition-all duration-300 {{ $theme }} max-w-full max-h-full"
93
+
title="{{ .Date.Format "2006-01-02" }}: {{ .Count }} commits">
94
+
</div>
95
+
</div>
96
+
{{ end }}
97
+
</div>
98
+
</div>
99
+
{{ end }}
100
+
101
+
{{ define "layouts/profilebase" }}
102
+
{{ template "layouts/base" . }}
103
+
{{ end }}
104
+
+4
-8
appview/pages/templates/layouts/repobase.html
+4
-8
appview/pages/templates/layouts/repobase.html
···
42
42
</section>
43
43
44
44
<section
45
-
class="w-full flex flex-col drop-shadow-sm"
45
+
class="w-full flex flex-col"
46
46
>
47
47
<nav class="w-full pl-4 overflow-auto">
48
48
<div class="flex z-60">
···
71
71
<span class="flex items-center justify-center">
72
72
{{ i $icon "w-4 h-4 mr-2" }}
73
73
{{ $key }}
74
-
{{ if not (isNil $meta) }}
75
-
<span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-sm ml-1">{{ $meta }}</span>
74
+
{{ if $meta }}
75
+
<span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-sm ml-1">{{ $meta }}</span>
76
76
{{ end }}
77
77
</span>
78
78
</div>
···
81
81
</div>
82
82
</nav>
83
83
<section
84
-
class="bg-white dark:bg-gray-800 p-6 rounded relative w-full dark:text-white"
84
+
class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white"
85
85
>
86
86
{{ block "repoContent" . }}{{ end }}
87
87
</section>
88
88
{{ block "repoAfter" . }}{{ end }}
89
89
</section>
90
90
{{ end }}
91
-
92
-
{{ define "layouts/repobase" }}
93
-
{{ template "layouts/base" . }}
94
-
{{ end }}
-87
appview/pages/templates/layouts/topbar.html
-87
appview/pages/templates/layouts/topbar.html
···
1
-
{{ define "layouts/topbar" }}
2
-
<nav class="space-x-4 px-6 py-2 rounded bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm">
3
-
<div class="flex justify-between p-0 items-center">
4
-
<div id="left-items">
5
-
<a href="/" hx-boost="true" class="flex gap-2 font-bold italic">
6
-
tangled<sub>alpha</sub>
7
-
</a>
8
-
</div>
9
-
10
-
<div id="right-items" class="flex items-center gap-2">
11
-
{{ with .LoggedInUser }}
12
-
{{ block "newButton" . }} {{ end }}
13
-
{{ block "dropDown" . }} {{ end }}
14
-
{{ else }}
15
-
<a href="/login">login</a>
16
-
<span class="text-gray-500 dark:text-gray-400">or</span>
17
-
<a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2">
18
-
join now {{ i "arrow-right" "size-4" }}
19
-
</a>
20
-
{{ end }}
21
-
</div>
22
-
</div>
23
-
</nav>
24
-
{{ if .LoggedInUser }}
25
-
<div id="upgrade-banner"
26
-
hx-get="/knots/upgradeBanner"
27
-
hx-trigger="load"
28
-
hx-swap="innerHTML">
29
-
</div>
30
-
{{ end }}
31
-
{{ end }}
32
-
33
-
{{ define "newButton" }}
34
-
<details class="relative inline-block text-left nav-dropdown">
35
-
<summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2">
36
-
{{ i "plus" "w-4 h-4" }} new
37
-
</summary>
38
-
<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">
39
-
<a href="/repo/new" class="flex items-center gap-2">
40
-
{{ i "book-plus" "w-4 h-4" }}
41
-
new repository
42
-
</a>
43
-
<a href="/strings/new" class="flex items-center gap-2">
44
-
{{ i "line-squiggle" "w-4 h-4" }}
45
-
new string
46
-
</a>
47
-
</div>
48
-
</details>
49
-
{{ end }}
50
-
51
-
{{ define "dropDown" }}
52
-
<details class="relative inline-block text-left nav-dropdown">
53
-
<summary
54
-
class="cursor-pointer list-none flex items-center"
55
-
>
56
-
{{ $user := didOrHandle .Did .Handle }}
57
-
{{ template "user/fragments/picHandle" $user }}
58
-
</summary>
59
-
<div
60
-
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"
61
-
>
62
-
<a href="/{{ $user }}">profile</a>
63
-
<a href="/{{ $user }}?tab=repos">repositories</a>
64
-
<a href="/strings/{{ $user }}">strings</a>
65
-
<a href="/knots">knots</a>
66
-
<a href="/spindles">spindles</a>
67
-
<a href="/settings">settings</a>
68
-
<a href="#"
69
-
hx-post="/logout"
70
-
hx-swap="none"
71
-
class="text-red-400 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300">
72
-
logout
73
-
</a>
74
-
</div>
75
-
</details>
76
-
77
-
<script>
78
-
document.addEventListener('click', function(event) {
79
-
const dropdowns = document.querySelectorAll('.nav-dropdown');
80
-
dropdowns.forEach(function(dropdown) {
81
-
if (!dropdown.contains(event.target)) {
82
-
dropdown.removeAttribute('open');
83
-
}
84
-
});
85
-
});
86
-
</script>
87
-
{{ end }}
+4
-126
appview/pages/templates/legal/privacy.html
+4
-126
appview/pages/templates/legal/privacy.html
···
1
-
{{ define "title" }} privacy policy {{ end }}
1
+
{{ define "title" }}privacy policy{{ end }}
2
+
2
3
{{ define "content" }}
3
4
<div class="max-w-4xl mx-auto px-4 py-8">
4
5
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8">
5
6
<div class="prose prose-gray dark:prose-invert max-w-none">
6
-
<h1>Privacy Policy</h1>
7
-
8
-
<p><strong>Last updated:</strong> {{ now.Format "January 2, 2006" }}</p>
9
-
10
-
<p>This Privacy Policy describes how Tangled ("we," "us," or "our") collects, uses, and shares your personal information when you use our platform and services (the "Service").</p>
11
-
12
-
<h2>1. Information We Collect</h2>
13
-
14
-
<h3>Account Information</h3>
15
-
<p>When you create an account, we collect:</p>
16
-
<ul>
17
-
<li>Your chosen username</li>
18
-
<li>Email address</li>
19
-
<li>Profile information you choose to provide</li>
20
-
<li>Authentication data</li>
21
-
</ul>
22
-
23
-
<h3>Content and Activity</h3>
24
-
<p>We store:</p>
25
-
<ul>
26
-
<li>Code repositories and associated metadata</li>
27
-
<li>Issues, pull requests, and comments</li>
28
-
<li>Activity logs and usage patterns</li>
29
-
<li>Public keys for authentication</li>
30
-
</ul>
31
-
32
-
<h2>2. Data Location and Hosting</h2>
33
-
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 my-6">
34
-
<h3 class="text-blue-800 dark:text-blue-200 font-semibold mb-2">EU Data Hosting</h3>
35
-
<p class="text-blue-700 dark:text-blue-300">
36
-
<strong>All Tangled service data is hosted within the European Union.</strong> Specifically:
37
-
</p>
38
-
<ul class="text-blue-700 dark:text-blue-300 mt-2">
39
-
<li><strong>Personal Data Servers (PDS):</strong> Accounts hosted on Tangled PDS (*.tngl.sh) are located in Finland</li>
40
-
<li><strong>Application Data:</strong> All other service data is stored on EU-based servers</li>
41
-
<li><strong>Data Processing:</strong> All data processing occurs within EU jurisdiction</li>
42
-
</ul>
43
-
</div>
44
-
45
-
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4 my-6">
46
-
<h3 class="text-yellow-800 dark:text-yellow-200 font-semibold mb-2">External PDS Notice</h3>
47
-
<p class="text-yellow-700 dark:text-yellow-300">
48
-
<strong>Important:</strong> If your account is hosted on Bluesky's PDS or other self-hosted Personal Data Servers (not *.tngl.sh), we do not control that data. The data protection, storage location, and privacy practices for such accounts are governed by the respective PDS provider's policies, not this Privacy Policy. We only control data processing within our own services and infrastructure.
49
-
</p>
50
-
</div>
51
-
52
-
<h2>3. Third-Party Data Processors</h2>
53
-
<p>We only share your data with the following third-party processors:</p>
54
-
55
-
<h3>Resend (Email Services)</h3>
56
-
<ul>
57
-
<li><strong>Purpose:</strong> Sending transactional emails (account verification, notifications)</li>
58
-
<li><strong>Data Shared:</strong> Email address and necessary message content</li>
59
-
<li><strong>Location:</strong> EU-compliant email delivery service</li>
60
-
</ul>
61
-
62
-
<h3>Cloudflare (Image Caching)</h3>
63
-
<ul>
64
-
<li><strong>Purpose:</strong> Caching and optimizing image delivery</li>
65
-
<li><strong>Data Shared:</strong> Public images and associated metadata for caching purposes</li>
66
-
<li><strong>Location:</strong> Global CDN with EU data protection compliance</li>
67
-
</ul>
68
-
69
-
<h2>4. How We Use Your Information</h2>
70
-
<p>We use your information to:</p>
71
-
<ul>
72
-
<li>Provide and maintain the Service</li>
73
-
<li>Process your transactions and requests</li>
74
-
<li>Send you technical notices and support messages</li>
75
-
<li>Improve and develop new features</li>
76
-
<li>Ensure security and prevent fraud</li>
77
-
<li>Comply with legal obligations</li>
78
-
</ul>
79
-
80
-
<h2>5. Data Sharing and Disclosure</h2>
81
-
<p>We do not sell, trade, or rent your personal information. We may share your information only in the following circumstances:</p>
82
-
<ul>
83
-
<li>With the third-party processors listed above</li>
84
-
<li>When required by law or legal process</li>
85
-
<li>To protect our rights, property, or safety, or that of our users</li>
86
-
<li>In connection with a merger, acquisition, or sale of assets (with appropriate protections)</li>
87
-
</ul>
88
-
89
-
<h2>6. Data Security</h2>
90
-
<p>We implement appropriate technical and organizational measures to protect your personal information against unauthorized access, alteration, disclosure, or destruction. However, no method of transmission over the Internet is 100% secure.</p>
91
-
92
-
<h2>7. Data Retention</h2>
93
-
<p>We retain your personal information for as long as necessary to provide the Service and fulfill the purposes outlined in this Privacy Policy, unless a longer retention period is required by law.</p>
94
-
95
-
<h2>8. Your Rights</h2>
96
-
<p>Under applicable data protection laws, you have the right to:</p>
97
-
<ul>
98
-
<li>Access your personal information</li>
99
-
<li>Correct inaccurate information</li>
100
-
<li>Request deletion of your information</li>
101
-
<li>Object to processing of your information</li>
102
-
<li>Data portability</li>
103
-
<li>Withdraw consent (where applicable)</li>
104
-
</ul>
105
-
106
-
<h2>9. Cookies and Tracking</h2>
107
-
<p>We use cookies and similar technologies to:</p>
108
-
<ul>
109
-
<li>Maintain your login session</li>
110
-
<li>Remember your preferences</li>
111
-
<li>Analyze usage patterns to improve the Service</li>
112
-
</ul>
113
-
<p>You can control cookie settings through your browser preferences.</p>
114
-
115
-
<h2>10. Children's Privacy</h2>
116
-
<p>The Service is not intended for children under 16 years of age. We do not knowingly collect personal information from children under 16. If we become aware that we have collected such information, we will take steps to delete it.</p>
117
-
118
-
<h2>11. International Data Transfers</h2>
119
-
<p>While all our primary data processing occurs within the EU, some of our third-party processors may process data outside the EU. When this occurs, we ensure appropriate safeguards are in place, such as Standard Contractual Clauses or adequacy decisions.</p>
120
-
121
-
<h2>12. Changes to This Privacy Policy</h2>
122
-
<p>We may update this Privacy Policy from time to time. We will notify you of any changes by posting the new Privacy Policy on this page and updating the "Last updated" date.</p>
123
-
124
-
<h2>13. Contact Information</h2>
125
-
<p>If you have any questions about this Privacy Policy or wish to exercise your rights, please contact us through our platform or via email.</p>
126
-
127
-
<div class="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700 text-sm text-gray-600 dark:text-gray-400">
128
-
<p>This Privacy Policy complies with the EU General Data Protection Regulation (GDPR) and other applicable data protection laws.</p>
129
-
</div>
7
+
{{ .Content }}
130
8
</div>
131
9
</div>
132
10
</div>
133
-
{{ end }}
11
+
{{ end }}
+2
-62
appview/pages/templates/legal/terms.html
+2
-62
appview/pages/templates/legal/terms.html
···
4
4
<div class="max-w-4xl mx-auto px-4 py-8">
5
5
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8">
6
6
<div class="prose prose-gray dark:prose-invert max-w-none">
7
-
<h1>Terms of Service</h1>
8
-
9
-
<p><strong>Last updated:</strong> {{ now.Format "January 2, 2006" }}</p>
10
-
11
-
<p>Welcome to Tangled. These Terms of Service ("Terms") govern your access to and use of the Tangled platform and services (the "Service") operated by us ("Tangled," "we," "us," or "our").</p>
12
-
13
-
<h2>1. Acceptance of Terms</h2>
14
-
<p>By accessing or using our Service, you agree to be bound by these Terms. If you disagree with any part of these terms, then you may not access the Service.</p>
15
-
16
-
<h2>2. Account Registration</h2>
17
-
<p>To use certain features of the Service, you must register for an account. You agree to provide accurate, current, and complete information during the registration process and to update such information to keep it accurate, current, and complete.</p>
18
-
19
-
<h2>3. Account Termination</h2>
20
-
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 my-6">
21
-
<h3 class="text-red-800 dark:text-red-200 font-semibold mb-2">Important Notice</h3>
22
-
<p class="text-red-700 dark:text-red-300">
23
-
<strong>We reserve the right to terminate, suspend, or restrict access to your account at any time, for any reason, or for no reason at all, at our sole discretion.</strong> This includes, but is not limited to, termination for violation of these Terms, inappropriate conduct, spam, abuse, or any other behavior we deem harmful to the Service or other users.
24
-
</p>
25
-
<p class="text-red-700 dark:text-red-300 mt-2">
26
-
Account termination may result in the loss of access to your repositories, data, and other content associated with your account. We are not obligated to provide advance notice of termination, though we may do so in our discretion.
27
-
</p>
28
-
</div>
29
-
30
-
<h2>4. Acceptable Use</h2>
31
-
<p>You agree not to use the Service to:</p>
32
-
<ul>
33
-
<li>Violate any applicable laws or regulations</li>
34
-
<li>Infringe upon the rights of others</li>
35
-
<li>Upload, store, or share content that is illegal, harmful, threatening, abusive, harassing, defamatory, vulgar, obscene, or otherwise objectionable</li>
36
-
<li>Engage in spam, phishing, or other deceptive practices</li>
37
-
<li>Attempt to gain unauthorized access to the Service or other users' accounts</li>
38
-
<li>Interfere with or disrupt the Service or servers connected to the Service</li>
39
-
</ul>
40
-
41
-
<h2>5. Content and Intellectual Property</h2>
42
-
<p>You retain ownership of the content you upload to the Service. By uploading content, you grant us a non-exclusive, worldwide, royalty-free license to use, reproduce, modify, and distribute your content as necessary to provide the Service.</p>
43
-
44
-
<h2>6. Privacy</h2>
45
-
<p>Your privacy is important to us. Please review our <a href="/privacy" class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300">Privacy Policy</a>, which also governs your use of the Service.</p>
46
-
47
-
<h2>7. Disclaimers</h2>
48
-
<p>The Service is provided on an "AS IS" and "AS AVAILABLE" basis. We make no warranties, expressed or implied, and hereby disclaim and negate all other warranties including without limitation, implied warranties or conditions of merchantability, fitness for a particular purpose, or non-infringement of intellectual property or other violation of rights.</p>
49
-
50
-
<h2>8. Limitation of Liability</h2>
51
-
<p>In no event shall Tangled, nor its directors, employees, partners, agents, suppliers, or affiliates, be liable for any indirect, incidental, special, consequential, or punitive damages, including without limitation, loss of profits, data, use, goodwill, or other intangible losses, resulting from your use of the Service.</p>
52
-
53
-
<h2>9. Indemnification</h2>
54
-
<p>You agree to defend, indemnify, and hold harmless Tangled and its affiliates, officers, directors, employees, and agents from and against any and all claims, damages, obligations, losses, liabilities, costs, or debt, and expenses (including attorney's fees).</p>
55
-
56
-
<h2>10. Governing Law</h2>
57
-
<p>These Terms shall be interpreted and governed by the laws of Finland, without regard to its conflict of law provisions.</p>
58
-
59
-
<h2>11. Changes to Terms</h2>
60
-
<p>We reserve the right to modify or replace these Terms at any time. If a revision is material, we will try to provide at least 30 days notice prior to any new terms taking effect.</p>
61
-
62
-
<h2>12. Contact Information</h2>
63
-
<p>If you have any questions about these Terms of Service, please contact us through our platform or via email.</p>
64
-
65
-
<div class="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700 text-sm text-gray-600 dark:text-gray-400">
66
-
<p>These terms are effective as of the last updated date shown above and will remain in effect except with respect to any changes in their provisions in the future, which will be in effect immediately after being posted on this page.</p>
67
-
</div>
7
+
{{ .Content }}
68
8
</div>
69
9
</div>
70
10
</div>
71
-
{{ end }}
11
+
{{ end }}
+2
-2
appview/pages/templates/repo/commit.html
+2
-2
appview/pages/templates/repo/commit.html
···
81
81
82
82
{{ define "topbarLayout" }}
83
83
<header class="px-1 col-span-full" style="z-index: 20;">
84
-
{{ template "layouts/topbar" . }}
84
+
{{ template "layouts/fragments/topbar" . }}
85
85
</header>
86
86
{{ end }}
87
87
···
106
106
107
107
{{ define "footerLayout" }}
108
108
<footer class="px-1 col-span-full mt-12">
109
-
{{ template "layouts/footer" . }}
109
+
{{ template "layouts/fragments/footer" . }}
110
110
</footer>
111
111
{{ end }}
112
112
+2
-2
appview/pages/templates/repo/compare/compare.html
+2
-2
appview/pages/templates/repo/compare/compare.html
···
12
12
13
13
{{ define "topbarLayout" }}
14
14
<header class="px-1 col-span-full" style="z-index: 20;">
15
-
{{ template "layouts/topbar" . }}
15
+
{{ template "layouts/fragments/topbar" . }}
16
16
</header>
17
17
{{ end }}
18
18
···
37
37
38
38
{{ define "footerLayout" }}
39
39
<footer class="px-1 col-span-full mt-12">
40
-
{{ template "layouts/footer" . }}
40
+
{{ template "layouts/fragments/footer" . }}
41
41
</footer>
42
42
{{ end }}
43
43
+1
-1
appview/pages/templates/repo/fork.html
+1
-1
appview/pages/templates/repo/fork.html
+35
-83
appview/pages/templates/repo/fragments/diff.html
+35
-83
appview/pages/templates/repo/fragments/diff.html
···
11
11
{{ $last := sub (len $diff) 1 }}
12
12
13
13
<div class="flex flex-col gap-4">
14
+
{{ if eq (len $diff) 0 }}
15
+
<div class="text-center text-gray-500 dark:text-gray-400 py-8">
16
+
<p>No differences found between the selected revisions.</p>
17
+
</div>
18
+
{{ else }}
14
19
{{ range $idx, $hunk := $diff }}
15
20
{{ with $hunk }}
16
-
<section class="border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm">
17
-
<div id="file-{{ .Name.New }}">
18
-
<div id="diff-file">
19
-
<details open>
20
-
<summary class="list-none cursor-pointer sticky top-0">
21
-
<div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between">
22
-
<div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto">
23
-
<div class="flex gap-1 items-center">
24
-
{{ $markerstyle := "diff-type p-1 mr-1 font-mono text-sm rounded select-none" }}
25
-
{{ if .IsNew }}
26
-
<span class="bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400 {{ $markerstyle }}">ADDED</span>
27
-
{{ else if .IsDelete }}
28
-
<span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">DELETED</span>
29
-
{{ else if .IsCopy }}
30
-
<span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">COPIED</span>
31
-
{{ else if .IsRename }}
32
-
<span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">RENAMED</span>
33
-
{{ else }}
34
-
<span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">MODIFIED</span>
35
-
{{ end }}
36
-
37
-
{{ template "repo/fragments/diffStatPill" .Stats }}
38
-
</div>
39
-
40
-
<div class="flex gap-2 items-center overflow-x-auto">
41
-
{{ if .IsDelete }}
42
-
<a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $this }}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.Old }}"{{end}}>
43
-
{{ .Name.Old }}
44
-
</a>
45
-
{{ else if (or .IsCopy .IsRename) }}
46
-
<a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $parent}}href="/{{ $repo }}/blob/{{ $parent }}/{{ .Name.Old }}"{{end}}>
47
-
{{ .Name.Old }}
48
-
</a>
49
-
{{ i "arrow-right" "w-4 h-4" }}
50
-
<a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $this}}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.New }}"{{end}}>
51
-
{{ .Name.New }}
52
-
</a>
53
-
{{ else }}
54
-
<a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $this}}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.New }}"{{end}}>
55
-
{{ .Name.New }}
56
-
</a>
57
-
{{ end }}
58
-
</div>
59
-
</div>
60
-
61
-
{{ $iconstyle := "p-1 mx-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" }}
62
-
<div id="right-side-items" class="p-2 flex items-center">
63
-
<a title="top of file" href="#file-{{ .Name.New }}" class="{{ $iconstyle }}">{{ i "arrow-up-to-line" "w-4 h-4" }}</a>
64
-
{{ if gt $idx 0 }}
65
-
{{ $prev := index $diff (sub $idx 1) }}
66
-
<a title="previous file" href="#file-{{ $prev.Name.New }}" class="{{ $iconstyle }}">{{ i "arrow-up" "w-4 h-4" }}</a>
67
-
{{ end }}
68
-
69
-
{{ if lt $idx $last }}
70
-
{{ $next := index $diff (add $idx 1) }}
71
-
<a title="next file" href="#file-{{ $next.Name.New }}" class="{{ $iconstyle }}">{{ i "arrow-down" "w-4 h-4" }}</a>
72
-
{{ end }}
73
-
</div>
21
+
<details open id="file-{{ .Name.New }}" class="group border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm" tabindex="{{ add $idx 1 }}">
22
+
<summary class="list-none cursor-pointer sticky top-0">
23
+
<div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between">
24
+
<div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto">
25
+
<span class="group-open:hidden inline">{{ i "chevron-right" "w-4 h-4" }}</span>
26
+
<span class="hidden group-open:inline">{{ i "chevron-down" "w-4 h-4" }}</span>
27
+
{{ template "repo/fragments/diffStatPill" .Stats }}
74
28
75
-
</div>
76
-
</summary>
77
-
78
-
<div class="transition-all duration-700 ease-in-out">
79
-
{{ if .IsDelete }}
80
-
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
81
-
This file has been deleted.
82
-
</p>
83
-
{{ else if .IsCopy }}
84
-
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
85
-
This file has been copied.
86
-
</p>
87
-
{{ else if .IsBinary }}
88
-
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
89
-
This is a binary file and will not be displayed.
90
-
</p>
91
-
{{ else }}
92
-
{{ if $isSplit }}
93
-
{{- template "repo/fragments/splitDiff" .Split -}}
29
+
<div class="flex gap-2 items-center overflow-x-auto">
30
+
{{ if .IsDelete }}
31
+
{{ .Name.Old }}
32
+
{{ else if (or .IsCopy .IsRename) }}
33
+
{{ .Name.Old }} {{ i "arrow-right" "w-4 h-4" }} {{ .Name.New }}
94
34
{{ else }}
95
-
{{- template "repo/fragments/unifiedDiff" . -}}
35
+
{{ .Name.New }}
96
36
{{ end }}
97
-
{{- end -}}
37
+
</div>
98
38
</div>
39
+
</div>
40
+
</summary>
99
41
100
-
</details>
101
-
42
+
<div class="transition-all duration-700 ease-in-out">
43
+
{{ if .IsBinary }}
44
+
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
45
+
This is a binary file and will not be displayed.
46
+
</p>
47
+
{{ else }}
48
+
{{ if $isSplit }}
49
+
{{- template "repo/fragments/splitDiff" .Split -}}
50
+
{{ else }}
51
+
{{- template "repo/fragments/unifiedDiff" . -}}
52
+
{{ end }}
53
+
{{- end -}}
102
54
</div>
103
-
</div>
104
-
</section>
55
+
</details>
105
56
{{ end }}
57
+
{{ end }}
106
58
{{ end }}
107
59
</div>
108
60
{{ end }}
+4
appview/pages/templates/repo/fragments/duration.html
+4
appview/pages/templates/repo/fragments/duration.html
+44
-69
appview/pages/templates/repo/fragments/interdiff.html
+44
-69
appview/pages/templates/repo/fragments/interdiff.html
···
10
10
<div class="flex flex-col gap-4">
11
11
{{ range $idx, $hunk := $diff }}
12
12
{{ with $hunk }}
13
-
<section class="border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm">
14
-
<div id="file-{{ .Name }}">
15
-
<div id="diff-file">
16
-
<details {{ if not (.Status.IsOnlyInOne) }}open{{end}}>
17
-
<summary class="list-none cursor-pointer sticky top-0">
18
-
<div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between">
19
-
<div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto">
20
-
<div class="flex gap-1 items-center" style="direction: ltr;">
21
-
{{ $markerstyle := "diff-type p-1 mr-1 font-mono text-sm rounded select-none" }}
22
-
{{ if .Status.IsOk }}
23
-
<span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">CHANGED</span>
24
-
{{ else if .Status.IsUnchanged }}
25
-
<span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">UNCHANGED</span>
26
-
{{ else if .Status.IsOnlyInOne }}
27
-
<span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">REVERTED</span>
28
-
{{ else if .Status.IsOnlyInTwo }}
29
-
<span class="bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400 {{ $markerstyle }}">NEW</span>
30
-
{{ else if .Status.IsRebased }}
31
-
<span class="bg-amber-100 text-amber-700 dark:bg-amber-800/50 dark:text-amber-400 {{ $markerstyle }}">REBASED</span>
32
-
{{ else }}
33
-
<span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">ERROR</span>
34
-
{{ end }}
35
-
</div>
36
-
37
-
<div class="flex gap-2 items-center overflow-x-auto" style="direction: rtl;">
38
-
<a class="dark:text-white whitespace-nowrap overflow-x-auto" href="">
39
-
{{ .Name }}
40
-
</a>
41
-
</div>
42
-
</div>
43
-
44
-
{{ $iconstyle := "p-1 mx-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" }}
45
-
<div id="right-side-items" class="p-2 flex items-center">
46
-
<a title="top of file" href="#file-{{ .Name }}" class="{{ $iconstyle }}">{{ i "arrow-up-to-line" "w-4 h-4" }}</a>
47
-
{{ if gt $idx 0 }}
48
-
{{ $prev := index $diff (sub $idx 1) }}
49
-
<a title="previous file" href="#file-{{ $prev.Name }}" class="{{ $iconstyle }}">{{ i "arrow-up" "w-4 h-4" }}</a>
50
-
{{ end }}
51
-
52
-
{{ if lt $idx $last }}
53
-
{{ $next := index $diff (add $idx 1) }}
54
-
<a title="next file" href="#file-{{ $next.Name }}" class="{{ $iconstyle }}">{{ i "arrow-down" "w-4 h-4" }}</a>
55
-
{{ end }}
56
-
</div>
57
-
13
+
<details {{ if not (.Status.IsOnlyInOne) }}open{{end}} id="file-{{ .Name }}" class="border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm">
14
+
<summary class="list-none cursor-pointer sticky top-0">
15
+
<div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between">
16
+
<div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto">
17
+
<div class="flex gap-1 items-center" style="direction: ltr;">
18
+
{{ $markerstyle := "diff-type p-1 mr-1 font-mono text-sm rounded select-none" }}
19
+
{{ if .Status.IsOk }}
20
+
<span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">CHANGED</span>
21
+
{{ else if .Status.IsUnchanged }}
22
+
<span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">UNCHANGED</span>
23
+
{{ else if .Status.IsOnlyInOne }}
24
+
<span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">REVERTED</span>
25
+
{{ else if .Status.IsOnlyInTwo }}
26
+
<span class="bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400 {{ $markerstyle }}">NEW</span>
27
+
{{ else if .Status.IsRebased }}
28
+
<span class="bg-amber-100 text-amber-700 dark:bg-amber-800/50 dark:text-amber-400 {{ $markerstyle }}">REBASED</span>
29
+
{{ else }}
30
+
<span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">ERROR</span>
31
+
{{ end }}
58
32
</div>
59
-
</summary>
60
33
61
-
<div class="transition-all duration-700 ease-in-out">
62
-
{{ if .Status.IsUnchanged }}
63
-
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
64
-
This file has not been changed.
65
-
</p>
66
-
{{ else if .Status.IsRebased }}
67
-
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
68
-
This patch was likely rebased, as context lines do not match.
69
-
</p>
70
-
{{ else if .Status.IsError }}
71
-
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
72
-
Failed to calculate interdiff for this file.
73
-
</p>
74
-
{{ else }}
75
-
{{ if $isSplit }}
76
-
{{- template "repo/fragments/splitDiff" .Split -}}
77
-
{{ else }}
78
-
{{- template "repo/fragments/unifiedDiff" . -}}
79
-
{{ end }}
80
-
{{- end -}}
34
+
<div class="flex gap-2 items-center overflow-x-auto" style="direction: rtl;">{{ .Name }}</div>
81
35
</div>
82
36
83
-
</details>
37
+
</div>
38
+
</summary>
84
39
40
+
<div class="transition-all duration-700 ease-in-out">
41
+
{{ if .Status.IsUnchanged }}
42
+
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
43
+
This file has not been changed.
44
+
</p>
45
+
{{ else if .Status.IsRebased }}
46
+
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
47
+
This patch was likely rebased, as context lines do not match.
48
+
</p>
49
+
{{ else if .Status.IsError }}
50
+
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
51
+
Failed to calculate interdiff for this file.
52
+
</p>
53
+
{{ else }}
54
+
{{ if $isSplit }}
55
+
{{- template "repo/fragments/splitDiff" .Split -}}
56
+
{{ else }}
57
+
{{- template "repo/fragments/unifiedDiff" . -}}
58
+
{{ end }}
59
+
{{- end -}}
85
60
</div>
86
-
</div>
87
-
</section>
61
+
62
+
</details>
88
63
{{ end }}
89
64
{{ end }}
90
65
</div>
+6
appview/pages/templates/repo/fragments/languageBall.html
+6
appview/pages/templates/repo/fragments/languageBall.html
···
1
+
{{ define "repo/fragments/languageBall" }}
2
+
<div
3
+
class="size-2 rounded-full"
4
+
style="background: radial-gradient(circle at 35% 35%, color-mix(in srgb, {{ langColor . }} 70%, white), {{ langColor . }} 30%, color-mix(in srgb, {{ langColor . }} 85%, black));"
5
+
></div>
6
+
{{ end }}
+4
appview/pages/templates/repo/fragments/shortTime.html
+4
appview/pages/templates/repo/fragments/shortTime.html
+4
appview/pages/templates/repo/fragments/shortTimeAgo.html
+4
appview/pages/templates/repo/fragments/shortTimeAgo.html
-16
appview/pages/templates/repo/fragments/time.html
-16
appview/pages/templates/repo/fragments/time.html
···
1
-
{{ define "repo/fragments/timeWrapper" }}
2
-
<time datetime="{{ .Time | iso8601DateTimeFmt }}" title="{{ .Time | longTimeFmt }}">{{ .Content }}</time>
3
-
{{ end }}
4
-
5
1
{{ define "repo/fragments/time" }}
6
2
{{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (. | relTimeFmt)) }}
7
3
{{ end }}
8
-
9
-
{{ define "repo/fragments/shortTime" }}
10
-
{{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (. | shortRelTimeFmt)) }}
11
-
{{ end }}
12
-
13
-
{{ define "repo/fragments/shortTimeAgo" }}
14
-
{{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (print (. | shortRelTimeFmt) " ago")) }}
15
-
{{ end }}
16
-
17
-
{{ define "repo/fragments/duration" }}
18
-
<time datetime="{{ . | iso8601DurationFmt }}" title="{{ . | longDurationFmt }}">{{ . | durationFmt }}</time>
19
-
{{ end }}
+5
appview/pages/templates/repo/fragments/timeWrapper.html
+5
appview/pages/templates/repo/fragments/timeWrapper.html
+25
-8
appview/pages/templates/repo/index.html
+25
-8
appview/pages/templates/repo/index.html
···
35
35
{{ end }}
36
36
37
37
{{ define "repoLanguages" }}
38
-
<div class="flex gap-[1px] -m-6 mb-6 overflow-hidden rounded-t">
38
+
<details class="group -m-6 mb-4">
39
+
<summary class="flex gap-[1px] h-4 scale-y-50 hover:scale-y-100 origin-top group-open:scale-y-100 transition-all hover:cursor-pointer overflow-hidden rounded-t">
40
+
{{ range $value := .Languages }}
41
+
<div
42
+
title='{{ or $value.Name "Other" }} {{ printf "%.1f" $value.Percentage }}%'
43
+
style="background-color: {{ $value.Color }}; width: {{ $value.Percentage }}%"
44
+
></div>
45
+
{{ end }}
46
+
</summary>
47
+
<div class="px-4 py-2 border-b border-gray-200 dark:border-gray-600 flex items-center gap-4 flex-wrap">
39
48
{{ range $value := .Languages }}
40
-
<div
41
-
title='{{ or $value.Name "Other" }} {{ printf "%.1f" $value.Percentage }}%'
42
-
class="h-[4px] rounded-full"
43
-
style="background-color: {{ $value.Color }}; width: {{ $value.Percentage }}%"
44
-
></div>
49
+
<div
50
+
class="flex flex-grow items-center gap-2 text-xs align-items-center justify-center"
51
+
>
52
+
{{ template "repo/fragments/languageBall" $value.Name }}
53
+
<div>{{ or $value.Name "Other" }}
54
+
<span class="text-gray-500 dark:text-gray-400">
55
+
{{ if lt $value.Percentage 0.05 }}
56
+
0.1%
57
+
{{ else }}
58
+
{{ printf "%.1f" $value.Percentage }}%
59
+
{{ end }}
60
+
</span></div>
61
+
</div>
45
62
{{ end }}
46
-
</div>
63
+
</div>
64
+
</details>
47
65
{{ end }}
48
-
49
66
50
67
{{ define "branchSelector" }}
51
68
<div class="flex gap-2 items-center justify-between w-full">
+58
appview/pages/templates/repo/issues/fragments/commentList.html
+58
appview/pages/templates/repo/issues/fragments/commentList.html
···
1
+
{{ define "repo/issues/fragments/commentList" }}
2
+
<div class="flex flex-col gap-8">
3
+
{{ range $item := .CommentList }}
4
+
{{ template "commentListing" (list $ .) }}
5
+
{{ end }}
6
+
<div>
7
+
{{ end }}
8
+
9
+
{{ define "commentListing" }}
10
+
{{ $root := index . 0 }}
11
+
{{ $comment := index . 1 }}
12
+
{{ $params :=
13
+
(dict
14
+
"RepoInfo" $root.RepoInfo
15
+
"LoggedInUser" $root.LoggedInUser
16
+
"Issue" $root.Issue
17
+
"Comment" $comment.Self) }}
18
+
19
+
<div class="rounded border border-gray-300 dark:border-gray-700 w-full overflow-hidden shadow-sm">
20
+
{{ template "topLevelComment" $params }}
21
+
22
+
<div class="relative ml-4 border-l border-gray-300 dark:border-gray-700">
23
+
{{ range $index, $reply := $comment.Replies }}
24
+
<div class="relative ">
25
+
<!-- Horizontal connector -->
26
+
<div class="absolute left-0 top-6 w-4 h-px bg-gray-300 dark:bg-gray-700"></div>
27
+
28
+
<div class="pl-2">
29
+
{{
30
+
template "replyComment"
31
+
(dict
32
+
"RepoInfo" $root.RepoInfo
33
+
"LoggedInUser" $root.LoggedInUser
34
+
"Issue" $root.Issue
35
+
"Comment" $reply)
36
+
}}
37
+
</div>
38
+
</div>
39
+
{{ end }}
40
+
</div>
41
+
42
+
{{ template "repo/issues/fragments/replyIssueCommentPlaceholder" $params }}
43
+
</div>
44
+
{{ end }}
45
+
46
+
{{ define "topLevelComment" }}
47
+
<div class="rounded px-6 py-4 bg-white dark:bg-gray-800">
48
+
{{ template "repo/issues/fragments/issueCommentHeader" . }}
49
+
{{ template "repo/issues/fragments/issueCommentBody" . }}
50
+
</div>
51
+
{{ end }}
52
+
53
+
{{ define "replyComment" }}
54
+
<div class="p-4 w-full mx-auto overflow-hidden">
55
+
{{ template "repo/issues/fragments/issueCommentHeader" . }}
56
+
{{ template "repo/issues/fragments/issueCommentBody" . }}
57
+
</div>
58
+
{{ end }}
+37
-45
appview/pages/templates/repo/issues/fragments/editIssueComment.html
+37
-45
appview/pages/templates/repo/issues/fragments/editIssueComment.html
···
1
1
{{ define "repo/issues/fragments/editIssueComment" }}
2
-
{{ with .Comment }}
3
-
<div id="comment-container-{{.CommentId}}">
4
-
<div class="flex items-center gap-2 mb-2 text-gray-500 dark:text-gray-400 text-sm flex-wrap">
5
-
{{ $owner := didOrHandle $.LoggedInUser.Did $.LoggedInUser.Handle }}
6
-
<a href="/{{ $owner }}" class="no-underline hover:underline">{{ $owner }}</a>
2
+
<div id="comment-body-{{.Comment.Id}}" class="pt-2">
3
+
<textarea
4
+
id="edit-textarea-{{ .Comment.Id }}"
5
+
name="body"
6
+
class="w-full p-2 rounded border border-gray-200 dark:border-gray-700"
7
+
rows="5"
8
+
autofocus>{{ .Comment.Body }}</textarea>
7
9
8
-
<!-- show user "hats" -->
9
-
{{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }}
10
-
{{ if $isIssueAuthor }}
11
-
<span class="before:content-['ยท']"></span>
12
-
author
13
-
{{ end }}
14
-
15
-
<span class="before:content-['ยท']"></span>
16
-
<a
17
-
href="#{{ .CommentId }}"
18
-
class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-400 hover:underline no-underline"
19
-
id="{{ .CommentId }}">
20
-
{{ template "repo/fragments/time" .Created }}
21
-
</a>
22
-
23
-
<button
24
-
class="btn px-2 py-1 flex items-center gap-2 text-sm group"
25
-
hx-post="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/edit"
26
-
hx-include="#edit-textarea-{{ .CommentId }}"
27
-
hx-target="#comment-container-{{ .CommentId }}"
28
-
hx-swap="outerHTML">
29
-
{{ i "check" "w-4 h-4" }}
30
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
31
-
</button>
32
-
<button
33
-
class="btn px-2 py-1 flex items-center gap-2 text-sm"
34
-
hx-get="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/"
35
-
hx-target="#comment-container-{{ .CommentId }}"
36
-
hx-swap="outerHTML">
37
-
{{ i "x" "w-4 h-4" }}
38
-
</button>
39
-
<span id="comment-{{.CommentId}}-status"></span>
40
-
</div>
10
+
{{ template "editActions" $ }}
11
+
</div>
12
+
{{ end }}
41
13
42
-
<div>
43
-
<textarea
44
-
id="edit-textarea-{{ .CommentId }}"
45
-
name="body"
46
-
class="w-full p-2 border rounded min-h-[100px]">{{ .Body }}</textarea>
47
-
</div>
14
+
{{ define "editActions" }}
15
+
<div class="flex flex-wrap items-center justify-end gap-2 text-gray-500 dark:text-gray-400 text-sm pt-2">
16
+
{{ template "cancel" . }}
17
+
{{ template "save" . }}
48
18
</div>
49
-
{{ end }}
19
+
{{ end }}
20
+
21
+
{{ define "save" }}
22
+
<button
23
+
class="btn-create py-0 flex gap-1 items-center group text-sm"
24
+
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/edit"
25
+
hx-include="#edit-textarea-{{ .Comment.Id }}"
26
+
hx-target="#comment-body-{{ .Comment.Id }}"
27
+
hx-swap="outerHTML">
28
+
{{ i "check" "size-4" }}
29
+
save
30
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
31
+
</button>
50
32
{{ end }}
51
33
34
+
{{ define "cancel" }}
35
+
<button
36
+
class="btn py-0 text-red-500 dark:text-red-400 flex gap-1 items-center group"
37
+
hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/"
38
+
hx-target="#comment-body-{{ .Comment.Id }}"
39
+
hx-swap="outerHTML">
40
+
{{ i "x" "size-4" }}
41
+
cancel
42
+
</button>
43
+
{{ end }}
-58
appview/pages/templates/repo/issues/fragments/issueComment.html
-58
appview/pages/templates/repo/issues/fragments/issueComment.html
···
1
-
{{ define "repo/issues/fragments/issueComment" }}
2
-
{{ with .Comment }}
3
-
<div id="comment-container-{{.CommentId}}">
4
-
<div class="flex items-center gap-2 mb-2 text-gray-500 dark:text-gray-400 text-sm flex-wrap">
5
-
{{ template "user/fragments/picHandleLink" .OwnerDid }}
6
-
7
-
<!-- show user "hats" -->
8
-
{{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }}
9
-
{{ if $isIssueAuthor }}
10
-
<span class="before:content-['ยท']"></span>
11
-
author
12
-
{{ end }}
13
-
14
-
<span class="before:content-['ยท']"></span>
15
-
<a
16
-
href="#{{ .CommentId }}"
17
-
class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-400 hover:underline no-underline"
18
-
id="{{ .CommentId }}">
19
-
{{ if .Deleted }}
20
-
deleted {{ template "repo/fragments/time" .Deleted }}
21
-
{{ else if .Edited }}
22
-
edited {{ template "repo/fragments/time" .Edited }}
23
-
{{ else }}
24
-
{{ template "repo/fragments/time" .Created }}
25
-
{{ end }}
26
-
</a>
27
-
28
-
{{ $isCommentOwner := and $.LoggedInUser (eq $.LoggedInUser.Did .OwnerDid) }}
29
-
{{ if and $isCommentOwner (not .Deleted) }}
30
-
<button
31
-
class="btn px-2 py-1 text-sm"
32
-
hx-get="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/edit"
33
-
hx-swap="outerHTML"
34
-
hx-target="#comment-container-{{.CommentId}}"
35
-
>
36
-
{{ i "pencil" "w-4 h-4" }}
37
-
</button>
38
-
<button
39
-
class="btn px-2 py-1 text-sm text-red-500 flex gap-2 items-center group"
40
-
hx-delete="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/"
41
-
hx-confirm="Are you sure you want to delete your comment?"
42
-
hx-swap="outerHTML"
43
-
hx-target="#comment-container-{{.CommentId}}"
44
-
>
45
-
{{ i "trash-2" "w-4 h-4" }}
46
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
47
-
</button>
48
-
{{ end }}
49
-
50
-
</div>
51
-
{{ if not .Deleted }}
52
-
<div class="prose dark:prose-invert">
53
-
{{ .Body | markdown }}
54
-
</div>
55
-
{{ end }}
56
-
</div>
57
-
{{ end }}
58
-
{{ end }}
+34
appview/pages/templates/repo/issues/fragments/issueCommentActions.html
+34
appview/pages/templates/repo/issues/fragments/issueCommentActions.html
···
1
+
{{ define "repo/issues/fragments/issueCommentActions" }}
2
+
{{ $isCommentOwner := and .LoggedInUser (eq .LoggedInUser.Did .Comment.Did) }}
3
+
{{ if and $isCommentOwner (not .Comment.Deleted) }}
4
+
<div class="flex flex-wrap items-center gap-4 text-gray-500 dark:text-gray-400 text-sm pt-2">
5
+
{{ template "edit" . }}
6
+
{{ template "delete" . }}
7
+
</div>
8
+
{{ end }}
9
+
{{ end }}
10
+
11
+
{{ define "edit" }}
12
+
<a
13
+
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
14
+
hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/edit"
15
+
hx-swap="outerHTML"
16
+
hx-target="#comment-body-{{.Comment.Id}}">
17
+
{{ i "pencil" "size-3" }}
18
+
edit
19
+
</a>
20
+
{{ end }}
21
+
22
+
{{ define "delete" }}
23
+
<a
24
+
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
25
+
hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/"
26
+
hx-confirm="Are you sure you want to delete your comment?"
27
+
hx-swap="outerHTML"
28
+
hx-target="#comment-body-{{.Comment.Id}}"
29
+
>
30
+
{{ i "trash-2" "size-3" }}
31
+
delete
32
+
{{ i "loader-circle" "size-3 animate-spin hidden group-[.htmx-request]:inline" }}
33
+
</a>
34
+
{{ end }}
+9
appview/pages/templates/repo/issues/fragments/issueCommentBody.html
+9
appview/pages/templates/repo/issues/fragments/issueCommentBody.html
···
1
+
{{ define "repo/issues/fragments/issueCommentBody" }}
2
+
<div id="comment-body-{{.Comment.Id}}">
3
+
{{ if not .Comment.Deleted }}
4
+
<div class="prose dark:prose-invert">{{ .Comment.Body | markdown }}</div>
5
+
{{ else }}
6
+
<div class="prose dark:prose-invert italic text-gray-500 dark:text-gray-400">[deleted by author]</div>
7
+
{{ end }}
8
+
</div>
9
+
{{ end }}
+56
appview/pages/templates/repo/issues/fragments/issueCommentHeader.html
+56
appview/pages/templates/repo/issues/fragments/issueCommentHeader.html
···
1
+
{{ define "repo/issues/fragments/issueCommentHeader" }}
2
+
<div class="flex flex-wrap items-center gap-2 text-sm text-gray-500 dark:text-gray-400 ">
3
+
{{ template "user/fragments/picHandleLink" .Comment.Did }}
4
+
{{ template "hats" $ }}
5
+
{{ template "timestamp" . }}
6
+
{{ $isCommentOwner := and .LoggedInUser (eq .LoggedInUser.Did .Comment.Did) }}
7
+
{{ if and $isCommentOwner (not .Comment.Deleted) }}
8
+
{{ template "editIssueComment" . }}
9
+
{{ template "deleteIssueComment" . }}
10
+
{{ end }}
11
+
</div>
12
+
{{ end }}
13
+
14
+
{{ define "hats" }}
15
+
{{ $isIssueAuthor := eq .Comment.Did .Issue.Did }}
16
+
{{ if $isIssueAuthor }}
17
+
(author)
18
+
{{ end }}
19
+
{{ end }}
20
+
21
+
{{ define "timestamp" }}
22
+
<a href="#{{ .Comment.Id }}"
23
+
class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-400 hover:underline no-underline"
24
+
id="{{ .Comment.Id }}">
25
+
{{ if .Comment.Deleted }}
26
+
{{ template "repo/fragments/shortTimeAgo" .Comment.Deleted }}
27
+
{{ else if .Comment.Edited }}
28
+
edited {{ template "repo/fragments/shortTimeAgo" .Comment.Edited }}
29
+
{{ else }}
30
+
{{ template "repo/fragments/shortTimeAgo" .Comment.Created }}
31
+
{{ end }}
32
+
</a>
33
+
{{ end }}
34
+
35
+
{{ define "editIssueComment" }}
36
+
<a
37
+
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
38
+
hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/edit"
39
+
hx-swap="outerHTML"
40
+
hx-target="#comment-body-{{.Comment.Id}}">
41
+
{{ i "pencil" "size-3" }}
42
+
</a>
43
+
{{ end }}
44
+
45
+
{{ define "deleteIssueComment" }}
46
+
<a
47
+
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
48
+
hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/"
49
+
hx-confirm="Are you sure you want to delete your comment?"
50
+
hx-swap="outerHTML"
51
+
hx-target="#comment-body-{{.Comment.Id}}"
52
+
>
53
+
{{ i "trash-2" "size-3" }}
54
+
{{ i "loader-circle" "size-3 animate-spin hidden group-[.htmx-request]:inline" }}
55
+
</a>
56
+
{{ end }}
+145
appview/pages/templates/repo/issues/fragments/newComment.html
+145
appview/pages/templates/repo/issues/fragments/newComment.html
···
1
+
{{ define "repo/issues/fragments/newComment" }}
2
+
{{ if .LoggedInUser }}
3
+
<form
4
+
id="comment-form"
5
+
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment"
6
+
hx-on::after-request="if(event.detail.successful) this.reset()"
7
+
>
8
+
<div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-full">
9
+
<div class="text-sm pb-2 text-gray-500 dark:text-gray-400">
10
+
{{ template "user/fragments/picHandleLink" .LoggedInUser.Did }}
11
+
</div>
12
+
<textarea
13
+
id="comment-textarea"
14
+
name="body"
15
+
class="w-full p-2 rounded border border-gray-200 dark:border-gray-700"
16
+
placeholder="Add to the discussion. Markdown is supported."
17
+
onkeyup="updateCommentForm()"
18
+
rows="5"
19
+
></textarea>
20
+
<div id="issue-comment"></div>
21
+
<div id="issue-action" class="error"></div>
22
+
</div>
23
+
24
+
<div class="flex gap-2 mt-2">
25
+
<button
26
+
id="comment-button"
27
+
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment"
28
+
type="submit"
29
+
hx-disabled-elt="#comment-button"
30
+
class="btn-create p-2 flex items-center gap-2 no-underline hover:no-underline group"
31
+
disabled
32
+
>
33
+
{{ i "message-square-plus" "w-4 h-4" }}
34
+
comment
35
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
36
+
</button>
37
+
38
+
{{ $isIssueAuthor := and .LoggedInUser (eq .LoggedInUser.Did .Issue.Did) }}
39
+
{{ $isRepoCollaborator := .RepoInfo.Roles.IsCollaborator }}
40
+
{{ $isRepoOwner := .RepoInfo.Roles.IsOwner }}
41
+
{{ if and (or $isIssueAuthor $isRepoCollaborator $isRepoOwner) .Issue.Open }}
42
+
<button
43
+
id="close-button"
44
+
type="button"
45
+
class="btn flex items-center gap-2"
46
+
hx-indicator="#close-spinner"
47
+
hx-trigger="click"
48
+
>
49
+
{{ i "ban" "w-4 h-4" }}
50
+
close
51
+
<span id="close-spinner" class="group">
52
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
53
+
</span>
54
+
</button>
55
+
<div
56
+
id="close-with-comment"
57
+
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment"
58
+
hx-trigger="click from:#close-button"
59
+
hx-disabled-elt="#close-with-comment"
60
+
hx-target="#issue-comment"
61
+
hx-indicator="#close-spinner"
62
+
hx-vals="js:{body: document.getElementById('comment-textarea').value.trim() !== '' ? document.getElementById('comment-textarea').value : ''}"
63
+
hx-swap="none"
64
+
>
65
+
</div>
66
+
<div
67
+
id="close-issue"
68
+
hx-disabled-elt="#close-issue"
69
+
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/close"
70
+
hx-trigger="click from:#close-button"
71
+
hx-target="#issue-action"
72
+
hx-indicator="#close-spinner"
73
+
hx-swap="none"
74
+
>
75
+
</div>
76
+
<script>
77
+
document.addEventListener('htmx:configRequest', function(evt) {
78
+
if (evt.target.id === 'close-with-comment') {
79
+
const commentText = document.getElementById('comment-textarea').value.trim();
80
+
if (commentText === '') {
81
+
evt.detail.parameters = {};
82
+
evt.preventDefault();
83
+
}
84
+
}
85
+
});
86
+
</script>
87
+
{{ else if and (or $isIssueAuthor $isRepoCollaborator $isRepoOwner) (not .Issue.Open) }}
88
+
<button
89
+
type="button"
90
+
class="btn flex items-center gap-2"
91
+
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/reopen"
92
+
hx-indicator="#reopen-spinner"
93
+
hx-swap="none"
94
+
>
95
+
{{ i "refresh-ccw-dot" "w-4 h-4" }}
96
+
reopen
97
+
<span id="reopen-spinner" class="group">
98
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
99
+
</span>
100
+
</button>
101
+
{{ end }}
102
+
103
+
<script>
104
+
function updateCommentForm() {
105
+
const textarea = document.getElementById('comment-textarea');
106
+
const commentButton = document.getElementById('comment-button');
107
+
const closeButton = document.getElementById('close-button');
108
+
109
+
if (textarea.value.trim() !== '') {
110
+
commentButton.removeAttribute('disabled');
111
+
} else {
112
+
commentButton.setAttribute('disabled', '');
113
+
}
114
+
115
+
if (closeButton) {
116
+
if (textarea.value.trim() !== '') {
117
+
closeButton.innerHTML = `
118
+
{{ i "ban" "w-4 h-4" }}
119
+
<span>close with comment</span>
120
+
<span id="close-spinner" class="group">
121
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
122
+
</span>`;
123
+
} else {
124
+
closeButton.innerHTML = `
125
+
{{ i "ban" "w-4 h-4" }}
126
+
<span>close</span>
127
+
<span id="close-spinner" class="group">
128
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
129
+
</span>`;
130
+
}
131
+
}
132
+
}
133
+
134
+
document.addEventListener('DOMContentLoaded', function() {
135
+
updateCommentForm();
136
+
});
137
+
</script>
138
+
</div>
139
+
</form>
140
+
{{ else }}
141
+
<div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-fit">
142
+
<a href="/login" class="underline">login</a> to join the discussion
143
+
</div>
144
+
{{ end }}
145
+
{{ end }}
+57
appview/pages/templates/repo/issues/fragments/putIssue.html
+57
appview/pages/templates/repo/issues/fragments/putIssue.html
···
1
+
{{ define "repo/issues/fragments/putIssue" }}
2
+
<!-- this form is used for new and edit, .Issue is passed when editing -->
3
+
<form
4
+
{{ if eq .Action "edit" }}
5
+
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/edit"
6
+
{{ else }}
7
+
hx-post="/{{ .RepoInfo.FullName }}/issues/new"
8
+
{{ end }}
9
+
hx-swap="none"
10
+
hx-indicator="#spinner">
11
+
<div class="flex flex-col gap-2">
12
+
<div>
13
+
<label for="title">title</label>
14
+
<input type="text" name="title" id="title" class="w-full" value="{{ if .Issue }}{{ .Issue.Title }}{{ end }}" />
15
+
</div>
16
+
<div>
17
+
<label for="body">body</label>
18
+
<textarea
19
+
name="body"
20
+
id="body"
21
+
rows="6"
22
+
class="w-full resize-y"
23
+
placeholder="Describe your issue. Markdown is supported."
24
+
>{{ if .Issue }}{{ .Issue.Body }}{{ end }}</textarea>
25
+
</div>
26
+
<div class="flex justify-between">
27
+
<div id="issues" class="error"></div>
28
+
<div class="flex gap-2 items-center">
29
+
<a
30
+
class="btn flex items-center gap-2 no-underline hover:no-underline"
31
+
type="button"
32
+
{{ if .Issue }}
33
+
href="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}"
34
+
{{ else }}
35
+
href="/{{ .RepoInfo.FullName }}/issues"
36
+
{{ end }}
37
+
>
38
+
{{ i "x" "w-4 h-4" }}
39
+
cancel
40
+
</a>
41
+
<button type="submit" class="btn-create flex items-center gap-2">
42
+
{{ if eq .Action "edit" }}
43
+
{{ i "pencil" "w-4 h-4" }}
44
+
{{ .Action }} issue
45
+
{{ else }}
46
+
{{ i "circle-plus" "w-4 h-4" }}
47
+
{{ .Action }} issue
48
+
{{ end }}
49
+
<span id="spinner" class="group">
50
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
51
+
</span>
52
+
</button>
53
+
</div>
54
+
</div>
55
+
</div>
56
+
</form>
57
+
{{ end }}
+61
appview/pages/templates/repo/issues/fragments/replyComment.html
+61
appview/pages/templates/repo/issues/fragments/replyComment.html
···
1
+
{{ define "repo/issues/fragments/replyComment" }}
2
+
<form
3
+
class="p-2 group w-full border-t border-gray-200 dark:border-gray-700 flex flex-col gap-2"
4
+
id="reply-form-{{ .Comment.Id }}"
5
+
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment"
6
+
hx-on::after-request="if(event.detail.successful) this.reset()"
7
+
hx-disabled-elt="#reply-{{ .Comment.Id }}"
8
+
>
9
+
{{ template "user/fragments/picHandleLink" .LoggedInUser.Did }}
10
+
<textarea
11
+
id="reply-{{.Comment.Id}}-textarea"
12
+
name="body"
13
+
class="w-full p-2"
14
+
placeholder="Leave a reply..."
15
+
autofocus
16
+
rows="3"
17
+
hx-trigger="keydown[ctrlKey&&key=='Enter']"
18
+
hx-target="#reply-form-{{ .Comment.Id }}"
19
+
hx-get="#"
20
+
hx-on:htmx:before-request="event.preventDefault(); document.getElementById('reply-form-{{ .Comment.Id }}').requestSubmit()"></textarea>
21
+
22
+
<input
23
+
type="text"
24
+
id="reply-to"
25
+
name="reply-to"
26
+
required
27
+
value="{{ .Comment.AtUri }}"
28
+
class="hidden"
29
+
/>
30
+
{{ template "replyActions" . }}
31
+
</form>
32
+
{{ end }}
33
+
34
+
{{ define "replyActions" }}
35
+
<div class="flex flex-wrap items-stretch justify-end gap-2 text-gray-500 dark:text-gray-400 text-sm">
36
+
{{ template "cancel" . }}
37
+
{{ template "reply" . }}
38
+
</div>
39
+
{{ end }}
40
+
41
+
{{ define "cancel" }}
42
+
<button
43
+
class="btn text-red-500 dark:text-red-400 flex gap-2 items-center group"
44
+
hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/replyPlaceholder"
45
+
hx-target="#reply-form-{{ .Comment.Id }}"
46
+
hx-swap="outerHTML">
47
+
{{ i "x" "size-4" }}
48
+
cancel
49
+
</button>
50
+
{{ end }}
51
+
52
+
{{ define "reply" }}
53
+
<button
54
+
id="reply-{{ .Comment.Id }}"
55
+
type="submit"
56
+
class="btn-create flex items-center gap-2 no-underline hover:no-underline">
57
+
{{ i "reply" "w-4 h-4 inline group-[.htmx-request]:hidden" }}
58
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
59
+
reply
60
+
</button>
61
+
{{ end }}
+20
appview/pages/templates/repo/issues/fragments/replyIssueCommentPlaceholder.html
+20
appview/pages/templates/repo/issues/fragments/replyIssueCommentPlaceholder.html
···
1
+
{{ define "repo/issues/fragments/replyIssueCommentPlaceholder" }}
2
+
<div class="p-2 border-t flex gap-2 items-center border-gray-300 dark:border-gray-700">
3
+
{{ if .LoggedInUser }}
4
+
<img
5
+
src="{{ tinyAvatar .LoggedInUser.Did }}"
6
+
alt=""
7
+
class="rounded-full h-6 w-6 mr-1 border border-gray-300 dark:border-gray-700"
8
+
/>
9
+
{{ end }}
10
+
<input
11
+
class="w-full py-2 border-none focus:outline-none"
12
+
placeholder="Leave a reply..."
13
+
hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/reply"
14
+
hx-trigger="focus"
15
+
hx-target="closest div"
16
+
hx-swap="outerHTML"
17
+
>
18
+
</input>
19
+
</div>
20
+
{{ end }}
+95
-202
appview/pages/templates/repo/issues/issue.html
+95
-202
appview/pages/templates/repo/issues/issue.html
···
9
9
{{ end }}
10
10
11
11
{{ define "repoContent" }}
12
-
<header class="pb-4">
13
-
<h1 class="text-2xl">
14
-
{{ .Issue.Title | description }}
15
-
<span class="text-gray-500 dark:text-gray-400">#{{ .Issue.IssueId }}</span>
16
-
</h1>
17
-
</header>
12
+
<section id="issue-{{ .Issue.IssueId }}">
13
+
{{ template "issueHeader" .Issue }}
14
+
{{ template "issueInfo" . }}
15
+
{{ if .Issue.Body }}
16
+
<article id="body" class="mt-4 prose dark:prose-invert">{{ .Issue.Body | markdown }}</article>
17
+
{{ end }}
18
+
{{ template "issueReactions" . }}
19
+
</section>
20
+
{{ end }}
18
21
19
-
{{ $bgColor := "bg-gray-800 dark:bg-gray-700" }}
20
-
{{ $icon := "ban" }}
21
-
{{ if eq .State "open" }}
22
-
{{ $bgColor = "bg-green-600 dark:bg-green-700" }}
23
-
{{ $icon = "circle-dot" }}
24
-
{{ end }}
22
+
{{ define "issueHeader" }}
23
+
<header class="pb-2">
24
+
<h1 class="text-2xl">
25
+
{{ .Title | description }}
26
+
<span class="text-gray-500 dark:text-gray-400">#{{ .IssueId }}</span>
27
+
</h1>
28
+
</header>
29
+
{{ end }}
25
30
26
-
<section class="mt-2">
27
-
<div class="inline-flex items-center gap-2">
28
-
<div id="state"
29
-
class="inline-flex items-center rounded px-3 py-1 {{ $bgColor }}">
30
-
{{ i $icon "w-4 h-4 mr-1.5 text-white" }}
31
-
<span class="text-white">{{ .State }}</span>
32
-
</div>
33
-
<span class="text-gray-500 dark:text-gray-400 text-sm flex flex-wrap items-center gap-1">
34
-
opened by
35
-
{{ $owner := didOrHandle .Issue.OwnerDid .IssueOwnerHandle }}
36
-
{{ template "user/fragments/picHandleLink" $owner }}
37
-
<span class="select-none before:content-['\00B7']"></span>
38
-
{{ template "repo/fragments/time" .Issue.Created }}
39
-
</span>
40
-
</div>
31
+
{{ define "issueInfo" }}
32
+
{{ $bgColor := "bg-gray-800 dark:bg-gray-700" }}
33
+
{{ $icon := "ban" }}
34
+
{{ if eq .Issue.State "open" }}
35
+
{{ $bgColor = "bg-green-600 dark:bg-green-700" }}
36
+
{{ $icon = "circle-dot" }}
37
+
{{ end }}
38
+
<div class="inline-flex items-center gap-2">
39
+
<div id="state"
40
+
class="inline-flex items-center rounded px-3 py-1 {{ $bgColor }}">
41
+
{{ i $icon "w-4 h-4 mr-1.5 text-white" }}
42
+
<span class="text-white">{{ .Issue.State }}</span>
43
+
</div>
44
+
<span class="text-gray-500 dark:text-gray-400 text-sm flex flex-wrap items-center gap-1">
45
+
opened by
46
+
{{ template "user/fragments/picHandleLink" .Issue.Did }}
47
+
<span class="select-none before:content-['\00B7']"></span>
48
+
{{ if .Issue.Edited }}
49
+
edited {{ template "repo/fragments/time" .Issue.Edited }}
50
+
{{ else }}
51
+
{{ template "repo/fragments/time" .Issue.Created }}
52
+
{{ end }}
53
+
</span>
41
54
42
-
{{ if .Issue.Body }}
43
-
<article id="body" class="mt-8 prose dark:prose-invert">
44
-
{{ .Issue.Body | markdown }}
45
-
</article>
46
-
{{ end }}
55
+
{{ if and .LoggedInUser (eq .LoggedInUser.Did .Issue.Did) }}
56
+
{{ template "issueActions" . }}
57
+
{{ end }}
58
+
</div>
59
+
<div id="issue-actions-error" class="error"></div>
60
+
{{ end }}
47
61
48
-
<div class="flex items-center gap-2 mt-2">
49
-
{{ template "repo/fragments/reactionsPopUp" .OrderedReactionKinds }}
50
-
{{ range $kind := .OrderedReactionKinds }}
51
-
{{
52
-
template "repo/fragments/reaction"
53
-
(dict
54
-
"Kind" $kind
55
-
"Count" (index $.Reactions $kind)
56
-
"IsReacted" (index $.UserReacted $kind)
57
-
"ThreadAt" $.Issue.AtUri)
58
-
}}
59
-
{{ end }}
60
-
</div>
61
-
</section>
62
+
{{ define "issueActions" }}
63
+
{{ template "editIssue" . }}
64
+
{{ template "deleteIssue" . }}
62
65
{{ end }}
63
66
64
-
{{ define "repoAfter" }}
65
-
<section id="comments" class="my-2 mt-2 space-y-2 relative">
66
-
{{ range $index, $comment := .Comments }}
67
-
<div
68
-
id="comment-{{ .CommentId }}"
69
-
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">
70
-
{{ if gt $index 0 }}
71
-
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div>
72
-
{{ end }}
73
-
{{ template "repo/issues/fragments/issueComment" (dict "RepoInfo" $.RepoInfo "LoggedInUser" $.LoggedInUser "Issue" $.Issue "Comment" .)}}
74
-
</div>
75
-
{{ end }}
76
-
</section>
67
+
{{ define "editIssue" }}
68
+
<a
69
+
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
70
+
hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/edit"
71
+
hx-swap="innerHTML"
72
+
hx-target="#issue-{{.Issue.IssueId}}">
73
+
{{ i "pencil" "size-3" }}
74
+
</a>
75
+
{{ end }}
77
76
78
-
{{ block "newComment" . }} {{ end }}
77
+
{{ define "deleteIssue" }}
78
+
<a
79
+
class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group"
80
+
hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/"
81
+
hx-confirm="Are you sure you want to delete your issue?"
82
+
hx-swap="none">
83
+
{{ i "trash-2" "size-3" }}
84
+
{{ i "loader-circle" "size-3 animate-spin hidden group-[.htmx-request]:inline" }}
85
+
</a>
86
+
{{ end }}
79
87
88
+
{{ define "issueReactions" }}
89
+
<div class="flex items-center gap-2 mt-2">
90
+
{{ template "repo/fragments/reactionsPopUp" .OrderedReactionKinds }}
91
+
{{ range $kind := .OrderedReactionKinds }}
92
+
{{
93
+
template "repo/fragments/reaction"
94
+
(dict
95
+
"Kind" $kind
96
+
"Count" (index $.Reactions $kind)
97
+
"IsReacted" (index $.UserReacted $kind)
98
+
"ThreadAt" $.Issue.AtUri)
99
+
}}
100
+
{{ end }}
101
+
</div>
80
102
{{ end }}
81
103
82
-
{{ define "newComment" }}
83
-
{{ if .LoggedInUser }}
84
-
<form
85
-
id="comment-form"
86
-
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment"
87
-
hx-on::after-request="if(event.detail.successful) this.reset()"
88
-
>
89
-
<div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-full md:w-3/5">
90
-
<div class="text-sm pb-2 text-gray-500 dark:text-gray-400">
91
-
{{ template "user/fragments/picHandleLink" (didOrHandle .LoggedInUser.Did .LoggedInUser.Handle) }}
92
-
</div>
93
-
<textarea
94
-
id="comment-textarea"
95
-
name="body"
96
-
class="w-full p-2 rounded border border-gray-200 dark:border-gray-700"
97
-
placeholder="Add to the discussion. Markdown is supported."
98
-
onkeyup="updateCommentForm()"
99
-
></textarea>
100
-
<div id="issue-comment"></div>
101
-
<div id="issue-action" class="error"></div>
102
-
</div>
103
-
104
-
<div class="flex gap-2 mt-2">
105
-
<button
106
-
id="comment-button"
107
-
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment"
108
-
type="submit"
109
-
hx-disabled-elt="#comment-button"
110
-
class="btn p-2 flex items-center gap-2 no-underline hover:no-underline group"
111
-
disabled
112
-
>
113
-
{{ i "message-square-plus" "w-4 h-4" }}
114
-
comment
115
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
116
-
</button>
117
-
118
-
{{ $isIssueAuthor := and .LoggedInUser (eq .LoggedInUser.Did .Issue.OwnerDid) }}
119
-
{{ $isRepoCollaborator := .RepoInfo.Roles.IsCollaborator }}
120
-
{{ $isRepoOwner := .RepoInfo.Roles.IsOwner }}
121
-
{{ if and (or $isIssueAuthor $isRepoCollaborator $isRepoOwner) (eq .State "open") }}
122
-
<button
123
-
id="close-button"
124
-
type="button"
125
-
class="btn flex items-center gap-2"
126
-
hx-indicator="#close-spinner"
127
-
hx-trigger="click"
128
-
>
129
-
{{ i "ban" "w-4 h-4" }}
130
-
close
131
-
<span id="close-spinner" class="group">
132
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
133
-
</span>
134
-
</button>
135
-
<div
136
-
id="close-with-comment"
137
-
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment"
138
-
hx-trigger="click from:#close-button"
139
-
hx-disabled-elt="#close-with-comment"
140
-
hx-target="#issue-comment"
141
-
hx-indicator="#close-spinner"
142
-
hx-vals="js:{body: document.getElementById('comment-textarea').value.trim() !== '' ? document.getElementById('comment-textarea').value : ''}"
143
-
hx-swap="none"
144
-
>
145
-
</div>
146
-
<div
147
-
id="close-issue"
148
-
hx-disabled-elt="#close-issue"
149
-
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/close"
150
-
hx-trigger="click from:#close-button"
151
-
hx-target="#issue-action"
152
-
hx-indicator="#close-spinner"
153
-
hx-swap="none"
154
-
>
155
-
</div>
156
-
<script>
157
-
document.addEventListener('htmx:configRequest', function(evt) {
158
-
if (evt.target.id === 'close-with-comment') {
159
-
const commentText = document.getElementById('comment-textarea').value.trim();
160
-
if (commentText === '') {
161
-
evt.detail.parameters = {};
162
-
evt.preventDefault();
163
-
}
164
-
}
165
-
});
166
-
</script>
167
-
{{ else if and (or $isIssueAuthor $isRepoCollaborator $isRepoOwner) (eq .State "closed") }}
168
-
<button
169
-
type="button"
170
-
class="btn flex items-center gap-2"
171
-
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/reopen"
172
-
hx-indicator="#reopen-spinner"
173
-
hx-swap="none"
174
-
>
175
-
{{ i "refresh-ccw-dot" "w-4 h-4" }}
176
-
reopen
177
-
<span id="reopen-spinner" class="group">
178
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
179
-
</span>
180
-
</button>
181
-
{{ end }}
182
-
183
-
<script>
184
-
function updateCommentForm() {
185
-
const textarea = document.getElementById('comment-textarea');
186
-
const commentButton = document.getElementById('comment-button');
187
-
const closeButton = document.getElementById('close-button');
188
-
189
-
if (textarea.value.trim() !== '') {
190
-
commentButton.removeAttribute('disabled');
191
-
} else {
192
-
commentButton.setAttribute('disabled', '');
193
-
}
104
+
{{ define "repoAfter" }}
105
+
<div class="flex flex-col gap-4 mt-4">
106
+
{{
107
+
template "repo/issues/fragments/commentList"
108
+
(dict
109
+
"RepoInfo" $.RepoInfo
110
+
"LoggedInUser" $.LoggedInUser
111
+
"Issue" $.Issue
112
+
"CommentList" $.Issue.CommentList)
113
+
}}
194
114
195
-
if (closeButton) {
196
-
if (textarea.value.trim() !== '') {
197
-
closeButton.innerHTML = `
198
-
{{ i "ban" "w-4 h-4" }}
199
-
<span>close with comment</span>
200
-
<span id="close-spinner" class="group">
201
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
202
-
</span>`;
203
-
} else {
204
-
closeButton.innerHTML = `
205
-
{{ i "ban" "w-4 h-4" }}
206
-
<span>close</span>
207
-
<span id="close-spinner" class="group">
208
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
209
-
</span>`;
210
-
}
211
-
}
212
-
}
115
+
{{ template "repo/issues/fragments/newComment" . }}
116
+
<div>
117
+
{{ end }}
213
118
214
-
document.addEventListener('DOMContentLoaded', function() {
215
-
updateCommentForm();
216
-
});
217
-
</script>
218
-
</div>
219
-
</form>
220
-
{{ else }}
221
-
<div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-fit">
222
-
<a href="/login" class="underline">login</a> to join the discussion
223
-
</div>
224
-
{{ end }}
225
-
{{ end }}
+42
-44
appview/pages/templates/repo/issues/issues.html
+42
-44
appview/pages/templates/repo/issues/issues.html
···
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 }}
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
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>
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
66
67
-
<span class="ml-1">
68
-
{{ template "user/fragments/picHandleLink" .OwnerDid }}
69
-
</span>
67
+
<span class="ml-1">
68
+
{{ template "user/fragments/picHandleLink" .Did }}
69
+
</span>
70
70
71
-
<span class="before:content-['ยท']">
72
-
{{ template "repo/fragments/time" .Created }}
73
-
</span>
71
+
<span class="before:content-['ยท']">
72
+
{{ template "repo/fragments/time" .Created }}
73
+
</span>
74
74
75
-
<span class="before:content-['ยท']">
76
-
{{ $s := "s" }}
77
-
{{ if eq .Metadata.CommentCount 1 }}
78
-
{{ $s = "" }}
79
-
{{ end }}
80
-
<a href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ .Metadata.CommentCount }} comment{{$s}}</a>
81
-
</span>
82
-
</p>
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 }}
83
85
</div>
84
-
{{ end }}
85
-
</div>
86
-
87
-
{{ block "pagination" . }} {{ end }}
88
-
86
+
{{ block "pagination" . }} {{ end }}
89
87
{{ end }}
90
88
91
89
{{ define "pagination" }}
+1
-33
appview/pages/templates/repo/issues/new.html
+1
-33
appview/pages/templates/repo/issues/new.html
···
1
1
{{ define "title" }}new issue · {{ .RepoInfo.FullName }}{{ end }}
2
2
3
3
{{ define "repoContent" }}
4
-
<form
5
-
hx-post="/{{ .RepoInfo.FullName }}/issues/new"
6
-
class="mt-6 space-y-6"
7
-
hx-swap="none"
8
-
hx-indicator="#spinner"
9
-
>
10
-
<div class="flex flex-col gap-4">
11
-
<div>
12
-
<label for="title">title</label>
13
-
<input type="text" name="title" id="title" class="w-full" />
14
-
</div>
15
-
<div>
16
-
<label for="body">body</label>
17
-
<textarea
18
-
name="body"
19
-
id="body"
20
-
rows="6"
21
-
class="w-full resize-y"
22
-
placeholder="Describe your issue. Markdown is supported."
23
-
></textarea>
24
-
</div>
25
-
<div>
26
-
<button type="submit" class="btn-create flex items-center gap-2">
27
-
{{ i "circle-plus" "w-4 h-4" }}
28
-
create issue
29
-
<span id="create-pull-spinner" class="group">
30
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
31
-
</span>
32
-
</button>
33
-
</div>
34
-
</div>
35
-
<div id="issues" class="error"></div>
36
-
</form>
4
+
{{ template "repo/issues/fragments/putIssue" . }}
37
5
{{ end }}
+60
appview/pages/templates/repo/needsUpgrade.html
+60
appview/pages/templates/repo/needsUpgrade.html
···
1
+
{{ define "title" }}{{ .RepoInfo.FullName }}{{ end }}
2
+
{{ define "extrameta" }}
3
+
{{ template "repo/fragments/meta" . }}
4
+
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo) }}
5
+
{{ end }}
6
+
{{ define "repoContent" }}
7
+
<main>
8
+
<div class="relative w-full h-96 flex items-center justify-center">
9
+
<div class="w-full h-full grid grid-cols-1 md:grid-cols-2 gap-4 md:divide-x divide-gray-300 dark:divide-gray-600 text-gray-300 dark:text-gray-600">
10
+
<!-- mimic the repo view here, placeholders are LLM generated -->
11
+
<div id="file-list" class="flex flex-col gap-2 col-span-1 w-full h-full p-4 items-start justify-start text-left">
12
+
{{ $files :=
13
+
(list
14
+
"src"
15
+
"docs"
16
+
"config"
17
+
"lib"
18
+
"index.html"
19
+
"log.html"
20
+
"needsUpgrade.html"
21
+
"new.html"
22
+
"tags.html"
23
+
"tree.html")
24
+
}}
25
+
{{ range $files }}
26
+
<span>
27
+
{{ if (contains . ".") }}
28
+
{{ i "file" "size-4 inline-flex" }}
29
+
{{ else }}
30
+
{{ i "folder" "size-4 inline-flex fill-current" }}
31
+
{{ end }}
32
+
33
+
{{ . }}
34
+
</span>
35
+
{{ end }}
36
+
</div>
37
+
<div id="commit-list" class="hidden md:flex md:flex-col gap-4 col-span-1 w-full h-full p-4 items-start justify-start text-left">
38
+
{{ $commits :=
39
+
(list
40
+
"Fix authentication bug in login flow"
41
+
"Add new dashboard widgets for metrics"
42
+
"Implement real-time notifications system")
43
+
}}
44
+
{{ range $commits }}
45
+
<div class="flex flex-col">
46
+
<span>{{ . }}</span>
47
+
<span class="text-xs">{{ . }}</span>
48
+
</div>
49
+
{{ end }}
50
+
</div>
51
+
</div>
52
+
<div class="absolute inset-0 flex items-center justify-center py-12 text-red-500 dark:text-red-400 backdrop-blur">
53
+
<div class="text-center">
54
+
{{ i "triangle-alert" "size-5 inline-flex items-center align-middle" }}
55
+
The knot hosting this repository needs an upgrade. This repository is currently unavailable.
56
+
</div>
57
+
</div>
58
+
</div>
59
+
</main>
60
+
{{ end }}
+1
-1
appview/pages/templates/repo/new.html
+1
-1
appview/pages/templates/repo/new.html
+2
-2
appview/pages/templates/repo/pulls/fragments/pullCompareForks.html
+2
-2
appview/pages/templates/repo/pulls/fragments/pullCompareForks.html
···
19
19
>
20
20
<option disabled selected>select a fork</option>
21
21
{{ range .Forks }}
22
-
<option value="{{ .Name }}" {{ if eq .Name $.Selected }}selected{{ end }} class="py-1">
23
-
{{ .Name }}
22
+
<option value="{{ .Did }}/{{ .Name }}" {{ if eq .Name $.Selected }}selected{{ end }} class="py-1">
23
+
{{ .Did | resolve }}/{{ .Name }}
24
24
</option>
25
25
{{ end }}
26
26
</select>
+1
-1
appview/pages/templates/repo/pulls/fragments/pullHeader.html
+1
-1
appview/pages/templates/repo/pulls/fragments/pullHeader.html
···
17
17
{{ $icon = "git-merge" }}
18
18
{{ end }}
19
19
20
-
{{ $owner := resolve .Pull.OwnerDid }}
21
20
<section class="mt-2">
22
21
<div class="flex items-center gap-2">
23
22
<div
···
45
44
<span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center">
46
45
{{ if .Pull.IsForkBased }}
47
46
{{ if .Pull.PullSource.Repo }}
47
+
{{ $owner := resolve .Pull.PullSource.Repo.Did }}
48
48
<a href="/{{ $owner }}/{{ .Pull.PullSource.Repo.Name }}" class="no-underline hover:underline">{{ $owner }}/{{ .Pull.PullSource.Repo.Name }}</a>:
49
49
{{- else -}}
50
50
<span class="italic">[deleted fork]</span>
+1
-1
appview/pages/templates/repo/pulls/fragments/pullStack.html
+1
-1
appview/pages/templates/repo/pulls/fragments/pullStack.html
···
52
52
</div>
53
53
{{ end }}
54
54
<div class="{{ if not $isCurrent }} pl-6 {{ end }} flex-grow min-w-0 w-full py-2">
55
-
{{ template "repo/pulls/fragments/summarizedHeader" (list $pull $pipeline) }}
55
+
{{ template "repo/pulls/fragments/summarizedPullHeader" (list $pull $pipeline) }}
56
56
</div>
57
57
</div>
58
58
</a>
+1
-1
appview/pages/templates/repo/pulls/fragments/summarizedPullHeader.html
+1
-1
appview/pages/templates/repo/pulls/fragments/summarizedPullHeader.html
+2
-2
appview/pages/templates/repo/pulls/interdiff.html
+2
-2
appview/pages/templates/repo/pulls/interdiff.html
···
30
30
31
31
{{ define "topbarLayout" }}
32
32
<header class="px-1 col-span-full" style="z-index: 20;">
33
-
{{ template "layouts/topbar" . }}
33
+
{{ template "layouts/fragments/topbar" . }}
34
34
</header>
35
35
{{ end }}
36
36
···
55
55
56
56
{{ define "footerLayout" }}
57
57
<footer class="px-1 col-span-full mt-12">
58
-
{{ template "layouts/footer" . }}
58
+
{{ template "layouts/fragments/footer" . }}
59
59
</footer>
60
60
{{ end }}
61
61
+2
-2
appview/pages/templates/repo/pulls/patch.html
+2
-2
appview/pages/templates/repo/pulls/patch.html
···
36
36
37
37
{{ define "topbarLayout" }}
38
38
<header class="px-1 col-span-full" style="z-index: 20;">
39
-
{{ template "layouts/topbar" . }}
39
+
{{ template "layouts/fragments/topbar" . }}
40
40
</header>
41
41
{{ end }}
42
42
···
61
61
62
62
{{ define "footerLayout" }}
63
63
<footer class="px-1 col-span-full mt-12">
64
-
{{ template "layouts/footer" . }}
64
+
{{ template "layouts/fragments/footer" . }}
65
65
</footer>
66
66
{{ end }}
67
67
+1
-1
appview/pages/templates/repo/pulls/pulls.html
+1
-1
appview/pages/templates/repo/pulls/pulls.html
···
144
144
<a href="/{{ $root.RepoInfo.FullName }}/pulls/{{ $pull.PullId }}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25">
145
145
<div class="flex gap-2 items-center px-6">
146
146
<div class="flex-grow min-w-0 w-full py-2">
147
-
{{ template "repo/pulls/fragments/summarizedHeader" (list $pull $pipeline) }}
147
+
{{ template "repo/pulls/fragments/summarizedPullHeader" (list $pull $pipeline) }}
148
148
</div>
149
149
</div>
150
150
</a>
+6
-1
appview/pages/templates/spindles/fragments/spindleListing.html
+6
-1
appview/pages/templates/spindles/fragments/spindleListing.html
···
30
30
{{ define "spindleRightSide" }}
31
31
<div id="right-side" class="flex gap-2">
32
32
{{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2 text-sm" }}
33
-
{{ if .Verified }}
33
+
34
+
{{ if .NeedsUpgrade }}
35
+
<span class="bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 {{$style}}"> {{ i "shield-alert" "w-4 h-4" }} needs upgrade </span>
36
+
{{ block "spindleRetryButton" . }} {{ end }}
37
+
{{ else if .Verified }}
34
38
<span class="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 {{$style}}">{{ i "shield-check" "w-4 h-4" }} verified</span>
35
39
{{ template "spindles/fragments/addMemberModal" . }}
36
40
{{ else }}
37
41
<span class="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 {{$style}}">{{ i "shield-off" "w-4 h-4" }} unverified</span>
38
42
{{ block "spindleRetryButton" . }} {{ end }}
39
43
{{ end }}
44
+
40
45
{{ block "spindleDeleteButton" . }} {{ end }}
41
46
</div>
42
47
{{ end }}
+10
-9
appview/pages/templates/spindles/index.html
+10
-9
appview/pages/templates/spindles/index.html
···
1
1
{{ define "title" }}spindles{{ end }}
2
2
3
3
{{ define "content" }}
4
-
<div class="px-6 py-4">
5
-
<h1 class="text-xl font-bold dark:text-white">Spindles</h1>
4
+
<div class="px-6 py-4 flex items-center justify-between gap-4 align-bottom">
5
+
<h1 class="text-xl font-bold dark:text-white">Spindles</h1>
6
+
<span class="flex items-center gap-1">
7
+
{{ i "book" "w-3 h-3" }}
8
+
<a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/spindle/hosting.md">docs</a>
9
+
</span>
6
10
</div>
7
11
8
12
<section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
···
15
19
{{ end }}
16
20
17
21
{{ define "about" }}
18
-
<section class="rounded flex flex-col gap-2">
19
-
<p class="dark:text-gray-300">
20
-
Spindles are small CI runners.
21
-
<a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/spindle/hosting.md">
22
-
Checkout the documentation if you're interested in self-hosting.
23
-
</a>
22
+
<section class="rounded flex items-center gap-2">
23
+
<p class="text-gray-500 dark:text-gray-400">
24
+
Spindles are small CI runners.
24
25
</p>
25
-
</section>
26
+
</section>
26
27
{{ end }}
27
28
28
29
{{ define "list" }}
-4
appview/pages/templates/strings/put.html
-4
appview/pages/templates/strings/put.html
-4
appview/pages/templates/strings/string.html
-4
appview/pages/templates/strings/string.html
···
8
8
<meta property="og:description" content="{{ .String.Description }}" />
9
9
{{ end }}
10
10
11
-
{{ define "topbar" }}
12
-
{{ template "layouts/topbar" $ }}
13
-
{{ end }}
14
-
15
11
{{ define "content" }}
16
12
{{ $ownerId := didOrHandle .Owner.DID.String .Owner.Handle.String }}
17
13
<section id="string-header" class="mb-4 py-2 px-6 dark:text-white">
-4
appview/pages/templates/strings/timeline.html
-4
appview/pages/templates/strings/timeline.html
+34
appview/pages/templates/timeline/fragments/hero.html
+34
appview/pages/templates/timeline/fragments/hero.html
···
1
+
{{ define "timeline/fragments/hero" }}
2
+
<div class="mx-auto max-w-[100rem] flex flex-col text-black dark:text-white px-6 py-4 gap-6 items-center md:flex-row">
3
+
<div class="flex flex-col gap-6">
4
+
<h1 class="font-bold text-4xl">tightly-knit<br>social coding.</h1>
5
+
6
+
<p class="text-lg">
7
+
tangled is new social-enabled git collaboration platform built on <a class="underline" href="https://atproto.com/">atproto</a>.
8
+
</p>
9
+
<p class="text-lg">
10
+
we envision a place where developers have complete ownership of their
11
+
code, open source communities can freely self-govern and most
12
+
importantly, coding can be social and fun again.
13
+
</p>
14
+
15
+
<div class="flex gap-6 items-center">
16
+
<a href="/signup" class="no-underline hover:no-underline ">
17
+
<button class="btn-create flex gap-2 px-4 items-center">
18
+
join now {{ i "arrow-right" "size-4" }}
19
+
</button>
20
+
</a>
21
+
</div>
22
+
</div>
23
+
24
+
<figure class="w-full hidden md:block md:w-auto">
25
+
<a href="https://tangled.sh/@tangled.sh/core" class="block">
26
+
<img src="https://assets.tangled.network/hero-repo.png" alt="Screenshot of the Tangled monorepo." class="max-w-md mx-auto md:max-w-none w-full md:w-[30vw] h-auto shadow-sm rounded" />
27
+
</a>
28
+
<figcaption class="text-sm text-gray-600 dark:text-gray-400 mt-2 text-center">
29
+
Monorepo for Tangled, built in the open with the community.
30
+
</figcaption>
31
+
</figure>
32
+
</div>
33
+
{{ end }}
34
+
+116
appview/pages/templates/timeline/fragments/timeline.html
+116
appview/pages/templates/timeline/fragments/timeline.html
···
1
+
{{ define "timeline/fragments/timeline" }}
2
+
<div class="py-4">
3
+
<div class="px-6 pb-4">
4
+
<p class="text-xl font-bold dark:text-white">Timeline</p>
5
+
</div>
6
+
7
+
<div class="flex flex-col gap-4">
8
+
{{ range $i, $e := .Timeline }}
9
+
<div class="relative">
10
+
{{ if ne $i 0 }}
11
+
<div class="absolute left-8 -top-4 w-px h-4 bg-gray-300 dark:bg-gray-600"></div>
12
+
{{ end }}
13
+
{{ with $e }}
14
+
<div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm">
15
+
{{ if .Repo }}
16
+
{{ template "timeline/fragments/repoEvent" (list $ .Repo .Source) }}
17
+
{{ else if .Star }}
18
+
{{ template "timeline/fragments/starEvent" (list $ .Star) }}
19
+
{{ else if .Follow }}
20
+
{{ template "timeline/fragments/followEvent" (list $ .Follow .Profile .FollowStats) }}
21
+
{{ end }}
22
+
</div>
23
+
{{ end }}
24
+
</div>
25
+
{{ end }}
26
+
</div>
27
+
</div>
28
+
{{ end }}
29
+
30
+
{{ define "timeline/fragments/repoEvent" }}
31
+
{{ $root := index . 0 }}
32
+
{{ $repo := index . 1 }}
33
+
{{ $source := index . 2 }}
34
+
{{ $userHandle := resolve $repo.Did }}
35
+
<div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm">
36
+
{{ template "user/fragments/picHandleLink" $repo.Did }}
37
+
{{ with $source }}
38
+
{{ $sourceDid := resolve .Did }}
39
+
forked
40
+
<a href="/{{ $sourceDid }}/{{ .Name }}"class="no-underline hover:underline">
41
+
{{ $sourceDid }}/{{ .Name }}
42
+
</a>
43
+
to
44
+
<a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline">{{ $repo.Name }}</a>
45
+
{{ else }}
46
+
created
47
+
<a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline">
48
+
{{ $repo.Name }}
49
+
</a>
50
+
{{ end }}
51
+
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $repo.Created }}</span>
52
+
</div>
53
+
{{ with $repo }}
54
+
{{ template "user/fragments/repoCard" (list $root . true) }}
55
+
{{ end }}
56
+
{{ end }}
57
+
58
+
{{ define "timeline/fragments/starEvent" }}
59
+
{{ $root := index . 0 }}
60
+
{{ $star := index . 1 }}
61
+
{{ with $star }}
62
+
{{ $starrerHandle := resolve .StarredByDid }}
63
+
{{ $repoOwnerHandle := resolve .Repo.Did }}
64
+
<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">
65
+
{{ template "user/fragments/picHandleLink" $starrerHandle }}
66
+
starred
67
+
<a href="/{{ $repoOwnerHandle }}/{{ .Repo.Name }}" class="no-underline hover:underline">
68
+
{{ $repoOwnerHandle | truncateAt30 }}/{{ .Repo.Name }}
69
+
</a>
70
+
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" .Created }}</span>
71
+
</div>
72
+
{{ with .Repo }}
73
+
{{ template "user/fragments/repoCard" (list $root . true) }}
74
+
{{ end }}
75
+
{{ end }}
76
+
{{ end }}
77
+
78
+
{{ define "timeline/fragments/followEvent" }}
79
+
{{ $root := index . 0 }}
80
+
{{ $follow := index . 1 }}
81
+
{{ $profile := index . 2 }}
82
+
{{ $stat := index . 3 }}
83
+
84
+
{{ $userHandle := resolve $follow.UserDid }}
85
+
{{ $subjectHandle := resolve $follow.SubjectDid }}
86
+
<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">
87
+
{{ template "user/fragments/picHandleLink" $userHandle }}
88
+
followed
89
+
{{ template "user/fragments/picHandleLink" $subjectHandle }}
90
+
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $follow.FollowedAt }}</span>
91
+
</div>
92
+
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4">
93
+
<div class="flex-shrink-0 max-h-full w-24 h-24">
94
+
<img alt="" class="object-cover rounded-full p-2" src="{{ fullAvatar $subjectHandle }}" />
95
+
</div>
96
+
97
+
<div class="flex-1 min-h-0 justify-around flex flex-col">
98
+
<a href="/{{ $subjectHandle }}">
99
+
<span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $subjectHandle | truncateAt30 }}</span>
100
+
</a>
101
+
{{ with $profile }}
102
+
{{ with .Description }}
103
+
<p class="text-sm pb-2 md:pb-2">{{.}}</p>
104
+
{{ end }}
105
+
{{ end }}
106
+
{{ with $stat }}
107
+
<div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
108
+
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
109
+
<span id="followers"><a href="/{{ $subjectHandle }}?tab=followers">{{ .Followers }} followers</a></span>
110
+
<span class="select-none after:content-['ยท']"></span>
111
+
<span id="following"><a href="/{{ $subjectHandle }}?tab=following">{{ .Following }} following</a></span>
112
+
</div>
113
+
{{ end }}
114
+
</div>
115
+
</div>
116
+
{{ end }}
+25
appview/pages/templates/timeline/fragments/trending.html
+25
appview/pages/templates/timeline/fragments/trending.html
···
1
+
{{ define "timeline/fragments/trending" }}
2
+
<div class="w-full md:mx-0 py-4">
3
+
<div class="px-6 pb-4">
4
+
<h3 class="text-xl font-bold dark:text-white flex items-center gap-2">
5
+
Trending
6
+
{{ i "trending-up" "size-4 flex-shrink-0" }}
7
+
</h3>
8
+
</div>
9
+
<div class="flex gap-4 overflow-x-auto scrollbar-hide items-stretch">
10
+
{{ range $index, $repo := .Repos }}
11
+
<div class="flex-none h-full border border-gray-200 dark:border-gray-700 rounded-sm w-96">
12
+
{{ template "user/fragments/repoCard" (list $ $repo true) }}
13
+
</div>
14
+
{{ else }}
15
+
<div class="py-8 px-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-sm">
16
+
<div class="text-sm text-gray-500 dark:text-gray-400 text-center">
17
+
No trending repositories this week
18
+
</div>
19
+
</div>
20
+
{{ end }}
21
+
</div>
22
+
</div>
23
+
{{ end }}
24
+
25
+
+90
appview/pages/templates/timeline/home.html
+90
appview/pages/templates/timeline/home.html
···
1
+
{{ define "title" }}tangled · tightly-knit social coding{{ end }}
2
+
3
+
{{ define "extrameta" }}
4
+
<meta property="og:title" content="timeline ยท tangled" />
5
+
<meta property="og:type" content="object" />
6
+
<meta property="og:url" content="https://tangled.sh" />
7
+
<meta property="og:description" content="tightly-knit social coding" />
8
+
{{ end }}
9
+
10
+
11
+
{{ define "content" }}
12
+
<div class="flex flex-col gap-4">
13
+
{{ template "timeline/fragments/hero" . }}
14
+
{{ template "features" . }}
15
+
{{ template "timeline/fragments/trending" . }}
16
+
{{ template "timeline/fragments/timeline" . }}
17
+
<div class="flex justify-end">
18
+
<a href="/timeline" class="inline-flex items-center gap-2 text-gray-500 dark:text-gray-400">
19
+
view more
20
+
{{ i "arrow-right" "size-4" }}
21
+
</a>
22
+
</div>
23
+
</div>
24
+
{{ end }}
25
+
26
+
27
+
{{ define "feature" }}
28
+
{{ $info := index . 0 }}
29
+
{{ $bullets := index . 1 }}
30
+
<div class="flex flex-col items-center gap-6 md:flex-row md:items-top">
31
+
<div class="flex-1">
32
+
<h2 class="text-2xl font-bold text-black dark:text-white mb-6">{{ $info.title }}</h2>
33
+
<ul class="leading-normal">
34
+
{{ range $bullets }}
35
+
<li><p>{{ escapeHtml . }}</p></li>
36
+
{{ end }}
37
+
</ul>
38
+
</div>
39
+
<div class="flex-shrink-0 w-96 md:w-1/3">
40
+
<a href="{{ $info.image }}">
41
+
<img src="{{ $info.image }}" alt="{{ $info.alt }}" class="w-full h-auto rounded shadow-sm" />
42
+
</a>
43
+
</div>
44
+
</div>
45
+
{{ end }}
46
+
47
+
{{ define "features" }}
48
+
<div class="prose dark:text-gray-200 space-y-12 px-6 py-4 bg-white dark:bg-gray-800 rounded drop-shadow-sm">
49
+
{{ template "feature" (list
50
+
(dict
51
+
"title" "lightweight git repo hosting"
52
+
"image" "https://assets.tangled.network/what-is-tangled-repo.png"
53
+
"alt" "A repository hosted on Tangled"
54
+
)
55
+
(list
56
+
"Host your repositories on your own infrastructure using <em>knots</em>—tiny, headless servers that facilitate git operations."
57
+
"Add friends to your knot or invite collaborators to your repository."
58
+
"Guarded by fine-grained role-based access control."
59
+
"Use SSH to push and pull."
60
+
)
61
+
) }}
62
+
63
+
{{ template "feature" (list
64
+
(dict
65
+
"title" "improved pull request model"
66
+
"image" "https://assets.tangled.network/pulls.png"
67
+
"alt" "Round-based pull requests."
68
+
)
69
+
(list
70
+
"An intuitive and effective round-based pull request flow, with inter-diffing between rounds."
71
+
"Stacked pull requests using Jujutsu's change IDs."
72
+
"Paste a <code>git diff</code> or <code>git format-patch</code> for quick drive-by changes."
73
+
)
74
+
) }}
75
+
76
+
{{ template "feature" (list
77
+
(dict
78
+
"title" "run pipelines using spindles"
79
+
"image" "https://assets.tangled.network/pipelines.png"
80
+
"alt" "CI pipeline running on spindle"
81
+
)
82
+
(list
83
+
"Run pipelines on your own infrastructure using <em>spindles</em>—lightweight CI runners."
84
+
"Natively supports Nix for package management."
85
+
"Easily extended to support different execution backends."
86
+
)
87
+
) }}
88
+
</div>
89
+
{{ end }}
90
+
+6
-171
appview/pages/templates/timeline/timeline.html
+6
-171
appview/pages/templates/timeline/timeline.html
···
8
8
{{ end }}
9
9
10
10
{{ define "content" }}
11
-
{{ if .LoggedInUser }}
12
-
{{ else }}
13
-
{{ block "hero" $ }}{{ end }}
14
-
{{ end }}
11
+
{{ if .LoggedInUser }}
12
+
{{ else }}
13
+
{{ template "timeline/fragments/hero" . }}
14
+
{{ end }}
15
15
16
-
{{ block "trending" $ }}{{ end }}
17
-
{{ block "timeline" $ }}{{ end }}
18
-
{{ end }}
19
-
20
-
{{ define "hero" }}
21
-
<div class="flex flex-col text-black dark:text-white p-6 gap-6 max-w-xl">
22
-
<div class="font-bold text-4xl">tightly-knit<br>social coding.</div>
23
-
24
-
<p class="text-lg">
25
-
tangled is new social-enabled git collaboration platform built on <a class="underline" href="https://atproto.com/">atproto</a>.
26
-
</p>
27
-
<p class="text-lg">
28
-
we envision a place where developers have complete ownership of their
29
-
code, open source communities can freely self-govern and most
30
-
importantly, coding can be social and fun again.
31
-
</p>
32
-
33
-
<div class="flex gap-6 items-center">
34
-
<a href="/signup" class="no-underline hover:no-underline ">
35
-
<button class="btn-create flex gap-2 px-4 items-center">
36
-
join now {{ i "arrow-right" "size-4" }}
37
-
</button>
38
-
</a>
39
-
</div>
40
-
</div>
41
-
{{ end }}
42
-
43
-
{{ define "trending" }}
44
-
<div class="w-full md:mx-0 py-4">
45
-
<div class="px-6 pb-4">
46
-
<h3 class="text-xl font-bold dark:text-white flex items-center gap-2">
47
-
Trending
48
-
{{ i "trending-up" "size-4 flex-shrink-0" }}
49
-
</h3>
50
-
</div>
51
-
<div class="flex gap-4 overflow-x-auto scrollbar-hide items-stretch">
52
-
{{ range $index, $repo := .Repos }}
53
-
<div class="flex-none h-full border border-gray-200 dark:border-gray-700 rounded-sm w-96">
54
-
{{ template "user/fragments/repoCard" (list $ $repo true) }}
55
-
</div>
56
-
{{ else }}
57
-
<div class="py-8 px-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-sm">
58
-
<div class="text-sm text-gray-500 dark:text-gray-400 text-center">
59
-
No trending repositories this week
60
-
</div>
61
-
</div>
62
-
{{ end }}
63
-
</div>
64
-
</div>
65
-
{{ end }}
66
-
67
-
{{ define "timeline" }}
68
-
<div class="py-4">
69
-
<div class="px-6 pb-4">
70
-
<p class="text-xl font-bold dark:text-white">Timeline</p>
71
-
</div>
72
-
73
-
<div class="flex flex-col gap-4">
74
-
{{ range $i, $e := .Timeline }}
75
-
<div class="relative">
76
-
{{ if ne $i 0 }}
77
-
<div class="absolute left-8 -top-4 w-px h-4 bg-gray-300 dark:bg-gray-600"></div>
78
-
{{ end }}
79
-
{{ with $e }}
80
-
<div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm">
81
-
{{ if .Repo }}
82
-
{{ block "repoEvent" (list $ .Repo .Source) }} {{ end }}
83
-
{{ else if .Star }}
84
-
{{ block "starEvent" (list $ .Star) }} {{ end }}
85
-
{{ else if .Follow }}
86
-
{{ block "followEvent" (list $ .Follow .Profile .FollowStats) }} {{ end }}
87
-
{{ end }}
88
-
</div>
89
-
{{ end }}
90
-
</div>
91
-
{{ end }}
92
-
</div>
93
-
</div>
94
-
{{ end }}
95
-
96
-
{{ define "repoEvent" }}
97
-
{{ $root := index . 0 }}
98
-
{{ $repo := index . 1 }}
99
-
{{ $source := index . 2 }}
100
-
{{ $userHandle := resolve $repo.Did }}
101
-
<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">
102
-
{{ template "user/fragments/picHandleLink" $repo.Did }}
103
-
{{ with $source }}
104
-
{{ $sourceDid := resolve .Did }}
105
-
forked
106
-
<a href="/{{ $sourceDid }}/{{ .Name }}"class="no-underline hover:underline">
107
-
{{ $sourceDid }}/{{ .Name }}
108
-
</a>
109
-
to
110
-
<a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline">{{ $repo.Name }}</a>
111
-
{{ else }}
112
-
created
113
-
<a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline">
114
-
{{ $repo.Name }}
115
-
</a>
116
-
{{ end }}
117
-
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $repo.Created }}</span>
118
-
</div>
119
-
{{ with $repo }}
120
-
{{ template "user/fragments/repoCard" (list $root . true) }}
121
-
{{ end }}
122
-
{{ end }}
123
-
124
-
{{ define "starEvent" }}
125
-
{{ $root := index . 0 }}
126
-
{{ $star := index . 1 }}
127
-
{{ with $star }}
128
-
{{ $starrerHandle := resolve .StarredByDid }}
129
-
{{ $repoOwnerHandle := resolve .Repo.Did }}
130
-
<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">
131
-
{{ template "user/fragments/picHandleLink" $starrerHandle }}
132
-
starred
133
-
<a href="/{{ $repoOwnerHandle }}/{{ .Repo.Name }}" class="no-underline hover:underline">
134
-
{{ $repoOwnerHandle | truncateAt30 }}/{{ .Repo.Name }}
135
-
</a>
136
-
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" .Created }}</span>
137
-
</div>
138
-
{{ with .Repo }}
139
-
{{ template "user/fragments/repoCard" (list $root . true) }}
140
-
{{ end }}
141
-
{{ end }}
142
-
{{ end }}
143
-
144
-
145
-
{{ define "followEvent" }}
146
-
{{ $root := index . 0 }}
147
-
{{ $follow := index . 1 }}
148
-
{{ $profile := index . 2 }}
149
-
{{ $stat := index . 3 }}
150
-
151
-
{{ $userHandle := resolve $follow.UserDid }}
152
-
{{ $subjectHandle := resolve $follow.SubjectDid }}
153
-
<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">
154
-
{{ template "user/fragments/picHandleLink" $userHandle }}
155
-
followed
156
-
{{ template "user/fragments/picHandleLink" $subjectHandle }}
157
-
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $follow.FollowedAt }}</span>
158
-
</div>
159
-
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4">
160
-
<div class="flex-shrink-0 max-h-full w-24 h-24">
161
-
<img class="object-cover rounded-full p-2" src="{{ fullAvatar $subjectHandle }}" />
162
-
</div>
163
-
164
-
<div class="flex-1 min-h-0 justify-around flex flex-col">
165
-
<a href="/{{ $subjectHandle }}">
166
-
<span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $subjectHandle | truncateAt30 }}</span>
167
-
</a>
168
-
{{ with $profile }}
169
-
{{ with .Description }}
170
-
<p class="text-sm pb-2 md:pb-2">{{.}}</p>
171
-
{{ end }}
172
-
{{ end }}
173
-
{{ with $stat }}
174
-
<div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
175
-
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
176
-
<span id="followers"><a href="/{{ $subjectHandle }}?tab=followers">{{ .Followers }} followers</a></span>
177
-
<span class="select-none after:content-['ยท']"></span>
178
-
<span id="following"><a href="/{{ $subjectHandle }}?tab=following">{{ .Following }} following</a></span>
179
-
</div>
180
-
{{ end }}
181
-
</div>
182
-
</div>
16
+
{{ template "timeline/fragments/trending" . }}
17
+
{{ template "timeline/fragments/timeline" . }}
183
18
{{ end }}
+2
-4
appview/pages/templates/user/completeSignup.html
+2
-4
appview/pages/templates/user/completeSignup.html
···
29
29
</head>
30
30
<body class="flex items-center justify-center min-h-screen">
31
31
<main class="max-w-md px-6 -mt-4">
32
-
<h1
33
-
class="text-center text-2xl font-semibold italic dark:text-white"
34
-
>
35
-
tangled
32
+
<h1 class="flex place-content-center text-2xl font-semibold italic dark:text-white" >
33
+
{{ template "fragments/logotype" }}
36
34
</h1>
37
35
<h2 class="text-center text-xl italic dark:text-white">
38
36
tightly-knit social coding.
+4
-16
appview/pages/templates/user/followers.html
+4
-16
appview/pages/templates/user/followers.html
···
1
1
{{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท followers {{ end }}
2
2
3
-
{{ define "extrameta" }}
4
-
<meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}'s followers" />
5
-
<meta property="og:type" content="object" />
6
-
<meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}?tab=followers" />
7
-
<meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" />
8
-
{{ end }}
9
-
10
-
{{ define "content" }}
11
-
<div class="grid grid-cols-1 md:grid-cols-11 gap-4">
12
-
<div class="md:col-span-3 order-1 md:order-1">
13
-
{{ template "user/fragments/profileCard" .Card }}
14
-
</div>
15
-
<div id="all-followers" class="md:col-span-8 order-2 md:order-2">
16
-
{{ block "followers" . }}{{ end }}
17
-
</div>
18
-
</div>
3
+
{{ define "profileContent" }}
4
+
<div id="all-followers" class="md:col-span-8 order-2 md:order-2">
5
+
{{ block "followers" . }}{{ end }}
6
+
</div>
19
7
{{ end }}
20
8
21
9
{{ define "followers" }}
+4
-16
appview/pages/templates/user/following.html
+4
-16
appview/pages/templates/user/following.html
···
1
1
{{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท following {{ end }}
2
2
3
-
{{ define "extrameta" }}
4
-
<meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}'s following" />
5
-
<meta property="og:type" content="object" />
6
-
<meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}?tab=following" />
7
-
<meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" />
8
-
{{ end }}
9
-
10
-
{{ define "content" }}
11
-
<div class="grid grid-cols-1 md:grid-cols-11 gap-4">
12
-
<div class="md:col-span-3 order-1 md:order-1">
13
-
{{ template "user/fragments/profileCard" .Card }}
14
-
</div>
15
-
<div id="all-following" class="md:col-span-8 order-2 md:order-2">
16
-
{{ block "following" . }}{{ end }}
17
-
</div>
18
-
</div>
3
+
{{ define "profileContent" }}
4
+
<div id="all-following" class="md:col-span-8 order-2 md:order-2">
5
+
{{ block "following" . }}{{ end }}
6
+
</div>
19
7
{{ end }}
20
8
21
9
{{ define "following" }}
+1
-1
appview/pages/templates/user/fragments/editBio.html
+1
-1
appview/pages/templates/user/fragments/editBio.html
+1
-1
appview/pages/templates/user/fragments/picHandle.html
+1
-1
appview/pages/templates/user/fragments/picHandle.html
+2
-4
appview/pages/templates/user/fragments/profileCard.html
+2
-4
appview/pages/templates/user/fragments/profileCard.html
···
1
1
{{ define "user/fragments/profileCard" }}
2
2
{{ $userIdent := didOrHandle .UserDid .UserHandle }}
3
-
<div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm max-h-fit">
4
3
<div class="grid grid-cols-3 md:grid-cols-1 gap-1 items-center">
5
4
<div id="avatar" class="col-span-1 flex justify-center items-center">
6
5
<div class="w-3/4 aspect-square relative">
···
85
84
<div id="update-profile" class="text-red-400 dark:text-red-500"></div>
86
85
</div>
87
86
</div>
88
-
</div>
89
87
{{ end }}
90
88
91
89
{{ define "followerFollowing" }}
···
94
92
{{ with $root }}
95
93
<div class="flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm">
96
94
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
97
-
<span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .FollowersCount }} followers</a></span>
95
+
<span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .Stats.FollowersCount }} followers</a></span>
98
96
<span class="select-none after:content-['ยท']"></span>
99
-
<span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .FollowingCount }} following</a></span>
97
+
<span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .Stats.FollowingCount }} following</a></span>
100
98
</div>
101
99
{{ end }}
102
100
{{ end }}
+1
-2
appview/pages/templates/user/fragments/repoCard.html
+1
-2
appview/pages/templates/user/fragments/repoCard.html
···
36
36
<div class="text-gray-400 text-sm font-mono inline-flex gap-4 mt-auto">
37
37
{{ with .Language }}
38
38
<div class="flex gap-2 items-center text-sm">
39
-
<div class="size-2 rounded-full"
40
-
style="background: radial-gradient(circle at 35% 35%, color-mix(in srgb, {{ langColor . }} 70%, white), {{ langColor . }} 30%, color-mix(in srgb, {{ langColor . }} 85%, black));"></div>
39
+
{{ template "repo/fragments/languageBall" . }}
41
40
<span>{{ . }}</span>
42
41
</div>
43
42
{{ end }}
+2
-2
appview/pages/templates/user/login.html
+2
-2
appview/pages/templates/user/login.html
···
13
13
</head>
14
14
<body class="flex items-center justify-center min-h-screen">
15
15
<main class="max-w-md px-6 -mt-4">
16
-
<h1 class="text-center text-2xl font-semibold italic dark:text-white" >
17
-
tangled
16
+
<h1 class="flex place-content-center text-2xl font-semibold italic dark:text-white" >
17
+
{{ template "fragments/logotype" }}
18
18
</h1>
19
19
<h2 class="text-center text-xl italic dark:text-white">
20
20
tightly-knit social coding.
+269
appview/pages/templates/user/overview.html
+269
appview/pages/templates/user/overview.html
···
1
+
{{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }}{{ end }}
2
+
3
+
{{ define "profileContent" }}
4
+
<div id="all-repos" class="md:col-span-4 order-2 md:order-2">
5
+
<div class="grid grid-cols-1 gap-4">
6
+
{{ block "ownRepos" . }}{{ end }}
7
+
{{ block "collaboratingRepos" . }}{{ end }}
8
+
</div>
9
+
</div>
10
+
<div class="md:col-span-4 order-3 md:order-3">
11
+
{{ block "profileTimeline" . }}{{ end }}
12
+
</div>
13
+
{{ end }}
14
+
15
+
{{ define "profileTimeline" }}
16
+
<p class="text-sm font-bold px-2 pb-4 dark:text-white">ACTIVITY</p>
17
+
<div class="flex flex-col gap-4 relative">
18
+
{{ if .ProfileTimeline.IsEmpty }}
19
+
<p class="dark:text-white">This user does not have any activity yet.</p>
20
+
{{ end }}
21
+
22
+
{{ with .ProfileTimeline }}
23
+
{{ range $idx, $byMonth := .ByMonth }}
24
+
{{ with $byMonth }}
25
+
{{ if not .IsEmpty }}
26
+
<div class="border border-gray-200 dark:border-gray-700 rounded-sm py-4 px-6">
27
+
<p class="text-sm font-mono mb-2 text-gray-500 dark:text-gray-400">
28
+
{{ if eq $idx 0 }}
29
+
this month
30
+
{{ else }}
31
+
{{$idx}} month{{if ne $idx 1}}s{{end}} ago
32
+
{{ end }}
33
+
</p>
34
+
35
+
<div class="flex flex-col gap-1">
36
+
{{ block "repoEvents" .RepoEvents }} {{ end }}
37
+
{{ block "issueEvents" .IssueEvents }} {{ end }}
38
+
{{ block "pullEvents" .PullEvents }} {{ end }}
39
+
</div>
40
+
</div>
41
+
{{ end }}
42
+
{{ end }}
43
+
{{ end }}
44
+
{{ end }}
45
+
</div>
46
+
{{ end }}
47
+
48
+
{{ define "repoEvents" }}
49
+
{{ if gt (len .) 0 }}
50
+
<details>
51
+
<summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400">
52
+
<div class="flex flex-wrap items-center gap-2">
53
+
{{ i "book-plus" "w-4 h-4" }}
54
+
created {{ len . }} {{if eq (len .) 1 }}repository{{else}}repositories{{end}}
55
+
</div>
56
+
</summary>
57
+
<div class="py-2 text-sm flex flex-col gap-3 mb-2">
58
+
{{ range . }}
59
+
<div class="flex flex-wrap items-center justify-between gap-2">
60
+
<span class="flex items-center gap-2">
61
+
<span class="text-gray-500 dark:text-gray-400">
62
+
{{ if .Source }}
63
+
{{ i "git-fork" "w-4 h-4" }}
64
+
{{ else }}
65
+
{{ i "book-plus" "w-4 h-4" }}
66
+
{{ end }}
67
+
</span>
68
+
<a href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}" class="no-underline hover:underline">
69
+
{{- .Repo.Name -}}
70
+
</a>
71
+
</span>
72
+
73
+
{{ with .Repo.RepoStats }}
74
+
{{ with .Language }}
75
+
<div class="flex gap-2 items-center text-xs font-mono text-gray-400 ">
76
+
{{ template "repo/fragments/languageBall" . }}
77
+
<span>{{ . }}</span>
78
+
</div>
79
+
{{end }}
80
+
{{end }}
81
+
</div>
82
+
{{ end }}
83
+
</div>
84
+
</details>
85
+
{{ end }}
86
+
{{ end }}
87
+
88
+
{{ define "issueEvents" }}
89
+
{{ $items := .Items }}
90
+
{{ $stats := .Stats }}
91
+
92
+
{{ if gt (len $items) 0 }}
93
+
<details>
94
+
<summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400">
95
+
<div class="flex flex-wrap items-center gap-2">
96
+
{{ i "circle-dot" "w-4 h-4" }}
97
+
98
+
<div>
99
+
created {{ len $items }} {{if eq (len $items) 1 }}issue{{else}}issues{{end}}
100
+
</div>
101
+
102
+
{{ if gt $stats.Open 0 }}
103
+
<span class="px-2 py-1/2 text-sm rounded text-white bg-green-600 dark:bg-green-700">
104
+
{{$stats.Open}} open
105
+
</span>
106
+
{{ end }}
107
+
108
+
{{ if gt $stats.Closed 0 }}
109
+
<span class="px-2 py-1/2 text-sm rounded text-white bg-gray-800 dark:bg-gray-700">
110
+
{{$stats.Closed}} closed
111
+
</span>
112
+
{{ end }}
113
+
114
+
</div>
115
+
</summary>
116
+
<div class="py-2 text-sm flex flex-col gap-3 mb-2">
117
+
{{ range $items }}
118
+
{{ $repoOwner := resolve .Repo.Did }}
119
+
{{ $repoName := .Repo.Name }}
120
+
{{ $repoUrl := printf "%s/%s" $repoOwner $repoName }}
121
+
122
+
<div class="flex gap-2 text-gray-600 dark:text-gray-300">
123
+
{{ if .Open }}
124
+
<span class="text-green-600 dark:text-green-500">
125
+
{{ i "circle-dot" "w-4 h-4" }}
126
+
</span>
127
+
{{ else }}
128
+
<span class="text-gray-500 dark:text-gray-400">
129
+
{{ i "ban" "w-4 h-4" }}
130
+
</span>
131
+
{{ end }}
132
+
<div class="flex-none min-w-8 text-right">
133
+
<span class="text-gray-500 dark:text-gray-400">#{{ .IssueId }}</span>
134
+
</div>
135
+
<div class="break-words max-w-full">
136
+
<a href="/{{$repoUrl}}/issues/{{ .IssueId }}" class="no-underline hover:underline">
137
+
{{ .Title -}}
138
+
</a>
139
+
on
140
+
<a href="/{{$repoUrl}}" class="no-underline hover:underline whitespace-nowrap">
141
+
{{$repoUrl}}
142
+
</a>
143
+
</div>
144
+
</div>
145
+
{{ end }}
146
+
</div>
147
+
</details>
148
+
{{ end }}
149
+
{{ end }}
150
+
151
+
{{ define "pullEvents" }}
152
+
{{ $items := .Items }}
153
+
{{ $stats := .Stats }}
154
+
{{ if gt (len $items) 0 }}
155
+
<details>
156
+
<summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400">
157
+
<div class="flex flex-wrap items-center gap-2">
158
+
{{ i "git-pull-request" "w-4 h-4" }}
159
+
160
+
<div>
161
+
created {{ len $items }} {{if eq (len $items) 1 }}pull request{{else}}pull requests{{end}}
162
+
</div>
163
+
164
+
{{ if gt $stats.Open 0 }}
165
+
<span class="px-2 py-1/2 text-sm rounded text-white bg-green-600 dark:bg-green-700">
166
+
{{$stats.Open}} open
167
+
</span>
168
+
{{ end }}
169
+
170
+
{{ if gt $stats.Merged 0 }}
171
+
<span class="px-2 py-1/2 text-sm rounded text-white bg-purple-600 dark:bg-purple-700">
172
+
{{$stats.Merged}} merged
173
+
</span>
174
+
{{ end }}
175
+
176
+
177
+
{{ if gt $stats.Closed 0 }}
178
+
<span class="px-2 py-1/2 text-sm rounded text-white bg-gray-800 dark:bg-gray-700">
179
+
{{$stats.Closed}} closed
180
+
</span>
181
+
{{ end }}
182
+
183
+
</div>
184
+
</summary>
185
+
<div class="py-2 text-sm flex flex-col gap-3 mb-2">
186
+
{{ range $items }}
187
+
{{ $repoOwner := resolve .Repo.Did }}
188
+
{{ $repoName := .Repo.Name }}
189
+
{{ $repoUrl := printf "%s/%s" $repoOwner $repoName }}
190
+
191
+
<div class="flex gap-2 text-gray-600 dark:text-gray-300">
192
+
{{ if .State.IsOpen }}
193
+
<span class="text-green-600 dark:text-green-500">
194
+
{{ i "git-pull-request" "w-4 h-4" }}
195
+
</span>
196
+
{{ else if .State.IsMerged }}
197
+
<span class="text-purple-600 dark:text-purple-500">
198
+
{{ i "git-merge" "w-4 h-4" }}
199
+
</span>
200
+
{{ else }}
201
+
<span class="text-gray-600 dark:text-gray-300">
202
+
{{ i "git-pull-request-closed" "w-4 h-4" }}
203
+
</span>
204
+
{{ end }}
205
+
<div class="flex-none min-w-8 text-right">
206
+
<span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span>
207
+
</div>
208
+
<div class="break-words max-w-full">
209
+
<a href="/{{$repoUrl}}/pulls/{{ .PullId }}" class="no-underline hover:underline">
210
+
{{ .Title -}}
211
+
</a>
212
+
on
213
+
<a href="/{{$repoUrl}}" class="no-underline hover:underline whitespace-nowrap">
214
+
{{$repoUrl}}
215
+
</a>
216
+
</div>
217
+
</div>
218
+
{{ end }}
219
+
</div>
220
+
</details>
221
+
{{ end }}
222
+
{{ end }}
223
+
224
+
{{ define "ownRepos" }}
225
+
<div>
226
+
<div class="text-sm font-bold px-2 pb-4 dark:text-white flex items-center gap-2">
227
+
<a href="/@{{ or $.Card.UserHandle $.Card.UserDid }}?tab=repos"
228
+
class="flex text-black dark:text-white items-center gap-2 no-underline hover:no-underline group">
229
+
<span>PINNED REPOS</span>
230
+
</a>
231
+
{{ if and .LoggedInUser (eq .LoggedInUser.Did .Card.UserDid) }}
232
+
<button
233
+
hx-get="profile/edit-pins"
234
+
hx-target="#all-repos"
235
+
class="py-0 font-normal text-sm flex gap-2 items-center group">
236
+
{{ i "pencil" "w-3 h-3" }}
237
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
238
+
</button>
239
+
{{ end }}
240
+
</div>
241
+
<div id="repos" class="grid grid-cols-1 gap-4 items-stretch">
242
+
{{ range .Repos }}
243
+
<div class="border border-gray-200 dark:border-gray-700 rounded-sm">
244
+
{{ template "user/fragments/repoCard" (list $ . false) }}
245
+
</div>
246
+
{{ else }}
247
+
<p class="dark:text-white">This user does not have any pinned repos.</p>
248
+
{{ end }}
249
+
</div>
250
+
</div>
251
+
{{ end }}
252
+
253
+
{{ define "collaboratingRepos" }}
254
+
{{ if gt (len .CollaboratingRepos) 0 }}
255
+
<div>
256
+
<p class="text-sm font-bold px-2 pb-4 dark:text-white">COLLABORATING ON</p>
257
+
<div id="collaborating" class="grid grid-cols-1 gap-4">
258
+
{{ range .CollaboratingRepos }}
259
+
<div class="border border-gray-200 dark:border-gray-700 rounded-sm">
260
+
{{ template "user/fragments/repoCard" (list $ . true) }}
261
+
</div>
262
+
{{ else }}
263
+
<p class="px-6 dark:text-white">This user is not collaborating.</p>
264
+
{{ end }}
265
+
</div>
266
+
</div>
267
+
{{ end }}
268
+
{{ end }}
269
+
-318
appview/pages/templates/user/profile.html
-318
appview/pages/templates/user/profile.html
···
1
-
{{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }}{{ end }}
2
-
3
-
{{ define "extrameta" }}
4
-
<meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" />
5
-
<meta property="og:type" content="profile" />
6
-
<meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}" />
7
-
<meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" />
8
-
{{ end }}
9
-
10
-
{{ define "content" }}
11
-
<div class="grid grid-cols-1 md:grid-cols-11 gap-4">
12
-
<div class="md:col-span-3 order-1 md:order-1">
13
-
<div class="grid grid-cols-1 gap-4">
14
-
{{ template "user/fragments/profileCard" .Card }}
15
-
{{ block "punchcard" .Punchcard }} {{ end }}
16
-
</div>
17
-
</div>
18
-
<div id="all-repos" class="md:col-span-4 order-2 md:order-2">
19
-
<div class="grid grid-cols-1 gap-4">
20
-
{{ block "ownRepos" . }}{{ end }}
21
-
{{ block "collaboratingRepos" . }}{{ end }}
22
-
</div>
23
-
</div>
24
-
<div class="md:col-span-4 order-3 md:order-3">
25
-
{{ block "profileTimeline" . }}{{ end }}
26
-
</div>
27
-
</div>
28
-
{{ end }}
29
-
30
-
{{ define "profileTimeline" }}
31
-
<p class="text-sm font-bold p-2 dark:text-white">ACTIVITY</p>
32
-
<div class="flex flex-col gap-4 relative">
33
-
{{ with .ProfileTimeline }}
34
-
{{ range $idx, $byMonth := .ByMonth }}
35
-
{{ with $byMonth }}
36
-
<div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm">
37
-
{{ if eq $idx 0 }}
38
-
39
-
{{ else }}
40
-
{{ $s := "s" }}
41
-
{{ if eq $idx 1 }}
42
-
{{ $s = "" }}
43
-
{{ end }}
44
-
<p class="text-sm font-bold dark:text-white mb-2">{{$idx}} month{{$s}} ago</p>
45
-
{{ end }}
46
-
47
-
{{ if .IsEmpty }}
48
-
<div class="text-gray-500 dark:text-gray-400">
49
-
No activity for this month
50
-
</div>
51
-
{{ else }}
52
-
<div class="flex flex-col gap-1">
53
-
{{ block "repoEvents" .RepoEvents }} {{ end }}
54
-
{{ block "issueEvents" .IssueEvents }} {{ end }}
55
-
{{ block "pullEvents" .PullEvents }} {{ end }}
56
-
</div>
57
-
{{ end }}
58
-
</div>
59
-
60
-
{{ end }}
61
-
{{ else }}
62
-
<p class="dark:text-white">This user does not have any activity yet.</p>
63
-
{{ end }}
64
-
{{ end }}
65
-
</div>
66
-
{{ end }}
67
-
68
-
{{ define "repoEvents" }}
69
-
{{ if gt (len .) 0 }}
70
-
<details>
71
-
<summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400">
72
-
<div class="flex flex-wrap items-center gap-2">
73
-
{{ i "book-plus" "w-4 h-4" }}
74
-
created {{ len . }} {{if eq (len .) 1 }}repository{{else}}repositories{{end}}
75
-
</div>
76
-
</summary>
77
-
<div class="py-2 text-sm flex flex-col gap-3 mb-2">
78
-
{{ range . }}
79
-
<div class="flex flex-wrap items-center gap-2">
80
-
<span class="text-gray-500 dark:text-gray-400">
81
-
{{ if .Source }}
82
-
{{ i "git-fork" "w-4 h-4" }}
83
-
{{ else }}
84
-
{{ i "book-plus" "w-4 h-4" }}
85
-
{{ end }}
86
-
</span>
87
-
<a href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}" class="no-underline hover:underline">
88
-
{{- .Repo.Name -}}
89
-
</a>
90
-
</div>
91
-
{{ end }}
92
-
</div>
93
-
</details>
94
-
{{ end }}
95
-
{{ end }}
96
-
97
-
{{ define "issueEvents" }}
98
-
{{ $items := .Items }}
99
-
{{ $stats := .Stats }}
100
-
101
-
{{ if gt (len $items) 0 }}
102
-
<details>
103
-
<summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400">
104
-
<div class="flex flex-wrap items-center gap-2">
105
-
{{ i "circle-dot" "w-4 h-4" }}
106
-
107
-
<div>
108
-
created {{ len $items }} {{if eq (len $items) 1 }}issue{{else}}issues{{end}}
109
-
</div>
110
-
111
-
{{ if gt $stats.Open 0 }}
112
-
<span class="px-2 py-1/2 text-sm rounded text-white bg-green-600 dark:bg-green-700">
113
-
{{$stats.Open}} open
114
-
</span>
115
-
{{ end }}
116
-
117
-
{{ if gt $stats.Closed 0 }}
118
-
<span class="px-2 py-1/2 text-sm rounded text-white bg-gray-800 dark:bg-gray-700">
119
-
{{$stats.Closed}} closed
120
-
</span>
121
-
{{ end }}
122
-
123
-
</div>
124
-
</summary>
125
-
<div class="py-2 text-sm flex flex-col gap-3 mb-2">
126
-
{{ range $items }}
127
-
{{ $repoOwner := resolve .Metadata.Repo.Did }}
128
-
{{ $repoName := .Metadata.Repo.Name }}
129
-
{{ $repoUrl := printf "%s/%s" $repoOwner $repoName }}
130
-
131
-
<div class="flex gap-2 text-gray-600 dark:text-gray-300">
132
-
{{ if .Open }}
133
-
<span class="text-green-600 dark:text-green-500">
134
-
{{ i "circle-dot" "w-4 h-4" }}
135
-
</span>
136
-
{{ else }}
137
-
<span class="text-gray-500 dark:text-gray-400">
138
-
{{ i "ban" "w-4 h-4" }}
139
-
</span>
140
-
{{ end }}
141
-
<div class="flex-none min-w-8 text-right">
142
-
<span class="text-gray-500 dark:text-gray-400">#{{ .IssueId }}</span>
143
-
</div>
144
-
<div class="break-words max-w-full">
145
-
<a href="/{{$repoUrl}}/issues/{{ .IssueId }}" class="no-underline hover:underline">
146
-
{{ .Title -}}
147
-
</a>
148
-
on
149
-
<a href="/{{$repoUrl}}" class="no-underline hover:underline whitespace-nowrap">
150
-
{{$repoUrl}}
151
-
</a>
152
-
</div>
153
-
</div>
154
-
{{ end }}
155
-
</div>
156
-
</details>
157
-
{{ end }}
158
-
{{ end }}
159
-
160
-
{{ define "pullEvents" }}
161
-
{{ $items := .Items }}
162
-
{{ $stats := .Stats }}
163
-
{{ if gt (len $items) 0 }}
164
-
<details>
165
-
<summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400">
166
-
<div class="flex flex-wrap items-center gap-2">
167
-
{{ i "git-pull-request" "w-4 h-4" }}
168
-
169
-
<div>
170
-
created {{ len $items }} {{if eq (len $items) 1 }}pull request{{else}}pull requests{{end}}
171
-
</div>
172
-
173
-
{{ if gt $stats.Open 0 }}
174
-
<span class="px-2 py-1/2 text-sm rounded text-white bg-green-600 dark:bg-green-700">
175
-
{{$stats.Open}} open
176
-
</span>
177
-
{{ end }}
178
-
179
-
{{ if gt $stats.Merged 0 }}
180
-
<span class="px-2 py-1/2 text-sm rounded text-white bg-purple-600 dark:bg-purple-700">
181
-
{{$stats.Merged}} merged
182
-
</span>
183
-
{{ end }}
184
-
185
-
186
-
{{ if gt $stats.Closed 0 }}
187
-
<span class="px-2 py-1/2 text-sm rounded text-white bg-gray-800 dark:bg-gray-700">
188
-
{{$stats.Closed}} closed
189
-
</span>
190
-
{{ end }}
191
-
192
-
</div>
193
-
</summary>
194
-
<div class="py-2 text-sm flex flex-col gap-3 mb-2">
195
-
{{ range $items }}
196
-
{{ $repoOwner := resolve .Repo.Did }}
197
-
{{ $repoName := .Repo.Name }}
198
-
{{ $repoUrl := printf "%s/%s" $repoOwner $repoName }}
199
-
200
-
<div class="flex gap-2 text-gray-600 dark:text-gray-300">
201
-
{{ if .State.IsOpen }}
202
-
<span class="text-green-600 dark:text-green-500">
203
-
{{ i "git-pull-request" "w-4 h-4" }}
204
-
</span>
205
-
{{ else if .State.IsMerged }}
206
-
<span class="text-purple-600 dark:text-purple-500">
207
-
{{ i "git-merge" "w-4 h-4" }}
208
-
</span>
209
-
{{ else }}
210
-
<span class="text-gray-600 dark:text-gray-300">
211
-
{{ i "git-pull-request-closed" "w-4 h-4" }}
212
-
</span>
213
-
{{ end }}
214
-
<div class="flex-none min-w-8 text-right">
215
-
<span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span>
216
-
</div>
217
-
<div class="break-words max-w-full">
218
-
<a href="/{{$repoUrl}}/pulls/{{ .PullId }}" class="no-underline hover:underline">
219
-
{{ .Title -}}
220
-
</a>
221
-
on
222
-
<a href="/{{$repoUrl}}" class="no-underline hover:underline whitespace-nowrap">
223
-
{{$repoUrl}}
224
-
</a>
225
-
</div>
226
-
</div>
227
-
{{ end }}
228
-
</div>
229
-
</details>
230
-
{{ end }}
231
-
{{ end }}
232
-
233
-
{{ define "ownRepos" }}
234
-
<div>
235
-
<div class="text-sm font-bold p-2 pr-0 dark:text-white flex items-center justify-between gap-2">
236
-
<a href="/@{{ or $.Card.UserHandle $.Card.UserDid }}?tab=repos"
237
-
class="flex text-black dark:text-white items-center gap-2 no-underline hover:no-underline group">
238
-
<span>PINNED REPOS</span>
239
-
<span class="flex gap-1 items-center font-normal text-sm text-gray-500 dark:text-gray-400 ">
240
-
view all {{ i "chevron-right" "w-4 h-4" }}
241
-
</span>
242
-
</a>
243
-
{{ if and .LoggedInUser (eq .LoggedInUser.Did .Card.UserDid) }}
244
-
<button
245
-
hx-get="profile/edit-pins"
246
-
hx-target="#all-repos"
247
-
class="btn py-0 font-normal text-sm flex gap-2 items-center group">
248
-
{{ i "pencil" "w-3 h-3" }}
249
-
edit
250
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
251
-
</button>
252
-
{{ end }}
253
-
</div>
254
-
<div id="repos" class="grid grid-cols-1 gap-4 items-stretch">
255
-
{{ range .Repos }}
256
-
{{ template "user/fragments/repoCard" (list $ . false) }}
257
-
{{ else }}
258
-
<p class="px-6 dark:text-white">This user does not have any repos yet.</p>
259
-
{{ end }}
260
-
</div>
261
-
</div>
262
-
{{ end }}
263
-
264
-
{{ define "collaboratingRepos" }}
265
-
{{ if gt (len .CollaboratingRepos) 0 }}
266
-
<div>
267
-
<p class="text-sm font-bold p-2 dark:text-white">COLLABORATING ON</p>
268
-
<div id="collaborating" class="grid grid-cols-1 gap-4">
269
-
{{ range .CollaboratingRepos }}
270
-
{{ template "user/fragments/repoCard" (list $ . true) }}
271
-
{{ else }}
272
-
<p class="px-6 dark:text-white">This user is not collaborating.</p>
273
-
{{ end }}
274
-
</div>
275
-
</div>
276
-
{{ end }}
277
-
{{ end }}
278
-
279
-
{{ define "punchcard" }}
280
-
{{ $now := now }}
281
-
<div>
282
-
<p class="p-2 flex gap-2 text-sm font-bold dark:text-white">
283
-
PUNCHCARD
284
-
<span class="font-normal text-sm text-gray-500 dark:text-gray-400 ">
285
-
{{ .Total | int64 | commaFmt }} commits
286
-
</span>
287
-
</p>
288
-
<div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm">
289
-
<div class="grid grid-cols-28 md:grid-cols-14 gap-y-2 w-full h-full">
290
-
{{ range .Punches }}
291
-
{{ $count := .Count }}
292
-
{{ $theme := "bg-gray-200 dark:bg-gray-700 size-[4px]" }}
293
-
{{ if lt $count 1 }}
294
-
{{ $theme = "bg-gray-200 dark:bg-gray-700 size-[4px]" }}
295
-
{{ else if lt $count 2 }}
296
-
{{ $theme = "bg-green-200 dark:bg-green-900 size-[5px]" }}
297
-
{{ else if lt $count 4 }}
298
-
{{ $theme = "bg-green-300 dark:bg-green-800 size-[5px]" }}
299
-
{{ else if lt $count 8 }}
300
-
{{ $theme = "bg-green-400 dark:bg-green-700 size-[6px]" }}
301
-
{{ else }}
302
-
{{ $theme = "bg-green-500 dark:bg-green-600 size-[7px]" }}
303
-
{{ end }}
304
-
305
-
{{ if .Date.After $now }}
306
-
{{ $theme = "border border-gray-200 dark:border-gray-700 size-[4px]" }}
307
-
{{ end }}
308
-
<div class="w-full h-full flex justify-center items-center">
309
-
<div
310
-
class="aspect-square rounded-full transition-all duration-300 {{ $theme }} max-w-full max-h-full"
311
-
title="{{ .Date.Format "2006-01-02" }}: {{ .Count }} commits">
312
-
</div>
313
-
</div>
314
-
{{ end }}
315
-
</div>
316
-
</div>
317
-
</div>
318
-
{{ end }}
+7
-18
appview/pages/templates/user/repos.html
+7
-18
appview/pages/templates/user/repos.html
···
1
1
{{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท repos {{ end }}
2
2
3
-
{{ define "extrameta" }}
4
-
<meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}'s repos" />
5
-
<meta property="og:type" content="object" />
6
-
<meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}?tab=repos" />
7
-
<meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" />
8
-
{{ end }}
9
-
10
-
{{ define "content" }}
11
-
<div class="grid grid-cols-1 md:grid-cols-11 gap-4">
12
-
<div class="md:col-span-3 order-1 md:order-1">
13
-
{{ template "user/fragments/profileCard" .Card }}
14
-
</div>
15
-
<div id="all-repos" class="md:col-span-8 order-2 md:order-2">
16
-
{{ block "ownRepos" . }}{{ end }}
17
-
</div>
18
-
</div>
3
+
{{ define "profileContent" }}
4
+
<div id="all-repos" class="md:col-span-8 order-2 md:order-2">
5
+
{{ block "ownRepos" . }}{{ end }}
6
+
</div>
19
7
{{ end }}
20
8
21
9
{{ define "ownRepos" }}
22
-
<p class="text-sm font-bold p-2 dark:text-white">ALL REPOSITORIES</p>
23
10
<div id="repos" class="grid grid-cols-1 gap-4 mb-6">
24
11
{{ range .Repos }}
25
-
{{ template "user/fragments/repoCard" (list $ . false) }}
12
+
<div class="border border-gray-200 dark:border-gray-700 rounded-sm">
13
+
{{ template "user/fragments/repoCard" (list $ . false) }}
14
+
</div>
26
15
{{ else }}
27
16
<p class="px-6 dark:text-white">This user does not have any repos yet.</p>
28
17
{{ end }}
+2
-2
appview/pages/templates/user/settings/emails.html
+2
-2
appview/pages/templates/user/settings/emails.html
···
4
4
<div class="p-6">
5
5
<p class="text-xl font-bold dark:text-white">Settings</p>
6
6
</div>
7
-
<div class="bg-white dark:bg-gray-800">
8
-
<section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6 p-6">
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
9
<div class="col-span-1">
10
10
{{ template "user/settings/fragments/sidebar" . }}
11
11
</div>
+2
-2
appview/pages/templates/user/settings/keys.html
+2
-2
appview/pages/templates/user/settings/keys.html
···
4
4
<div class="p-6">
5
5
<p class="text-xl font-bold dark:text-white">Settings</p>
6
6
</div>
7
-
<div class="bg-white dark:bg-gray-800">
8
-
<section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6 p-6">
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
9
<div class="col-span-1">
10
10
{{ template "user/settings/fragments/sidebar" . }}
11
11
</div>
+2
-2
appview/pages/templates/user/settings/profile.html
+2
-2
appview/pages/templates/user/settings/profile.html
···
4
4
<div class="p-6">
5
5
<p class="text-xl font-bold dark:text-white">Settings</p>
6
6
</div>
7
-
<div class="bg-white dark:bg-gray-800">
8
-
<section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6 p-6">
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
9
<div class="col-span-1">
10
10
{{ template "user/settings/fragments/sidebar" . }}
11
11
</div>
+3
-1
appview/pages/templates/user/signup.html
+3
-1
appview/pages/templates/user/signup.html
···
13
13
</head>
14
14
<body class="flex items-center justify-center min-h-screen">
15
15
<main class="max-w-md px-6 -mt-4">
16
-
<h1 class="text-center text-2xl font-semibold italic dark:text-white" >tangled</h1>
16
+
<h1 class="flex place-content-center text-2xl font-semibold italic dark:text-white" >
17
+
{{ template "fragments/logotype" }}
18
+
</h1>
17
19
<h2 class="text-center text-xl italic dark:text-white">tightly-knit social coding.</h2>
18
20
<form
19
21
class="mt-4 max-w-sm mx-auto"
+19
appview/pages/templates/user/starred.html
+19
appview/pages/templates/user/starred.html
···
1
+
{{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท repos {{ end }}
2
+
3
+
{{ define "profileContent" }}
4
+
<div id="all-repos" class="md:col-span-8 order-2 md:order-2">
5
+
{{ block "starredRepos" . }}{{ end }}
6
+
</div>
7
+
{{ end }}
8
+
9
+
{{ define "starredRepos" }}
10
+
<div id="repos" class="grid grid-cols-1 gap-4 mb-6">
11
+
{{ range .Repos }}
12
+
<div class="border border-gray-200 dark:border-gray-700 rounded-sm">
13
+
{{ template "user/fragments/repoCard" (list $ . true) }}
14
+
</div>
15
+
{{ else }}
16
+
<p class="px-6 dark:text-white">This user does not have any starred repos yet.</p>
17
+
{{ end }}
18
+
</div>
19
+
{{ end }}
+45
appview/pages/templates/user/strings.html
+45
appview/pages/templates/user/strings.html
···
1
+
{{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท strings {{ end }}
2
+
3
+
{{ define "profileContent" }}
4
+
<div id="all-strings" class="md:col-span-8 order-2 md:order-2">
5
+
{{ block "allStrings" . }}{{ end }}
6
+
</div>
7
+
{{ end }}
8
+
9
+
{{ define "allStrings" }}
10
+
<div id="strings" class="grid grid-cols-1 gap-4 mb-6">
11
+
{{ range .Strings }}
12
+
<div class="border border-gray-200 dark:border-gray-700 rounded-sm">
13
+
{{ template "singleString" (list $ .) }}
14
+
</div>
15
+
{{ else }}
16
+
<p class="px-6 dark:text-white">This user does not have any strings yet.</p>
17
+
{{ end }}
18
+
</div>
19
+
{{ end }}
20
+
21
+
{{ define "singleString" }}
22
+
{{ $root := index . 0 }}
23
+
{{ $s := index . 1 }}
24
+
<div class="py-4 px-6 rounded bg-white dark:bg-gray-800">
25
+
<div class="font-medium dark:text-white flex gap-2 items-center">
26
+
<a href="/strings/{{ or $root.Card.UserHandle $root.Card.UserDid }}/{{ $s.Rkey }}">{{ $s.Filename }}</a>
27
+
</div>
28
+
{{ with $s.Description }}
29
+
<div class="text-gray-600 dark:text-gray-300 text-sm">
30
+
{{ . }}
31
+
</div>
32
+
{{ end }}
33
+
34
+
{{ $stat := $s.Stats }}
35
+
<div class="text-gray-400 pt-4 text-sm font-mono inline-flex gap-2 mt-auto">
36
+
<span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span>
37
+
<span class="select-none [&:before]:content-['ยท']"></span>
38
+
{{ with $s.Edited }}
39
+
<span>edited {{ template "repo/fragments/shortTimeAgo" . }}</span>
40
+
{{ else }}
41
+
{{ template "repo/fragments/shortTimeAgo" $s.Created }}
42
+
{{ end }}
43
+
</div>
44
+
</div>
45
+
{{ end }}
+1
-1
appview/posthog/notifier.go
+1
-1
appview/posthog/notifier.go
···
58
58
59
59
func (n *posthogNotifier) NewIssue(ctx context.Context, issue *db.Issue) {
60
60
err := n.client.Enqueue(posthog.Capture{
61
-
DistinctId: issue.OwnerDid,
61
+
DistinctId: issue.Did,
62
62
Event: "new_issue",
63
63
Properties: posthog.Properties{
64
64
"repo_at": issue.RepoAt.String(),
+252
-104
appview/pulls/pulls.go
+252
-104
appview/pulls/pulls.go
···
2
2
3
3
import (
4
4
"database/sql"
5
+
"encoding/json"
5
6
"errors"
6
7
"fmt"
7
8
"log"
···
21
22
"tangled.sh/tangled.sh/core/appview/reporesolver"
22
23
"tangled.sh/tangled.sh/core/appview/xrpcclient"
23
24
"tangled.sh/tangled.sh/core/idresolver"
24
-
"tangled.sh/tangled.sh/core/knotclient"
25
25
"tangled.sh/tangled.sh/core/patchutil"
26
26
"tangled.sh/tangled.sh/core/tid"
27
27
"tangled.sh/tangled.sh/core/types"
···
99
99
mergeCheckResponse := s.mergeCheck(r, f, pull, stack)
100
100
resubmitResult := pages.Unknown
101
101
if user.Did == pull.OwnerDid {
102
-
resubmitResult = s.resubmitCheck(f, pull, stack)
102
+
resubmitResult = s.resubmitCheck(r, f, pull, stack)
103
103
}
104
104
105
105
s.pages.PullActionsFragment(w, pages.PullActionsParams{
···
154
154
mergeCheckResponse := s.mergeCheck(r, f, pull, stack)
155
155
resubmitResult := pages.Unknown
156
156
if user != nil && user.Did == pull.OwnerDid {
157
-
resubmitResult = s.resubmitCheck(f, pull, stack)
157
+
resubmitResult = s.resubmitCheck(r, f, pull, stack)
158
158
}
159
159
160
160
repoInfo := f.RepoInfo(user)
···
282
282
return result
283
283
}
284
284
285
-
func (s *Pulls) resubmitCheck(f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) pages.ResubmitResult {
285
+
func (s *Pulls) resubmitCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) pages.ResubmitResult {
286
286
if pull.State == db.PullMerged || pull.State == db.PullDeleted || pull.PullSource == nil {
287
287
return pages.Unknown
288
288
}
···
307
307
repoName = f.Name
308
308
}
309
309
310
-
us, err := knotclient.NewUnsignedClient(knot, s.config.Core.Dev)
311
-
if err != nil {
312
-
log.Printf("failed to setup client for %s; ignoring: %v", knot, err)
313
-
return pages.Unknown
310
+
scheme := "http"
311
+
if !s.config.Core.Dev {
312
+
scheme = "https"
313
+
}
314
+
host := fmt.Sprintf("%s://%s", scheme, knot)
315
+
xrpcc := &indigoxrpc.Client{
316
+
Host: host,
314
317
}
315
318
316
-
result, err := us.Branch(ownerDid, repoName, pull.PullSource.Branch)
319
+
repo := fmt.Sprintf("%s/%s", ownerDid, repoName)
320
+
branchResp, err := tangled.RepoBranch(r.Context(), xrpcc, pull.PullSource.Branch, repo)
317
321
if err != nil {
322
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
323
+
log.Println("failed to call XRPC repo.branches", xrpcerr)
324
+
return pages.Unknown
325
+
}
318
326
log.Println("failed to reach knotserver", err)
319
327
return pages.Unknown
320
328
}
321
329
330
+
targetBranch := branchResp
331
+
322
332
latestSourceRev := pull.Submissions[pull.LastRoundNumber()].SourceRev
323
333
324
334
if pull.IsStacked() && stack != nil {
···
326
336
latestSourceRev = top.Submissions[top.LastRoundNumber()].SourceRev
327
337
}
328
338
329
-
if latestSourceRev != result.Branch.Hash {
339
+
if latestSourceRev != targetBranch.Hash {
330
340
return pages.ShouldResubmit
331
341
}
332
342
···
605
615
defer tx.Rollback()
606
616
607
617
createdAt := time.Now().Format(time.RFC3339)
608
-
ownerDid := user.Did
609
618
610
619
pullAt, err := db.GetPullAt(s.db, f.RepoAt(), pull.PullId)
611
620
if err != nil {
···
614
623
return
615
624
}
616
625
617
-
atUri := f.RepoAt().String()
618
626
client, err := s.oauth.AuthorizedClient(r)
619
627
if err != nil {
620
628
log.Println("failed to get authorized client", err)
···
627
635
Rkey: tid.TID(),
628
636
Record: &lexutil.LexiconTypeDecoder{
629
637
Val: &tangled.RepoPullComment{
630
-
Repo: &atUri,
631
638
Pull: string(pullAt),
632
-
Owner: &ownerDid,
633
639
Body: body,
634
640
CreatedAt: createdAt,
635
641
},
···
682
688
683
689
switch r.Method {
684
690
case http.MethodGet:
685
-
us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
691
+
scheme := "http"
692
+
if !s.config.Core.Dev {
693
+
scheme = "https"
694
+
}
695
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
696
+
xrpcc := &indigoxrpc.Client{
697
+
Host: host,
698
+
}
699
+
700
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
701
+
xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
686
702
if err != nil {
687
-
log.Printf("failed to create unsigned client for %s", f.Knot)
688
-
s.pages.Error503(w)
703
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
704
+
log.Println("failed to call XRPC repo.branches", xrpcerr)
705
+
s.pages.Error503(w)
706
+
return
707
+
}
708
+
log.Println("failed to fetch branches", err)
689
709
return
690
710
}
691
711
692
-
result, err := us.Branches(f.OwnerDid(), f.Name)
693
-
if err != nil {
694
-
log.Println("failed to fetch branches", err)
712
+
var result types.RepoBranchesResponse
713
+
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
714
+
log.Println("failed to decode XRPC response", err)
715
+
s.pages.Error503(w)
695
716
return
696
717
}
697
718
···
756
777
return
757
778
}
758
779
759
-
us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
760
-
if err != nil {
761
-
log.Printf("failed to create unsigned client to %s: %v", f.Knot, err)
762
-
s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.")
763
-
return
764
-
}
780
+
// us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
781
+
// if err != nil {
782
+
// log.Printf("failed to create unsigned client to %s: %v", f.Knot, err)
783
+
// s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.")
784
+
// return
785
+
// }
765
786
766
-
caps, err := us.Capabilities()
767
-
if err != nil {
768
-
log.Println("error fetching knot caps", f.Knot, err)
769
-
s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.")
770
-
return
787
+
// TODO: make capabilities an xrpc call
788
+
caps := struct {
789
+
PullRequests struct {
790
+
FormatPatch bool
791
+
BranchSubmissions bool
792
+
ForkSubmissions bool
793
+
PatchSubmissions bool
794
+
}
795
+
}{
796
+
PullRequests: struct {
797
+
FormatPatch bool
798
+
BranchSubmissions bool
799
+
ForkSubmissions bool
800
+
PatchSubmissions bool
801
+
}{
802
+
FormatPatch: true,
803
+
BranchSubmissions: true,
804
+
ForkSubmissions: true,
805
+
PatchSubmissions: true,
806
+
},
771
807
}
808
+
809
+
// caps, err := us.Capabilities()
810
+
// if err != nil {
811
+
// log.Println("error fetching knot caps", f.Knot, err)
812
+
// s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.")
813
+
// return
814
+
// }
772
815
773
816
if !caps.PullRequests.FormatPatch {
774
817
s.pages.Notice(w, "pull", "This knot doesn't support format-patch. Unfortunately, there is no fallback for now.")
···
810
853
sourceBranch string,
811
854
isStacked bool,
812
855
) {
813
-
// Generate a patch using /compare
814
-
ksClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
815
-
if err != nil {
816
-
log.Printf("failed to create signed client for %s: %s", f.Knot, err)
817
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
818
-
return
856
+
scheme := "http"
857
+
if !s.config.Core.Dev {
858
+
scheme = "https"
859
+
}
860
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
861
+
xrpcc := &indigoxrpc.Client{
862
+
Host: host,
819
863
}
820
864
821
-
comparison, err := ksClient.Compare(f.OwnerDid(), f.Name, targetBranch, sourceBranch)
865
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
866
+
xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, targetBranch, sourceBranch)
822
867
if err != nil {
868
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
869
+
log.Println("failed to call XRPC repo.compare", xrpcerr)
870
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
871
+
return
872
+
}
823
873
log.Println("failed to compare", err)
824
874
s.pages.Notice(w, "pull", err.Error())
875
+
return
876
+
}
877
+
878
+
var comparison types.RepoFormatPatchResponse
879
+
if err := json.Unmarshal(xrpcBytes, &comparison); err != nil {
880
+
log.Println("failed to decode XRPC compare response", err)
881
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
825
882
return
826
883
}
827
884
···
854
911
}
855
912
856
913
func (s *Pulls) handleForkBasedPull(w http.ResponseWriter, r *http.Request, f *reporesolver.ResolvedRepo, user *oauth.User, forkRepo string, title, body, targetBranch, sourceBranch string, isStacked bool) {
857
-
fork, err := db.GetForkByDid(s.db, user.Did, forkRepo)
914
+
repoString := strings.SplitN(forkRepo, "/", 2)
915
+
forkOwnerDid := repoString[0]
916
+
repoName := repoString[1]
917
+
fork, err := db.GetForkByDid(s.db, forkOwnerDid, repoName)
858
918
if errors.Is(err, sql.ErrNoRows) {
859
919
s.pages.Notice(w, "pull", "No such fork.")
860
920
return
···
870
930
oauth.WithLxm(tangled.RepoHiddenRefNSID),
871
931
oauth.WithDev(s.config.Core.Dev),
872
932
)
873
-
if err != nil {
874
-
log.Printf("failed to connect to knot server: %v", err)
875
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
876
-
return
877
-
}
878
-
879
-
us, err := knotclient.NewUnsignedClient(fork.Knot, s.config.Core.Dev)
880
-
if err != nil {
881
-
log.Println("failed to create unsigned client:", err)
882
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
883
-
return
884
-
}
885
933
886
934
resp, err := tangled.RepoHiddenRef(
887
935
r.Context(),
···
912
960
// hiddenRef: hidden/feature-1/main (on repo-fork)
913
961
// targetBranch: main (on repo-1)
914
962
// sourceBranch: feature-1 (on repo-fork)
915
-
comparison, err := us.Compare(user.Did, fork.Name, hiddenRef, sourceBranch)
963
+
forkScheme := "http"
964
+
if !s.config.Core.Dev {
965
+
forkScheme = "https"
966
+
}
967
+
forkHost := fmt.Sprintf("%s://%s", forkScheme, fork.Knot)
968
+
forkXrpcc := &indigoxrpc.Client{
969
+
Host: forkHost,
970
+
}
971
+
972
+
forkRepoId := fmt.Sprintf("%s/%s", fork.Did, fork.Name)
973
+
forkXrpcBytes, err := tangled.RepoCompare(r.Context(), forkXrpcc, forkRepoId, hiddenRef, sourceBranch)
916
974
if err != nil {
975
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
976
+
log.Println("failed to call XRPC repo.compare for fork", xrpcerr)
977
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
978
+
return
979
+
}
917
980
log.Println("failed to compare across branches", err)
918
981
s.pages.Notice(w, "pull", err.Error())
982
+
return
983
+
}
984
+
985
+
var comparison types.RepoFormatPatchResponse
986
+
if err := json.Unmarshal(forkXrpcBytes, &comparison); err != nil {
987
+
log.Println("failed to decode XRPC compare response for fork", err)
988
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
919
989
return
920
990
}
921
991
···
1038
1108
Rkey: rkey,
1039
1109
Record: &lexutil.LexiconTypeDecoder{
1040
1110
Val: &tangled.RepoPull{
1041
-
Title: title,
1042
-
PullId: int64(pullId),
1043
-
TargetRepo: string(f.RepoAt()),
1044
-
TargetBranch: targetBranch,
1045
-
Patch: patch,
1046
-
Source: recordPullSource,
1111
+
Title: title,
1112
+
Target: &tangled.RepoPull_Target{
1113
+
Repo: string(f.RepoAt()),
1114
+
Branch: targetBranch,
1115
+
},
1116
+
Patch: patch,
1117
+
Source: recordPullSource,
1047
1118
},
1048
1119
},
1049
1120
})
···
1211
1282
return
1212
1283
}
1213
1284
1214
-
us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
1285
+
scheme := "http"
1286
+
if !s.config.Core.Dev {
1287
+
scheme = "https"
1288
+
}
1289
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
1290
+
xrpcc := &indigoxrpc.Client{
1291
+
Host: host,
1292
+
}
1293
+
1294
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
1295
+
xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
1215
1296
if err != nil {
1216
-
log.Printf("failed to create unsigned client for %s", f.Knot)
1217
-
s.pages.Error503(w)
1297
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1298
+
log.Println("failed to call XRPC repo.branches", xrpcerr)
1299
+
s.pages.Error503(w)
1300
+
return
1301
+
}
1302
+
log.Println("failed to fetch branches", err)
1218
1303
return
1219
1304
}
1220
1305
1221
-
result, err := us.Branches(f.OwnerDid(), f.Name)
1222
-
if err != nil {
1223
-
log.Println("failed to reach knotserver", err)
1306
+
var result types.RepoBranchesResponse
1307
+
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
1308
+
log.Println("failed to decode XRPC response", err)
1309
+
s.pages.Error503(w)
1224
1310
return
1225
1311
}
1226
1312
···
1274
1360
}
1275
1361
1276
1362
forkVal := r.URL.Query().Get("fork")
1277
-
1363
+
repoString := strings.SplitN(forkVal, "/", 2)
1364
+
forkOwnerDid := repoString[0]
1365
+
forkName := repoString[1]
1278
1366
// fork repo
1279
-
repo, err := db.GetRepo(s.db, user.Did, forkVal)
1367
+
repo, err := db.GetRepo(s.db, forkOwnerDid, forkName)
1280
1368
if err != nil {
1281
1369
log.Println("failed to get repo", user.Did, forkVal)
1282
1370
return
1283
1371
}
1284
1372
1285
-
sourceBranchesClient, err := knotclient.NewUnsignedClient(repo.Knot, s.config.Core.Dev)
1286
-
if err != nil {
1287
-
log.Printf("failed to create unsigned client for %s", repo.Knot)
1288
-
s.pages.Error503(w)
1289
-
return
1373
+
sourceScheme := "http"
1374
+
if !s.config.Core.Dev {
1375
+
sourceScheme = "https"
1376
+
}
1377
+
sourceHost := fmt.Sprintf("%s://%s", sourceScheme, repo.Knot)
1378
+
sourceXrpcc := &indigoxrpc.Client{
1379
+
Host: sourceHost,
1290
1380
}
1291
1381
1292
-
sourceResult, err := sourceBranchesClient.Branches(user.Did, repo.Name)
1382
+
sourceRepo := fmt.Sprintf("%s/%s", forkOwnerDid, repo.Name)
1383
+
sourceXrpcBytes, err := tangled.RepoBranches(r.Context(), sourceXrpcc, "", 0, sourceRepo)
1293
1384
if err != nil {
1294
-
log.Println("failed to reach knotserver for source branches", err)
1385
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1386
+
log.Println("failed to call XRPC repo.branches for source", xrpcerr)
1387
+
s.pages.Error503(w)
1388
+
return
1389
+
}
1390
+
log.Println("failed to fetch source branches", err)
1295
1391
return
1296
1392
}
1297
1393
1298
-
targetBranchesClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
1299
-
if err != nil {
1300
-
log.Printf("failed to create unsigned client for target knot %s", f.Knot)
1394
+
// Decode source branches
1395
+
var sourceBranches types.RepoBranchesResponse
1396
+
if err := json.Unmarshal(sourceXrpcBytes, &sourceBranches); err != nil {
1397
+
log.Println("failed to decode source branches XRPC response", err)
1301
1398
s.pages.Error503(w)
1302
1399
return
1303
1400
}
1304
1401
1305
-
targetResult, err := targetBranchesClient.Branches(f.OwnerDid(), f.Name)
1402
+
targetScheme := "http"
1403
+
if !s.config.Core.Dev {
1404
+
targetScheme = "https"
1405
+
}
1406
+
targetHost := fmt.Sprintf("%s://%s", targetScheme, f.Knot)
1407
+
targetXrpcc := &indigoxrpc.Client{
1408
+
Host: targetHost,
1409
+
}
1410
+
1411
+
targetRepo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
1412
+
targetXrpcBytes, err := tangled.RepoBranches(r.Context(), targetXrpcc, "", 0, targetRepo)
1306
1413
if err != nil {
1307
-
log.Println("failed to reach knotserver for target branches", err)
1414
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1415
+
log.Println("failed to call XRPC repo.branches for target", xrpcerr)
1416
+
s.pages.Error503(w)
1417
+
return
1418
+
}
1419
+
log.Println("failed to fetch target branches", err)
1308
1420
return
1309
1421
}
1310
1422
1311
-
sourceBranches := sourceResult.Branches
1312
-
sort.Slice(sourceBranches, func(i int, j int) bool {
1313
-
return sourceBranches[i].Commit.Committer.When.After(sourceBranches[j].Commit.Committer.When)
1423
+
// Decode target branches
1424
+
var targetBranches types.RepoBranchesResponse
1425
+
if err := json.Unmarshal(targetXrpcBytes, &targetBranches); err != nil {
1426
+
log.Println("failed to decode target branches XRPC response", err)
1427
+
s.pages.Error503(w)
1428
+
return
1429
+
}
1430
+
1431
+
sort.Slice(sourceBranches.Branches, func(i int, j int) bool {
1432
+
return sourceBranches.Branches[i].Commit.Committer.When.After(sourceBranches.Branches[j].Commit.Committer.When)
1314
1433
})
1315
1434
1316
1435
s.pages.PullCompareForkBranchesFragment(w, pages.PullCompareForkBranchesParams{
1317
1436
RepoInfo: f.RepoInfo(user),
1318
-
SourceBranches: sourceBranches,
1319
-
TargetBranches: targetResult.Branches,
1437
+
SourceBranches: sourceBranches.Branches,
1438
+
TargetBranches: targetBranches.Branches,
1320
1439
})
1321
1440
}
1322
1441
···
1411
1530
return
1412
1531
}
1413
1532
1414
-
ksClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
1415
-
if err != nil {
1416
-
log.Printf("failed to create client for %s: %s", f.Knot, err)
1417
-
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1418
-
return
1533
+
scheme := "http"
1534
+
if !s.config.Core.Dev {
1535
+
scheme = "https"
1536
+
}
1537
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
1538
+
xrpcc := &indigoxrpc.Client{
1539
+
Host: host,
1419
1540
}
1420
1541
1421
-
comparison, err := ksClient.Compare(f.OwnerDid(), f.Name, pull.TargetBranch, pull.PullSource.Branch)
1542
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
1543
+
xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, pull.TargetBranch, pull.PullSource.Branch)
1422
1544
if err != nil {
1545
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1546
+
log.Println("failed to call XRPC repo.compare", xrpcerr)
1547
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1548
+
return
1549
+
}
1423
1550
log.Printf("compare request failed: %s", err)
1424
1551
s.pages.Notice(w, "resubmit-error", err.Error())
1552
+
return
1553
+
}
1554
+
1555
+
var comparison types.RepoFormatPatchResponse
1556
+
if err := json.Unmarshal(xrpcBytes, &comparison); err != nil {
1557
+
log.Println("failed to decode XRPC compare response", err)
1558
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1425
1559
return
1426
1560
}
1427
1561
···
1461
1595
}
1462
1596
1463
1597
// extract patch by performing compare
1464
-
ksClient, err := knotclient.NewUnsignedClient(forkRepo.Knot, s.config.Core.Dev)
1598
+
forkScheme := "http"
1599
+
if !s.config.Core.Dev {
1600
+
forkScheme = "https"
1601
+
}
1602
+
forkHost := fmt.Sprintf("%s://%s", forkScheme, forkRepo.Knot)
1603
+
forkRepoId := fmt.Sprintf("%s/%s", forkRepo.Did, forkRepo.Name)
1604
+
forkXrpcBytes, err := tangled.RepoCompare(r.Context(), &indigoxrpc.Client{Host: forkHost}, forkRepoId, pull.TargetBranch, pull.PullSource.Branch)
1465
1605
if err != nil {
1466
-
log.Printf("failed to create client for %s: %s", forkRepo.Knot, err)
1606
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1607
+
log.Println("failed to call XRPC repo.compare for fork", xrpcerr)
1608
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1609
+
return
1610
+
}
1611
+
log.Printf("failed to compare branches: %s", err)
1612
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1613
+
return
1614
+
}
1615
+
1616
+
var forkComparison types.RepoFormatPatchResponse
1617
+
if err := json.Unmarshal(forkXrpcBytes, &forkComparison); err != nil {
1618
+
log.Println("failed to decode XRPC compare response for fork", err)
1467
1619
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1468
1620
return
1469
1621
}
···
1499
1651
return
1500
1652
}
1501
1653
1502
-
hiddenRef := fmt.Sprintf("hidden/%s/%s", pull.PullSource.Branch, pull.TargetBranch)
1503
-
comparison, err := ksClient.Compare(forkRepo.Did, forkRepo.Name, hiddenRef, pull.PullSource.Branch)
1504
-
if err != nil {
1505
-
log.Printf("failed to compare branches: %s", err)
1506
-
s.pages.Notice(w, "resubmit-error", err.Error())
1507
-
return
1508
-
}
1654
+
// Use the fork comparison we already made
1655
+
comparison := forkComparison
1509
1656
1510
1657
sourceRev := comparison.Rev2
1511
1658
patch := comparison.Patch
···
1609
1756
SwapRecord: ex.Cid,
1610
1757
Record: &lexutil.LexiconTypeDecoder{
1611
1758
Val: &tangled.RepoPull{
1612
-
Title: pull.Title,
1613
-
PullId: int64(pull.PullId),
1614
-
TargetRepo: string(f.RepoAt()),
1615
-
TargetBranch: pull.TargetBranch,
1616
-
Patch: patch, // new patch
1617
-
Source: recordPullSource,
1759
+
Title: pull.Title,
1760
+
Target: &tangled.RepoPull_Target{
1761
+
Repo: string(f.RepoAt()),
1762
+
Branch: pull.TargetBranch,
1763
+
},
1764
+
Patch: patch, // new patch
1765
+
Source: recordPullSource,
1618
1766
},
1619
1767
},
1620
1768
})
+26
-8
appview/repo/artifact.go
+26
-8
appview/repo/artifact.go
···
1
1
package repo
2
2
3
3
import (
4
+
"context"
5
+
"encoding/json"
4
6
"fmt"
5
7
"log"
6
8
"net/http"
···
9
11
10
12
comatproto "github.com/bluesky-social/indigo/api/atproto"
11
13
lexutil "github.com/bluesky-social/indigo/lex/util"
14
+
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
12
15
"github.com/dustin/go-humanize"
13
16
"github.com/go-chi/chi/v5"
14
17
"github.com/go-git/go-git/v5/plumbing"
···
17
20
"tangled.sh/tangled.sh/core/appview/db"
18
21
"tangled.sh/tangled.sh/core/appview/pages"
19
22
"tangled.sh/tangled.sh/core/appview/reporesolver"
20
-
"tangled.sh/tangled.sh/core/knotclient"
23
+
"tangled.sh/tangled.sh/core/appview/xrpcclient"
21
24
"tangled.sh/tangled.sh/core/tid"
22
25
"tangled.sh/tangled.sh/core/types"
23
26
)
···
33
36
return
34
37
}
35
38
36
-
tag, err := rp.resolveTag(f, tagParam)
39
+
tag, err := rp.resolveTag(r.Context(), f, tagParam)
37
40
if err != nil {
38
41
log.Println("failed to resolve tag", err)
39
42
rp.pages.Notice(w, "upload", "failed to upload artifact, error in tag resolution")
···
140
143
return
141
144
}
142
145
143
-
tag, err := rp.resolveTag(f, tagParam)
146
+
tag, err := rp.resolveTag(r.Context(), f, tagParam)
144
147
if err != nil {
145
148
log.Println("failed to resolve tag", err)
146
149
rp.pages.Notice(w, "upload", "failed to upload artifact, error in tag resolution")
···
259
262
w.Write([]byte{})
260
263
}
261
264
262
-
func (rp *Repo) resolveTag(f *reporesolver.ResolvedRepo, tagParam string) (*types.TagReference, error) {
265
+
func (rp *Repo) resolveTag(ctx context.Context, f *reporesolver.ResolvedRepo, tagParam string) (*types.TagReference, error) {
263
266
tagParam, err := url.QueryUnescape(tagParam)
264
267
if err != nil {
265
268
return nil, err
266
269
}
267
270
268
-
us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
269
-
if err != nil {
270
-
return nil, err
271
+
scheme := "http"
272
+
if !rp.config.Core.Dev {
273
+
scheme = "https"
274
+
}
275
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
276
+
xrpcc := &indigoxrpc.Client{
277
+
Host: host,
271
278
}
272
279
273
-
result, err := us.Tags(f.OwnerDid(), f.Name)
280
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
281
+
xrpcBytes, err := tangled.RepoTags(ctx, xrpcc, "", 0, repo)
274
282
if err != nil {
283
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
284
+
log.Println("failed to call XRPC repo.tags", xrpcerr)
285
+
return nil, xrpcerr
286
+
}
275
287
log.Println("failed to reach knotserver", err)
288
+
return nil, err
289
+
}
290
+
291
+
var result types.RepoTagsResponse
292
+
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
293
+
log.Println("failed to decode XRPC tags response", err)
276
294
return nil, err
277
295
}
278
296
+7
-2
appview/repo/feed.go
+7
-2
appview/repo/feed.go
···
9
9
"time"
10
10
11
11
"tangled.sh/tangled.sh/core/appview/db"
12
+
"tangled.sh/tangled.sh/core/appview/pagination"
12
13
"tangled.sh/tangled.sh/core/appview/reporesolver"
13
14
14
15
"github.com/bluesky-social/indigo/atproto/syntax"
···
23
24
return nil, err
24
25
}
25
26
26
-
issues, err := db.GetIssuesWithLimit(rp.db, feedLimitPerType, db.FilterEq("repo_at", f.RepoAt()))
27
+
issues, err := db.GetIssuesPaginated(
28
+
rp.db,
29
+
pagination.Page{Limit: feedLimitPerType},
30
+
db.FilterEq("repo_at", f.RepoAt()),
31
+
)
27
32
if err != nil {
28
33
return nil, err
29
34
}
···
104
109
}
105
110
106
111
func (rp *Repo) createIssueItem(ctx context.Context, issue db.Issue, f *reporesolver.ResolvedRepo) (*feeds.Item, error) {
107
-
owner, err := rp.idResolver.ResolveIdent(ctx, issue.OwnerDid)
112
+
owner, err := rp.idResolver.ResolveIdent(ctx, issue.Did)
108
113
if err != nil {
109
114
return nil, err
110
115
}
+207
-22
appview/repo/index.go
+207
-22
appview/repo/index.go
···
1
1
package repo
2
2
3
3
import (
4
+
"errors"
5
+
"fmt"
4
6
"log"
5
7
"net/http"
6
8
"slices"
7
9
"sort"
8
10
"strings"
11
+
"sync"
12
+
"time"
9
13
14
+
"context"
15
+
"encoding/json"
16
+
17
+
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
18
+
"github.com/go-git/go-git/v5/plumbing"
19
+
"tangled.sh/tangled.sh/core/api/tangled"
10
20
"tangled.sh/tangled.sh/core/appview/commitverify"
11
21
"tangled.sh/tangled.sh/core/appview/db"
12
22
"tangled.sh/tangled.sh/core/appview/pages"
23
+
"tangled.sh/tangled.sh/core/appview/pages/markup"
13
24
"tangled.sh/tangled.sh/core/appview/reporesolver"
14
-
"tangled.sh/tangled.sh/core/knotclient"
25
+
"tangled.sh/tangled.sh/core/appview/xrpcclient"
15
26
"tangled.sh/tangled.sh/core/types"
16
27
17
28
"github.com/go-chi/chi/v5"
···
27
38
return
28
39
}
29
40
30
-
us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
31
-
if err != nil {
32
-
log.Printf("failed to create unsigned client for %s", f.Knot)
33
-
rp.pages.Error503(w)
34
-
return
41
+
scheme := "http"
42
+
if !rp.config.Core.Dev {
43
+
scheme = "https"
44
+
}
45
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
46
+
xrpcc := &indigoxrpc.Client{
47
+
Host: host,
35
48
}
36
49
37
-
result, err := us.Index(f.OwnerDid(), f.Name, ref)
38
-
if err != nil {
39
-
rp.pages.Error503(w)
40
-
log.Println("failed to reach knotserver", err)
41
-
return
50
+
user := rp.oauth.GetUser(r)
51
+
repoInfo := f.RepoInfo(user)
52
+
53
+
// Build index response from multiple XRPC calls
54
+
result, err := rp.buildIndexResponse(r.Context(), xrpcc, f, ref)
55
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
56
+
if errors.Is(xrpcerr, xrpcclient.ErrXrpcUnsupported) {
57
+
log.Println("failed to call XRPC repo.index", err)
58
+
rp.pages.RepoIndexPage(w, pages.RepoIndexParams{
59
+
LoggedInUser: user,
60
+
NeedsKnotUpgrade: true,
61
+
RepoInfo: repoInfo,
62
+
})
63
+
return
64
+
} else {
65
+
rp.pages.Error503(w)
66
+
log.Println("failed to build index response", err)
67
+
return
68
+
}
42
69
}
43
70
44
71
tagMap := make(map[string][]string)
···
98
125
log.Println(err)
99
126
}
100
127
101
-
user := rp.oauth.GetUser(r)
102
-
repoInfo := f.RepoInfo(user)
103
-
104
128
// TODO: a bit dirty
105
-
languageInfo, err := rp.getLanguageInfo(f, us, result.Ref, ref == "")
129
+
languageInfo, err := rp.getLanguageInfo(r.Context(), f, xrpcc, result.Ref, ref == "")
106
130
if err != nil {
107
131
log.Printf("failed to compute language percentages: %s", err)
108
132
// non-fatal
···
135
159
}
136
160
137
161
func (rp *Repo) getLanguageInfo(
162
+
ctx context.Context,
138
163
f *reporesolver.ResolvedRepo,
139
-
us *knotclient.UnsignedClient,
164
+
xrpcc *indigoxrpc.Client,
140
165
currentRef string,
141
166
isDefaultRef bool,
142
167
) ([]types.RepoLanguageDetails, error) {
···
148
173
)
149
174
150
175
if err != nil || langs == nil {
151
-
// non-fatal, fetch langs from ks
152
-
ls, err := us.RepoLanguages(f.OwnerDid(), f.Name, currentRef)
176
+
// non-fatal, fetch langs from ks via XRPC
177
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
178
+
ls, err := tangled.RepoLanguages(ctx, xrpcc, currentRef, repo)
153
179
if err != nil {
180
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
181
+
log.Println("failed to call XRPC repo.languages", xrpcerr)
182
+
return nil, xrpcerr
183
+
}
154
184
return nil, err
155
185
}
156
-
if ls == nil {
186
+
187
+
if ls == nil || ls.Languages == nil {
157
188
return nil, nil
158
189
}
159
190
160
-
for l, s := range ls.Languages {
191
+
for _, lang := range ls.Languages {
161
192
langs = append(langs, db.RepoLanguage{
162
193
RepoAt: f.RepoAt(),
163
194
Ref: currentRef,
164
195
IsDefaultRef: isDefaultRef,
165
-
Language: l,
166
-
Bytes: s,
196
+
Language: lang.Name,
197
+
Bytes: lang.Size,
167
198
})
168
199
}
169
200
···
206
237
207
238
return languageStats, nil
208
239
}
240
+
241
+
// buildIndexResponse creates a RepoIndexResponse by combining multiple xrpc calls in parallel
242
+
func (rp *Repo) buildIndexResponse(ctx context.Context, xrpcc *indigoxrpc.Client, f *reporesolver.ResolvedRepo, ref string) (*types.RepoIndexResponse, error) {
243
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
244
+
245
+
// first get branches to determine the ref if not specified
246
+
branchesBytes, err := tangled.RepoBranches(ctx, xrpcc, "", 0, repo)
247
+
if err != nil {
248
+
return nil, err
249
+
}
250
+
251
+
var branchesResp types.RepoBranchesResponse
252
+
if err := json.Unmarshal(branchesBytes, &branchesResp); err != nil {
253
+
return nil, err
254
+
}
255
+
256
+
// if no ref specified, use default branch or first available
257
+
if ref == "" && len(branchesResp.Branches) > 0 {
258
+
for _, branch := range branchesResp.Branches {
259
+
if branch.IsDefault {
260
+
ref = branch.Name
261
+
break
262
+
}
263
+
}
264
+
if ref == "" {
265
+
ref = branchesResp.Branches[0].Name
266
+
}
267
+
}
268
+
269
+
// check if repo is empty
270
+
if len(branchesResp.Branches) == 0 {
271
+
return &types.RepoIndexResponse{
272
+
IsEmpty: true,
273
+
Branches: branchesResp.Branches,
274
+
}, nil
275
+
}
276
+
277
+
// now run the remaining queries in parallel
278
+
var wg sync.WaitGroup
279
+
var errs error
280
+
281
+
var (
282
+
tagsResp types.RepoTagsResponse
283
+
treeResp *tangled.RepoTree_Output
284
+
logResp types.RepoLogResponse
285
+
readmeContent string
286
+
readmeFileName string
287
+
)
288
+
289
+
// tags
290
+
wg.Add(1)
291
+
go func() {
292
+
defer wg.Done()
293
+
tagsBytes, err := tangled.RepoTags(ctx, xrpcc, "", 0, repo)
294
+
if err != nil {
295
+
errs = errors.Join(errs, err)
296
+
return
297
+
}
298
+
299
+
if err := json.Unmarshal(tagsBytes, &tagsResp); err != nil {
300
+
errs = errors.Join(errs, err)
301
+
}
302
+
}()
303
+
304
+
// tree/files
305
+
wg.Add(1)
306
+
go func() {
307
+
defer wg.Done()
308
+
resp, err := tangled.RepoTree(ctx, xrpcc, "", ref, repo)
309
+
if err != nil {
310
+
errs = errors.Join(errs, err)
311
+
return
312
+
}
313
+
treeResp = resp
314
+
}()
315
+
316
+
// commits
317
+
wg.Add(1)
318
+
go func() {
319
+
defer wg.Done()
320
+
logBytes, err := tangled.RepoLog(ctx, xrpcc, "", 50, "", ref, repo)
321
+
if err != nil {
322
+
errs = errors.Join(errs, err)
323
+
return
324
+
}
325
+
326
+
if err := json.Unmarshal(logBytes, &logResp); err != nil {
327
+
errs = errors.Join(errs, err)
328
+
}
329
+
}()
330
+
331
+
// readme content
332
+
wg.Add(1)
333
+
go func() {
334
+
defer wg.Done()
335
+
for _, filename := range markup.ReadmeFilenames {
336
+
blobResp, err := tangled.RepoBlob(ctx, xrpcc, filename, false, ref, repo)
337
+
if err != nil {
338
+
continue
339
+
}
340
+
341
+
if blobResp == nil {
342
+
continue
343
+
}
344
+
345
+
readmeContent = blobResp.Content
346
+
readmeFileName = filename
347
+
break
348
+
}
349
+
}()
350
+
351
+
wg.Wait()
352
+
353
+
if errs != nil {
354
+
return nil, errs
355
+
}
356
+
357
+
var files []types.NiceTree
358
+
if treeResp != nil && treeResp.Files != nil {
359
+
for _, file := range treeResp.Files {
360
+
niceFile := types.NiceTree{
361
+
IsFile: file.Is_file,
362
+
IsSubtree: file.Is_subtree,
363
+
Name: file.Name,
364
+
Mode: file.Mode,
365
+
Size: file.Size,
366
+
}
367
+
if file.Last_commit != nil {
368
+
when, _ := time.Parse(time.RFC3339, file.Last_commit.When)
369
+
niceFile.LastCommit = &types.LastCommitInfo{
370
+
Hash: plumbing.NewHash(file.Last_commit.Hash),
371
+
Message: file.Last_commit.Message,
372
+
When: when,
373
+
}
374
+
}
375
+
files = append(files, niceFile)
376
+
}
377
+
}
378
+
379
+
result := &types.RepoIndexResponse{
380
+
IsEmpty: false,
381
+
Ref: ref,
382
+
Readme: readmeContent,
383
+
ReadmeFileName: readmeFileName,
384
+
Commits: logResp.Commits,
385
+
Description: logResp.Description,
386
+
Files: files,
387
+
Branches: branchesResp.Branches,
388
+
Tags: tagsResp.Tags,
389
+
TotalCommits: logResp.Total,
390
+
}
391
+
392
+
return result, nil
393
+
}
+374
-144
appview/repo/repo.go
+374
-144
appview/repo/repo.go
···
19
19
20
20
comatproto "github.com/bluesky-social/indigo/api/atproto"
21
21
lexutil "github.com/bluesky-social/indigo/lex/util"
22
+
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
22
23
"tangled.sh/tangled.sh/core/api/tangled"
23
24
"tangled.sh/tangled.sh/core/appview/commitverify"
24
25
"tangled.sh/tangled.sh/core/appview/config"
···
31
32
xrpcclient "tangled.sh/tangled.sh/core/appview/xrpcclient"
32
33
"tangled.sh/tangled.sh/core/eventconsumer"
33
34
"tangled.sh/tangled.sh/core/idresolver"
34
-
"tangled.sh/tangled.sh/core/knotclient"
35
35
"tangled.sh/tangled.sh/core/patchutil"
36
36
"tangled.sh/tangled.sh/core/rbac"
37
37
"tangled.sh/tangled.sh/core/tid"
···
92
92
return
93
93
}
94
94
95
-
var uri string
96
-
if rp.config.Core.Dev {
97
-
uri = "http"
98
-
} else {
99
-
uri = "https"
95
+
scheme := "http"
96
+
if !rp.config.Core.Dev {
97
+
scheme = "https"
98
+
}
99
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
100
+
xrpcc := &indigoxrpc.Client{
101
+
Host: host,
102
+
}
103
+
104
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
105
+
archiveBytes, err := tangled.RepoArchive(r.Context(), xrpcc, "tar.gz", "", refParam, repo)
106
+
if err != nil {
107
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
108
+
log.Println("failed to call XRPC repo.archive", xrpcerr)
109
+
rp.pages.Error503(w)
110
+
return
111
+
}
112
+
rp.pages.Error404(w)
113
+
return
100
114
}
101
-
url := fmt.Sprintf("%s://%s/%s/%s/archive/%s.tar.gz", uri, f.Knot, f.OwnerDid(), f.Name, url.PathEscape(refParam))
115
+
116
+
// Set headers for file download
117
+
filename := fmt.Sprintf("%s-%s.tar.gz", f.Name, refParam)
118
+
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
119
+
w.Header().Set("Content-Type", "application/gzip")
120
+
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(archiveBytes)))
102
121
103
-
http.Redirect(w, r, url, http.StatusFound)
122
+
// Write the archive data directly
123
+
w.Write(archiveBytes)
104
124
}
105
125
106
126
func (rp *Repo) RepoLog(w http.ResponseWriter, r *http.Request) {
···
120
140
121
141
ref := chi.URLParam(r, "ref")
122
142
123
-
us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
143
+
scheme := "http"
144
+
if !rp.config.Core.Dev {
145
+
scheme = "https"
146
+
}
147
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
148
+
xrpcc := &indigoxrpc.Client{
149
+
Host: host,
150
+
}
151
+
152
+
limit := int64(60)
153
+
cursor := ""
154
+
if page > 1 {
155
+
// Convert page number to cursor (offset)
156
+
offset := (page - 1) * int(limit)
157
+
cursor = strconv.Itoa(offset)
158
+
}
159
+
160
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
161
+
xrpcBytes, err := tangled.RepoLog(r.Context(), xrpcc, cursor, limit, "", ref, repo)
124
162
if err != nil {
125
-
log.Println("failed to create unsigned client", err)
163
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
164
+
log.Println("failed to call XRPC repo.log", xrpcerr)
165
+
rp.pages.Error503(w)
166
+
return
167
+
}
168
+
rp.pages.Error404(w)
126
169
return
127
170
}
128
171
129
-
repolog, err := us.Log(f.OwnerDid(), f.Name, ref, page)
130
-
if err != nil {
172
+
var xrpcResp types.RepoLogResponse
173
+
if err := json.Unmarshal(xrpcBytes, &xrpcResp); err != nil {
174
+
log.Println("failed to decode XRPC response", err)
131
175
rp.pages.Error503(w)
132
-
log.Println("failed to reach knotserver", err)
133
176
return
134
177
}
135
178
136
-
tagResult, err := us.Tags(f.OwnerDid(), f.Name)
179
+
tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
137
180
if err != nil {
138
-
rp.pages.Error503(w)
139
-
log.Println("failed to reach knotserver", err)
140
-
return
181
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
182
+
log.Println("failed to call XRPC repo.tags", xrpcerr)
183
+
rp.pages.Error503(w)
184
+
return
185
+
}
141
186
}
142
187
143
188
tagMap := make(map[string][]string)
144
-
for _, tag := range tagResult.Tags {
145
-
hash := tag.Hash
146
-
if tag.Tag != nil {
147
-
hash = tag.Tag.Target.String()
189
+
if tagBytes != nil {
190
+
var tagResp types.RepoTagsResponse
191
+
if err := json.Unmarshal(tagBytes, &tagResp); err == nil {
192
+
for _, tag := range tagResp.Tags {
193
+
tagMap[tag.Hash] = append(tagMap[tag.Hash], tag.Name)
194
+
}
148
195
}
149
-
tagMap[hash] = append(tagMap[hash], tag.Name)
150
196
}
151
197
152
-
branchResult, err := us.Branches(f.OwnerDid(), f.Name)
198
+
branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
153
199
if err != nil {
154
-
rp.pages.Error503(w)
155
-
log.Println("failed to reach knotserver", err)
156
-
return
200
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
201
+
log.Println("failed to call XRPC repo.branches", xrpcerr)
202
+
rp.pages.Error503(w)
203
+
return
204
+
}
157
205
}
158
206
159
-
for _, branch := range branchResult.Branches {
160
-
hash := branch.Hash
161
-
tagMap[hash] = append(tagMap[hash], branch.Name)
207
+
if branchBytes != nil {
208
+
var branchResp types.RepoBranchesResponse
209
+
if err := json.Unmarshal(branchBytes, &branchResp); err == nil {
210
+
for _, branch := range branchResp.Branches {
211
+
tagMap[branch.Hash] = append(tagMap[branch.Hash], branch.Name)
212
+
}
213
+
}
162
214
}
163
215
164
216
user := rp.oauth.GetUser(r)
165
217
166
-
emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(repolog.Commits), true)
218
+
emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(xrpcResp.Commits), true)
167
219
if err != nil {
168
220
log.Println("failed to fetch email to did mapping", err)
169
221
}
170
222
171
-
vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, repolog.Commits)
223
+
vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, xrpcResp.Commits)
172
224
if err != nil {
173
225
log.Println(err)
174
226
}
···
176
228
repoInfo := f.RepoInfo(user)
177
229
178
230
var shas []string
179
-
for _, c := range repolog.Commits {
231
+
for _, c := range xrpcResp.Commits {
180
232
shas = append(shas, c.Hash.String())
181
233
}
182
234
pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas)
···
189
241
LoggedInUser: user,
190
242
TagMap: tagMap,
191
243
RepoInfo: repoInfo,
192
-
RepoLogResponse: *repolog,
244
+
RepoLogResponse: xrpcResp,
193
245
EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap),
194
246
VerifiedCommits: vc,
195
247
Pipelines: pipelines,
···
301
353
return
302
354
}
303
355
ref := chi.URLParam(r, "ref")
304
-
protocol := "http"
305
-
if !rp.config.Core.Dev {
306
-
protocol = "https"
307
-
}
308
356
309
357
var diffOpts types.DiffOpts
310
358
if d := r.URL.Query().Get("diff"); d == "split" {
···
316
364
return
317
365
}
318
366
319
-
resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/commit/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref))
320
-
if err != nil {
321
-
rp.pages.Error503(w)
322
-
log.Println("failed to reach knotserver", err)
323
-
return
367
+
scheme := "http"
368
+
if !rp.config.Core.Dev {
369
+
scheme = "https"
370
+
}
371
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
372
+
xrpcc := &indigoxrpc.Client{
373
+
Host: host,
324
374
}
325
375
326
-
body, err := io.ReadAll(resp.Body)
376
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
377
+
xrpcBytes, err := tangled.RepoDiff(r.Context(), xrpcc, ref, repo)
327
378
if err != nil {
328
-
log.Printf("Error reading response body: %v", err)
379
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
380
+
log.Println("failed to call XRPC repo.diff", xrpcerr)
381
+
rp.pages.Error503(w)
382
+
return
383
+
}
384
+
rp.pages.Error404(w)
329
385
return
330
386
}
331
387
332
388
var result types.RepoCommitResponse
333
-
err = json.Unmarshal(body, &result)
334
-
if err != nil {
335
-
log.Println("failed to parse response:", err)
389
+
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
390
+
log.Println("failed to decode XRPC response", err)
391
+
rp.pages.Error503(w)
336
392
return
337
393
}
338
394
···
378
434
379
435
ref := chi.URLParam(r, "ref")
380
436
treePath := chi.URLParam(r, "*")
381
-
protocol := "http"
382
-
if !rp.config.Core.Dev {
383
-
protocol = "https"
384
-
}
385
437
386
438
// if the tree path has a trailing slash, let's strip it
387
439
// so we don't 404
388
440
treePath = strings.TrimSuffix(treePath, "/")
389
441
390
-
resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, treePath))
442
+
scheme := "http"
443
+
if !rp.config.Core.Dev {
444
+
scheme = "https"
445
+
}
446
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
447
+
xrpcc := &indigoxrpc.Client{
448
+
Host: host,
449
+
}
450
+
451
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
452
+
xrpcResp, err := tangled.RepoTree(r.Context(), xrpcc, treePath, ref, repo)
391
453
if err != nil {
392
-
rp.pages.Error503(w)
393
-
log.Println("failed to reach knotserver", err)
454
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
455
+
log.Println("failed to call XRPC repo.tree", xrpcerr)
456
+
rp.pages.Error503(w)
457
+
return
458
+
}
459
+
rp.pages.Error404(w)
394
460
return
395
461
}
396
462
397
-
// uhhh so knotserver returns a 500 if the entry isn't found in
398
-
// the requested tree path, so let's stick to not-OK here.
399
-
// we can fix this once we build out the xrpc apis for these operations.
400
-
if resp.StatusCode != http.StatusOK {
401
-
rp.pages.Error404(w)
402
-
return
463
+
// Convert XRPC response to internal types.RepoTreeResponse
464
+
files := make([]types.NiceTree, len(xrpcResp.Files))
465
+
for i, xrpcFile := range xrpcResp.Files {
466
+
file := types.NiceTree{
467
+
Name: xrpcFile.Name,
468
+
Mode: xrpcFile.Mode,
469
+
Size: int64(xrpcFile.Size),
470
+
IsFile: xrpcFile.Is_file,
471
+
IsSubtree: xrpcFile.Is_subtree,
472
+
}
473
+
474
+
// Convert last commit info if present
475
+
if xrpcFile.Last_commit != nil {
476
+
commitWhen, _ := time.Parse(time.RFC3339, xrpcFile.Last_commit.When)
477
+
file.LastCommit = &types.LastCommitInfo{
478
+
Hash: plumbing.NewHash(xrpcFile.Last_commit.Hash),
479
+
Message: xrpcFile.Last_commit.Message,
480
+
When: commitWhen,
481
+
}
482
+
}
483
+
484
+
files[i] = file
403
485
}
404
486
405
-
body, err := io.ReadAll(resp.Body)
406
-
if err != nil {
407
-
log.Printf("Error reading response body: %v", err)
408
-
return
487
+
result := types.RepoTreeResponse{
488
+
Ref: xrpcResp.Ref,
489
+
Files: files,
409
490
}
410
491
411
-
var result types.RepoTreeResponse
412
-
err = json.Unmarshal(body, &result)
413
-
if err != nil {
414
-
log.Println("failed to parse response:", err)
415
-
return
492
+
if xrpcResp.Parent != nil {
493
+
result.Parent = *xrpcResp.Parent
494
+
}
495
+
if xrpcResp.Dotdot != nil {
496
+
result.DotDot = *xrpcResp.Dotdot
416
497
}
417
498
418
499
// redirects tree paths trying to access a blob; in this case the result.Files is unpopulated,
···
451
532
return
452
533
}
453
534
454
-
us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
535
+
scheme := "http"
536
+
if !rp.config.Core.Dev {
537
+
scheme = "https"
538
+
}
539
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
540
+
xrpcc := &indigoxrpc.Client{
541
+
Host: host,
542
+
}
543
+
544
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
545
+
xrpcBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
455
546
if err != nil {
456
-
log.Println("failed to create unsigned client", err)
547
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
548
+
log.Println("failed to call XRPC repo.tags", xrpcerr)
549
+
rp.pages.Error503(w)
550
+
return
551
+
}
552
+
rp.pages.Error404(w)
457
553
return
458
554
}
459
555
460
-
result, err := us.Tags(f.OwnerDid(), f.Name)
461
-
if err != nil {
556
+
var result types.RepoTagsResponse
557
+
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
558
+
log.Println("failed to decode XRPC response", err)
462
559
rp.pages.Error503(w)
463
-
log.Println("failed to reach knotserver", err)
464
560
return
465
561
}
466
562
···
496
592
rp.pages.RepoTags(w, pages.RepoTagsParams{
497
593
LoggedInUser: user,
498
594
RepoInfo: f.RepoInfo(user),
499
-
RepoTagsResponse: *result,
595
+
RepoTagsResponse: result,
500
596
ArtifactMap: artifactMap,
501
597
DanglingArtifacts: danglingArtifacts,
502
598
})
···
509
605
return
510
606
}
511
607
512
-
us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
608
+
scheme := "http"
609
+
if !rp.config.Core.Dev {
610
+
scheme = "https"
611
+
}
612
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
613
+
xrpcc := &indigoxrpc.Client{
614
+
Host: host,
615
+
}
616
+
617
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
618
+
xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
513
619
if err != nil {
514
-
log.Println("failed to create unsigned client", err)
620
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
621
+
log.Println("failed to call XRPC repo.branches", xrpcerr)
622
+
rp.pages.Error503(w)
623
+
return
624
+
}
625
+
rp.pages.Error404(w)
515
626
return
516
627
}
517
628
518
-
result, err := us.Branches(f.OwnerDid(), f.Name)
519
-
if err != nil {
629
+
var result types.RepoBranchesResponse
630
+
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
631
+
log.Println("failed to decode XRPC response", err)
520
632
rp.pages.Error503(w)
521
-
log.Println("failed to reach knotserver", err)
522
633
return
523
634
}
524
635
···
528
639
rp.pages.RepoBranches(w, pages.RepoBranchesParams{
529
640
LoggedInUser: user,
530
641
RepoInfo: f.RepoInfo(user),
531
-
RepoBranchesResponse: *result,
642
+
RepoBranchesResponse: result,
532
643
})
533
644
}
534
645
···
541
652
542
653
ref := chi.URLParam(r, "ref")
543
654
filePath := chi.URLParam(r, "*")
544
-
protocol := "http"
655
+
656
+
scheme := "http"
545
657
if !rp.config.Core.Dev {
546
-
protocol = "https"
658
+
scheme = "https"
547
659
}
548
-
resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, filePath))
549
-
if err != nil {
550
-
rp.pages.Error503(w)
551
-
log.Println("failed to reach knotserver", err)
552
-
return
660
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
661
+
xrpcc := &indigoxrpc.Client{
662
+
Host: host,
553
663
}
554
664
555
-
if resp.StatusCode == http.StatusNotFound {
665
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name)
666
+
resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, repo)
667
+
if err != nil {
668
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
669
+
log.Println("failed to call XRPC repo.blob", xrpcerr)
670
+
rp.pages.Error503(w)
671
+
return
672
+
}
556
673
rp.pages.Error404(w)
557
674
return
558
675
}
559
676
560
-
body, err := io.ReadAll(resp.Body)
561
-
if err != nil {
562
-
log.Printf("Error reading response body: %v", err)
563
-
return
564
-
}
565
-
566
-
var result types.RepoBlobResponse
567
-
err = json.Unmarshal(body, &result)
568
-
if err != nil {
569
-
log.Println("failed to parse response:", err)
570
-
return
571
-
}
677
+
// Use XRPC response directly instead of converting to internal types
572
678
573
679
var breadcrumbs [][]string
574
680
breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)})
···
581
687
showRendered := false
582
688
renderToggle := false
583
689
584
-
if markup.GetFormat(result.Path) == markup.FormatMarkdown {
690
+
if markup.GetFormat(resp.Path) == markup.FormatMarkdown {
585
691
renderToggle = true
586
692
showRendered = r.URL.Query().Get("code") != "true"
587
693
}
···
591
697
var isVideo bool
592
698
var contentSrc string
593
699
594
-
if result.IsBinary {
595
-
ext := strings.ToLower(filepath.Ext(result.Path))
700
+
if resp.IsBinary != nil && *resp.IsBinary {
701
+
ext := strings.ToLower(filepath.Ext(resp.Path))
596
702
switch ext {
597
703
case ".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp":
598
704
isImage = true
···
602
708
unsupported = true
603
709
}
604
710
605
-
// fetch the actual binary content like in RepoBlobRaw
711
+
// fetch the raw binary content using sh.tangled.repo.blob xrpc
712
+
repoName := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
713
+
blobURL := fmt.Sprintf("%s://%s/xrpc/sh.tangled.repo.blob?repo=%s&ref=%s&path=%s&raw=true",
714
+
scheme, f.Knot, url.QueryEscape(repoName), url.QueryEscape(ref), url.QueryEscape(filePath))
606
715
607
-
blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Name, ref, filePath)
608
716
contentSrc = blobURL
609
717
if !rp.config.Core.Dev {
610
718
contentSrc = markup.GenerateCamoURL(rp.config.Camo.Host, rp.config.Camo.SharedSecret, blobURL)
611
719
}
612
720
}
613
721
722
+
lines := 0
723
+
if resp.IsBinary == nil || !*resp.IsBinary {
724
+
lines = strings.Count(resp.Content, "\n") + 1
725
+
}
726
+
727
+
var sizeHint uint64
728
+
if resp.Size != nil {
729
+
sizeHint = uint64(*resp.Size)
730
+
} else {
731
+
sizeHint = uint64(len(resp.Content))
732
+
}
733
+
614
734
user := rp.oauth.GetUser(r)
735
+
736
+
// Determine if content is binary (dereference pointer)
737
+
isBinary := false
738
+
if resp.IsBinary != nil {
739
+
isBinary = *resp.IsBinary
740
+
}
741
+
615
742
rp.pages.RepoBlob(w, pages.RepoBlobParams{
616
-
LoggedInUser: user,
617
-
RepoInfo: f.RepoInfo(user),
618
-
RepoBlobResponse: result,
619
-
BreadCrumbs: breadcrumbs,
620
-
ShowRendered: showRendered,
621
-
RenderToggle: renderToggle,
622
-
Unsupported: unsupported,
623
-
IsImage: isImage,
624
-
IsVideo: isVideo,
625
-
ContentSrc: contentSrc,
743
+
LoggedInUser: user,
744
+
RepoInfo: f.RepoInfo(user),
745
+
BreadCrumbs: breadcrumbs,
746
+
ShowRendered: showRendered,
747
+
RenderToggle: renderToggle,
748
+
Unsupported: unsupported,
749
+
IsImage: isImage,
750
+
IsVideo: isVideo,
751
+
ContentSrc: contentSrc,
752
+
RepoBlob_Output: resp,
753
+
Contents: resp.Content,
754
+
Lines: lines,
755
+
SizeHint: sizeHint,
756
+
IsBinary: isBinary,
626
757
})
627
758
}
628
759
···
637
768
ref := chi.URLParam(r, "ref")
638
769
filePath := chi.URLParam(r, "*")
639
770
640
-
protocol := "http"
771
+
scheme := "http"
641
772
if !rp.config.Core.Dev {
642
-
protocol = "https"
773
+
scheme = "https"
643
774
}
644
775
645
-
blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, filePath)
776
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name)
777
+
blobURL := fmt.Sprintf("%s://%s/xrpc/sh.tangled.repo.blob?repo=%s&ref=%s&path=%s&raw=true",
778
+
scheme, f.Knot, url.QueryEscape(repo), url.QueryEscape(ref), url.QueryEscape(filePath))
646
779
647
780
req, err := http.NewRequest("GET", blobURL, nil)
648
781
if err != nil {
···
685
818
return
686
819
}
687
820
688
-
if strings.Contains(contentType, "text/plain") {
821
+
if strings.HasPrefix(contentType, "text/") || isTextualMimeType(contentType) {
822
+
// serve all textual content as text/plain
689
823
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
690
824
w.Write(body)
691
825
} else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") {
826
+
// serve images and videos with their original content type
692
827
w.Header().Set("Content-Type", contentType)
693
828
w.Write(body)
694
829
} else {
···
698
833
}
699
834
}
700
835
836
+
// isTextualMimeType returns true if the MIME type represents textual content
837
+
// that should be served as text/plain
838
+
func isTextualMimeType(mimeType string) bool {
839
+
textualTypes := []string{
840
+
"application/json",
841
+
"application/xml",
842
+
"application/yaml",
843
+
"application/x-yaml",
844
+
"application/toml",
845
+
"application/javascript",
846
+
"application/ecmascript",
847
+
"message/",
848
+
}
849
+
850
+
return slices.Contains(textualTypes, mimeType)
851
+
}
852
+
701
853
// modify the spindle configured for this repo
702
854
func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) {
703
855
user := rp.oauth.GetUser(r)
···
1201
1353
f, err := rp.repoResolver.Resolve(r)
1202
1354
user := rp.oauth.GetUser(r)
1203
1355
1204
-
us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
1356
+
scheme := "http"
1357
+
if !rp.config.Core.Dev {
1358
+
scheme = "https"
1359
+
}
1360
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
1361
+
xrpcc := &indigoxrpc.Client{
1362
+
Host: host,
1363
+
}
1364
+
1365
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
1366
+
xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
1205
1367
if err != nil {
1206
-
log.Println("failed to create unsigned client", err)
1368
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1369
+
log.Println("failed to call XRPC repo.branches", xrpcerr)
1370
+
rp.pages.Error503(w)
1371
+
return
1372
+
}
1373
+
rp.pages.Error503(w)
1207
1374
return
1208
1375
}
1209
1376
1210
-
result, err := us.Branches(f.OwnerDid(), f.Name)
1211
-
if err != nil {
1377
+
var result types.RepoBranchesResponse
1378
+
if err := json.Unmarshal(xrpcBytes, &result); err != nil {
1379
+
log.Println("failed to decode XRPC response", err)
1212
1380
rp.pages.Error503(w)
1213
-
log.Println("failed to reach knotserver", err)
1214
1381
return
1215
1382
}
1216
1383
···
1581
1748
return
1582
1749
}
1583
1750
1584
-
us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
1751
+
scheme := "http"
1752
+
if !rp.config.Core.Dev {
1753
+
scheme = "https"
1754
+
}
1755
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
1756
+
xrpcc := &indigoxrpc.Client{
1757
+
Host: host,
1758
+
}
1759
+
1760
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
1761
+
branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
1585
1762
if err != nil {
1586
-
log.Printf("failed to create unsigned client for %s", f.Knot)
1587
-
rp.pages.Error503(w)
1763
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1764
+
log.Println("failed to call XRPC repo.branches", xrpcerr)
1765
+
rp.pages.Error503(w)
1766
+
return
1767
+
}
1768
+
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1588
1769
return
1589
1770
}
1590
1771
1591
-
result, err := us.Branches(f.OwnerDid(), f.Name)
1592
-
if err != nil {
1772
+
var branchResult types.RepoBranchesResponse
1773
+
if err := json.Unmarshal(branchBytes, &branchResult); err != nil {
1774
+
log.Println("failed to decode XRPC branches response", err)
1593
1775
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1594
-
log.Println("failed to reach knotserver", err)
1595
1776
return
1596
1777
}
1597
-
branches := result.Branches
1778
+
branches := branchResult.Branches
1598
1779
1599
1780
sortBranches(branches)
1600
1781
···
1618
1799
head = queryHead
1619
1800
}
1620
1801
1621
-
tags, err := us.Tags(f.OwnerDid(), f.Name)
1802
+
tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
1622
1803
if err != nil {
1804
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1805
+
log.Println("failed to call XRPC repo.tags", xrpcerr)
1806
+
rp.pages.Error503(w)
1807
+
return
1808
+
}
1623
1809
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1624
-
log.Println("failed to reach knotserver", err)
1810
+
return
1811
+
}
1812
+
1813
+
var tags types.RepoTagsResponse
1814
+
if err := json.Unmarshal(tagBytes, &tags); err != nil {
1815
+
log.Println("failed to decode XRPC tags response", err)
1816
+
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1625
1817
return
1626
1818
}
1627
1819
···
1673
1865
return
1674
1866
}
1675
1867
1676
-
us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
1868
+
scheme := "http"
1869
+
if !rp.config.Core.Dev {
1870
+
scheme = "https"
1871
+
}
1872
+
host := fmt.Sprintf("%s://%s", scheme, f.Knot)
1873
+
xrpcc := &indigoxrpc.Client{
1874
+
Host: host,
1875
+
}
1876
+
1877
+
repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
1878
+
1879
+
branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
1677
1880
if err != nil {
1678
-
log.Printf("failed to create unsigned client for %s", f.Knot)
1679
-
rp.pages.Error503(w)
1881
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1882
+
log.Println("failed to call XRPC repo.branches", xrpcerr)
1883
+
rp.pages.Error503(w)
1884
+
return
1885
+
}
1886
+
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1680
1887
return
1681
1888
}
1682
1889
1683
-
branches, err := us.Branches(f.OwnerDid(), f.Name)
1684
-
if err != nil {
1890
+
var branches types.RepoBranchesResponse
1891
+
if err := json.Unmarshal(branchBytes, &branches); err != nil {
1892
+
log.Println("failed to decode XRPC branches response", err)
1685
1893
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1686
-
log.Println("failed to reach knotserver", err)
1687
1894
return
1688
1895
}
1689
1896
1690
-
tags, err := us.Tags(f.OwnerDid(), f.Name)
1897
+
tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo)
1691
1898
if err != nil {
1899
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1900
+
log.Println("failed to call XRPC repo.tags", xrpcerr)
1901
+
rp.pages.Error503(w)
1902
+
return
1903
+
}
1692
1904
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1693
-
log.Println("failed to reach knotserver", err)
1694
1905
return
1695
1906
}
1696
1907
1697
-
formatPatch, err := us.Compare(f.OwnerDid(), f.Name, base, head)
1908
+
var tags types.RepoTagsResponse
1909
+
if err := json.Unmarshal(tagBytes, &tags); err != nil {
1910
+
log.Println("failed to decode XRPC tags response", err)
1911
+
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1912
+
return
1913
+
}
1914
+
1915
+
compareBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, base, head)
1698
1916
if err != nil {
1917
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1918
+
log.Println("failed to call XRPC repo.compare", xrpcerr)
1919
+
rp.pages.Error503(w)
1920
+
return
1921
+
}
1699
1922
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1700
-
log.Println("failed to compare", err)
1701
1923
return
1702
1924
}
1925
+
1926
+
var formatPatch types.RepoFormatPatchResponse
1927
+
if err := json.Unmarshal(compareBytes, &formatPatch); err != nil {
1928
+
log.Println("failed to decode XRPC compare response", err)
1929
+
rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1930
+
return
1931
+
}
1932
+
1703
1933
diff := patchutil.AsNiceDiff(formatPatch.Patch, base)
1704
1934
1705
1935
repoinfo := f.RepoInfo(user)
+11
-27
appview/serververify/verify.go
+11
-27
appview/serververify/verify.go
···
4
4
"context"
5
5
"errors"
6
6
"fmt"
7
-
"io"
8
-
"net/http"
9
-
"strings"
10
-
"time"
11
7
8
+
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
9
+
"tangled.sh/tangled.sh/core/api/tangled"
12
10
"tangled.sh/tangled.sh/core/appview/db"
11
+
"tangled.sh/tangled.sh/core/appview/xrpcclient"
13
12
"tangled.sh/tangled.sh/core/rbac"
14
13
)
15
14
···
24
23
scheme = "http"
25
24
}
26
25
27
-
url := fmt.Sprintf("%s://%s/owner", scheme, domain)
28
-
req, err := http.NewRequest("GET", url, nil)
29
-
if err != nil {
30
-
return "", err
31
-
}
32
-
33
-
client := &http.Client{
34
-
Timeout: 1 * time.Second,
35
-
}
36
-
37
-
resp, err := client.Do(req.WithContext(ctx))
38
-
if err != nil || resp.StatusCode != 200 {
39
-
return "", fmt.Errorf("failed to fetch /owner")
26
+
host := fmt.Sprintf("%s://%s", scheme, domain)
27
+
xrpcc := &indigoxrpc.Client{
28
+
Host: host,
40
29
}
41
30
42
-
body, err := io.ReadAll(io.LimitReader(resp.Body, 1024)) // read atmost 1kb of data
43
-
if err != nil {
44
-
return "", fmt.Errorf("failed to read /owner response: %w", err)
31
+
res, err := tangled.Owner(ctx, xrpcc)
32
+
if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
33
+
return "", xrpcerr
45
34
}
46
35
47
-
did := strings.TrimSpace(string(body))
48
-
if did == "" {
49
-
return "", fmt.Errorf("empty DID in /owner response")
50
-
}
51
-
52
-
return did, nil
36
+
return res.Owner, nil
53
37
}
54
38
55
39
type OwnerMismatch struct {
···
65
49
func RunVerification(ctx context.Context, domain, expectedOwner string, dev bool) error {
66
50
observedOwner, err := fetchOwner(ctx, domain, dev)
67
51
if err != nil {
68
-
return fmt.Errorf("%w: %w", FetchError, err)
52
+
return err
69
53
}
70
54
71
55
if observedOwner != expectedOwner {
+4
-3
appview/spindles/spindles.go
+4
-3
appview/spindles/spindles.go
···
16
16
"tangled.sh/tangled.sh/core/appview/oauth"
17
17
"tangled.sh/tangled.sh/core/appview/pages"
18
18
"tangled.sh/tangled.sh/core/appview/serververify"
19
+
"tangled.sh/tangled.sh/core/appview/xrpcclient"
19
20
"tangled.sh/tangled.sh/core/idresolver"
20
21
"tangled.sh/tangled.sh/core/rbac"
21
22
"tangled.sh/tangled.sh/core/tid"
···
404
405
if err != nil {
405
406
l.Error("verification failed", "err", err)
406
407
407
-
if errors.Is(err, serververify.FetchError) {
408
-
s.Pages.Notice(w, noticeId, "Failed to verify knot, unable to fetch owner.")
408
+
if errors.Is(err, xrpcclient.ErrXrpcUnsupported) {
409
+
s.Pages.Notice(w, noticeId, "Failed to verify spindle, XRPC queries are unsupported on this spindle, consider upgrading!")
409
410
return
410
411
}
411
412
···
442
443
}
443
444
444
445
w.Header().Set("HX-Reswap", "outerHTML")
445
-
s.Pages.SpindleListing(w, pages.SpindleListingParams{verifiedSpindle[0]})
446
+
s.Pages.SpindleListing(w, pages.SpindleListingParams{Spindle: verifiedSpindle[0]})
446
447
}
447
448
448
449
func (s *Spindles) addMember(w http.ResponseWriter, r *http.Request) {
+197
-137
appview/state/profile.go
+197
-137
appview/state/profile.go
···
17
17
"github.com/gorilla/feeds"
18
18
"tangled.sh/tangled.sh/core/api/tangled"
19
19
"tangled.sh/tangled.sh/core/appview/db"
20
-
"tangled.sh/tangled.sh/core/appview/oauth"
21
20
"tangled.sh/tangled.sh/core/appview/pages"
22
21
)
23
22
24
23
func (s *State) Profile(w http.ResponseWriter, r *http.Request) {
25
24
tabVal := r.URL.Query().Get("tab")
26
25
switch tabVal {
27
-
case "":
28
-
s.profileHomePage(w, r)
29
26
case "repos":
30
27
s.reposPage(w, r)
31
28
case "followers":
32
29
s.followersPage(w, r)
33
30
case "following":
34
31
s.followingPage(w, r)
32
+
case "starred":
33
+
s.starredPage(w, r)
34
+
case "strings":
35
+
s.stringsPage(w, r)
36
+
default:
37
+
s.profileOverview(w, r)
35
38
}
36
39
}
37
40
38
-
type ProfilePageParams struct {
39
-
Id identity.Identity
40
-
LoggedInUser *oauth.User
41
-
Card pages.ProfileCard
42
-
}
43
-
44
-
func (s *State) profilePage(w http.ResponseWriter, r *http.Request) *ProfilePageParams {
41
+
func (s *State) profile(r *http.Request) (*pages.ProfileCard, error) {
45
42
didOrHandle := chi.URLParam(r, "user")
46
43
if didOrHandle == "" {
47
-
http.Error(w, "bad request", http.StatusBadRequest)
48
-
return nil
44
+
return nil, fmt.Errorf("empty DID or handle")
49
45
}
50
46
51
47
ident, ok := r.Context().Value("resolvedId").(identity.Identity)
52
48
if !ok {
53
-
log.Printf("malformed middleware")
54
-
w.WriteHeader(http.StatusInternalServerError)
55
-
return nil
49
+
return nil, fmt.Errorf("failed to resolve ID")
56
50
}
57
51
did := ident.DID.String()
58
52
59
53
profile, err := db.GetProfile(s.db, did)
60
54
if err != nil {
61
-
log.Printf("getting profile data for %s: %s", did, err)
62
-
s.pages.Error500(w)
63
-
return nil
55
+
return nil, fmt.Errorf("failed to get profile: %w", err)
56
+
}
57
+
58
+
repoCount, err := db.CountRepos(s.db, db.FilterEq("did", did))
59
+
if err != nil {
60
+
return nil, fmt.Errorf("failed to get repo count: %w", err)
61
+
}
62
+
63
+
stringCount, err := db.CountStrings(s.db, db.FilterEq("did", did))
64
+
if err != nil {
65
+
return nil, fmt.Errorf("failed to get string count: %w", err)
66
+
}
67
+
68
+
starredCount, err := db.CountStars(s.db, db.FilterEq("starred_by_did", did))
69
+
if err != nil {
70
+
return nil, fmt.Errorf("failed to get starred repo count: %w", err)
64
71
}
65
72
66
73
followStats, err := db.GetFollowerFollowingCount(s.db, did)
67
74
if err != nil {
68
-
log.Printf("getting follow stats for %s: %s", did, err)
75
+
return nil, fmt.Errorf("failed to get follower stats: %w", err)
69
76
}
70
77
71
78
loggedInUser := s.oauth.GetUser(r)
···
74
81
followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, did)
75
82
}
76
83
77
-
return &ProfilePageParams{
78
-
Id: ident,
79
-
LoggedInUser: loggedInUser,
80
-
Card: pages.ProfileCard{
81
-
UserDid: did,
82
-
UserHandle: ident.Handle.String(),
83
-
Profile: profile,
84
-
FollowStatus: followStatus,
84
+
now := time.Now()
85
+
startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC)
86
+
punchcard, err := db.MakePunchcard(
87
+
s.db,
88
+
db.FilterEq("did", did),
89
+
db.FilterGte("date", startOfYear.Format(time.DateOnly)),
90
+
db.FilterLte("date", now.Format(time.DateOnly)),
91
+
)
92
+
if err != nil {
93
+
return nil, fmt.Errorf("failed to get punchcard for %s: %w", did, err)
94
+
}
95
+
96
+
return &pages.ProfileCard{
97
+
UserDid: did,
98
+
UserHandle: ident.Handle.String(),
99
+
Profile: profile,
100
+
FollowStatus: followStatus,
101
+
Stats: pages.ProfileStats{
102
+
RepoCount: repoCount,
103
+
StringCount: stringCount,
104
+
StarredCount: starredCount,
85
105
FollowersCount: followStats.Followers,
86
106
FollowingCount: followStats.Following,
87
107
},
88
-
}
108
+
Punchcard: punchcard,
109
+
}, nil
89
110
}
90
111
91
-
func (s *State) profileHomePage(w http.ResponseWriter, r *http.Request) {
92
-
pageWithProfile := s.profilePage(w, r)
93
-
if pageWithProfile == nil {
112
+
func (s *State) profileOverview(w http.ResponseWriter, r *http.Request) {
113
+
l := s.logger.With("handler", "profileHomePage")
114
+
115
+
profile, err := s.profile(r)
116
+
if err != nil {
117
+
l.Error("failed to build profile card", "err", err)
118
+
s.pages.Error500(w)
94
119
return
95
120
}
121
+
l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle)
96
122
97
-
id := pageWithProfile.Id
98
123
repos, err := db.GetRepos(
99
124
s.db,
100
125
0,
101
-
db.FilterEq("did", id.DID),
126
+
db.FilterEq("did", profile.UserDid),
102
127
)
103
128
if err != nil {
104
-
log.Printf("getting repos for %s: %s", id.DID, err)
129
+
l.Error("failed to fetch repos", "err", err)
105
130
}
106
131
107
-
profile := pageWithProfile.Card.Profile
108
132
// filter out ones that are pinned
109
133
pinnedRepos := []db.Repo{}
110
134
for i, r := range repos {
111
135
// if this is a pinned repo, add it
112
-
if slices.Contains(profile.PinnedRepos[:], r.RepoAt()) {
136
+
if slices.Contains(profile.Profile.PinnedRepos[:], r.RepoAt()) {
113
137
pinnedRepos = append(pinnedRepos, r)
114
138
}
115
139
116
140
// if there are no saved pins, add the first 4 repos
117
-
if profile.IsPinnedReposEmpty() && i < 4 {
141
+
if profile.Profile.IsPinnedReposEmpty() && i < 4 {
118
142
pinnedRepos = append(pinnedRepos, r)
119
143
}
120
144
}
121
145
122
-
collaboratingRepos, err := db.CollaboratingIn(s.db, id.DID.String())
146
+
collaboratingRepos, err := db.CollaboratingIn(s.db, profile.UserDid)
123
147
if err != nil {
124
-
log.Printf("getting collaborating repos for %s: %s", id.DID, err)
148
+
l.Error("failed to fetch collaborating repos", "err", err)
125
149
}
126
150
127
151
pinnedCollaboratingRepos := []db.Repo{}
128
152
for _, r := range collaboratingRepos {
129
153
// if this is a pinned repo, add it
130
-
if slices.Contains(profile.PinnedRepos[:], r.RepoAt()) {
154
+
if slices.Contains(profile.Profile.PinnedRepos[:], r.RepoAt()) {
131
155
pinnedCollaboratingRepos = append(pinnedCollaboratingRepos, r)
132
156
}
133
157
}
134
158
135
-
timeline, err := db.MakeProfileTimeline(s.db, id.DID.String())
159
+
timeline, err := db.MakeProfileTimeline(s.db, profile.UserDid)
136
160
if err != nil {
137
-
log.Printf("failed to create profile timeline for %s: %s", id.DID, err)
161
+
l.Error("failed to create timeline", "err", err)
138
162
}
139
163
140
-
var didsToResolve []string
141
-
for _, r := range collaboratingRepos {
142
-
didsToResolve = append(didsToResolve, r.Did)
164
+
s.pages.ProfileOverview(w, pages.ProfileOverviewParams{
165
+
LoggedInUser: s.oauth.GetUser(r),
166
+
Card: profile,
167
+
Repos: pinnedRepos,
168
+
CollaboratingRepos: pinnedCollaboratingRepos,
169
+
ProfileTimeline: timeline,
170
+
})
171
+
}
172
+
173
+
func (s *State) reposPage(w http.ResponseWriter, r *http.Request) {
174
+
l := s.logger.With("handler", "reposPage")
175
+
176
+
profile, err := s.profile(r)
177
+
if err != nil {
178
+
l.Error("failed to build profile card", "err", err)
179
+
s.pages.Error500(w)
180
+
return
143
181
}
144
-
for _, byMonth := range timeline.ByMonth {
145
-
for _, pe := range byMonth.PullEvents.Items {
146
-
didsToResolve = append(didsToResolve, pe.Repo.Did)
147
-
}
148
-
for _, ie := range byMonth.IssueEvents.Items {
149
-
didsToResolve = append(didsToResolve, ie.Metadata.Repo.Did)
150
-
}
151
-
for _, re := range byMonth.RepoEvents {
152
-
didsToResolve = append(didsToResolve, re.Repo.Did)
153
-
if re.Source != nil {
154
-
didsToResolve = append(didsToResolve, re.Source.Did)
155
-
}
156
-
}
157
-
}
182
+
l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle)
158
183
159
-
now := time.Now()
160
-
startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC)
161
-
punchcard, err := db.MakePunchcard(
184
+
repos, err := db.GetRepos(
162
185
s.db,
163
-
db.FilterEq("did", id.DID),
164
-
db.FilterGte("date", startOfYear.Format(time.DateOnly)),
165
-
db.FilterLte("date", now.Format(time.DateOnly)),
186
+
0,
187
+
db.FilterEq("did", profile.UserDid),
166
188
)
167
189
if err != nil {
168
-
log.Println("failed to get punchcard for did", "did", id.DID, "err", err)
190
+
l.Error("failed to get repos", "err", err)
191
+
s.pages.Error500(w)
192
+
return
169
193
}
170
194
171
-
s.pages.ProfileHomePage(w, pages.ProfileHomePageParams{
172
-
LoggedInUser: pageWithProfile.LoggedInUser,
173
-
Repos: pinnedRepos,
174
-
CollaboratingRepos: pinnedCollaboratingRepos,
175
-
Card: pageWithProfile.Card,
176
-
Punchcard: punchcard,
177
-
ProfileTimeline: timeline,
195
+
err = s.pages.ProfileRepos(w, pages.ProfileReposParams{
196
+
LoggedInUser: s.oauth.GetUser(r),
197
+
Repos: repos,
198
+
Card: profile,
178
199
})
179
200
}
180
201
181
-
func (s *State) reposPage(w http.ResponseWriter, r *http.Request) {
182
-
pageWithProfile := s.profilePage(w, r)
183
-
if pageWithProfile == nil {
202
+
func (s *State) starredPage(w http.ResponseWriter, r *http.Request) {
203
+
l := s.logger.With("handler", "starredPage")
204
+
205
+
profile, err := s.profile(r)
206
+
if err != nil {
207
+
l.Error("failed to build profile card", "err", err)
208
+
s.pages.Error500(w)
184
209
return
185
210
}
211
+
l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle)
186
212
187
-
id := pageWithProfile.Id
213
+
stars, err := db.GetStars(s.db, 0, db.FilterEq("starred_by_did", profile.UserDid))
214
+
if err != nil {
215
+
l.Error("failed to get stars", "err", err)
216
+
s.pages.Error500(w)
217
+
return
218
+
}
219
+
var repoAts []string
220
+
for _, s := range stars {
221
+
repoAts = append(repoAts, string(s.RepoAt))
222
+
}
223
+
188
224
repos, err := db.GetRepos(
189
225
s.db,
190
226
0,
191
-
db.FilterEq("did", id.DID),
227
+
db.FilterIn("at_uri", repoAts),
192
228
)
193
229
if err != nil {
194
-
log.Printf("getting repos for %s: %s", id.DID, err)
230
+
l.Error("failed to get repos", "err", err)
231
+
s.pages.Error500(w)
232
+
return
195
233
}
196
234
197
-
s.pages.ReposPage(w, pages.ReposPageParams{
198
-
LoggedInUser: pageWithProfile.LoggedInUser,
235
+
err = s.pages.ProfileStarred(w, pages.ProfileStarredParams{
236
+
LoggedInUser: s.oauth.GetUser(r),
199
237
Repos: repos,
200
-
Card: pageWithProfile.Card,
238
+
Card: profile,
239
+
})
240
+
}
241
+
242
+
func (s *State) stringsPage(w http.ResponseWriter, r *http.Request) {
243
+
l := s.logger.With("handler", "stringsPage")
244
+
245
+
profile, err := s.profile(r)
246
+
if err != nil {
247
+
l.Error("failed to build profile card", "err", err)
248
+
s.pages.Error500(w)
249
+
return
250
+
}
251
+
l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle)
252
+
253
+
strings, err := db.GetStrings(s.db, 0, db.FilterEq("did", profile.UserDid))
254
+
if err != nil {
255
+
l.Error("failed to get strings", "err", err)
256
+
s.pages.Error500(w)
257
+
return
258
+
}
259
+
260
+
err = s.pages.ProfileStrings(w, pages.ProfileStringsParams{
261
+
LoggedInUser: s.oauth.GetUser(r),
262
+
Strings: strings,
263
+
Card: profile,
201
264
})
202
265
}
203
266
204
267
type FollowsPageParams struct {
205
-
LoggedInUser *oauth.User
206
-
Follows []pages.FollowCard
207
-
Card pages.ProfileCard
268
+
Follows []pages.FollowCard
269
+
Card *pages.ProfileCard
208
270
}
209
271
210
-
func (s *State) followPage(w http.ResponseWriter, r *http.Request, fetchFollows func(db.Execer, string) ([]db.Follow, error), extractDid func(db.Follow) string) (FollowsPageParams, error) {
211
-
pageWithProfile := s.profilePage(w, r)
212
-
if pageWithProfile == nil {
213
-
return FollowsPageParams{}, nil
272
+
func (s *State) followPage(
273
+
r *http.Request,
274
+
fetchFollows func(db.Execer, string) ([]db.Follow, error),
275
+
extractDid func(db.Follow) string,
276
+
) (*FollowsPageParams, error) {
277
+
l := s.logger.With("handler", "reposPage")
278
+
279
+
profile, err := s.profile(r)
280
+
if err != nil {
281
+
return nil, err
214
282
}
283
+
l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle)
215
284
216
-
id := pageWithProfile.Id
217
-
loggedInUser := pageWithProfile.LoggedInUser
285
+
loggedInUser := s.oauth.GetUser(r)
286
+
params := FollowsPageParams{
287
+
Card: profile,
288
+
}
218
289
219
-
follows, err := fetchFollows(s.db, id.DID.String())
290
+
follows, err := fetchFollows(s.db, profile.UserDid)
220
291
if err != nil {
221
-
log.Printf("getting followers for %s: %s", id.DID, err)
222
-
return FollowsPageParams{}, err
292
+
l.Error("failed to fetch follows", "err", err)
293
+
return ¶ms, err
223
294
}
224
295
225
296
if len(follows) == 0 {
226
-
return FollowsPageParams{
227
-
LoggedInUser: loggedInUser,
228
-
Follows: []pages.FollowCard{},
229
-
Card: pageWithProfile.Card,
230
-
}, nil
297
+
return ¶ms, nil
231
298
}
232
299
233
300
followDids := make([]string, 0, len(follows))
···
237
304
238
305
profiles, err := db.GetProfiles(s.db, db.FilterIn("did", followDids))
239
306
if err != nil {
240
-
log.Printf("getting profile for %s: %s", followDids, err)
241
-
return FollowsPageParams{}, err
307
+
l.Error("failed to get profiles", "followDids", followDids, "err", err)
308
+
return ¶ms, err
242
309
}
243
310
244
311
followStatsMap, err := db.GetFollowerFollowingCounts(s.db, followDids)
···
246
313
log.Printf("getting follow counts for %s: %s", followDids, err)
247
314
}
248
315
249
-
var loggedInUserFollowing map[string]struct{}
316
+
loggedInUserFollowing := make(map[string]struct{})
250
317
if loggedInUser != nil {
251
318
following, err := db.GetFollowing(s.db, loggedInUser.Did)
252
319
if err != nil {
253
-
return FollowsPageParams{}, err
320
+
l.Error("failed to get follow list", "err", err, "loggedInUser", loggedInUser.Did)
321
+
return ¶ms, err
254
322
}
255
-
if len(following) > 0 {
256
-
loggedInUserFollowing = make(map[string]struct{}, len(following))
257
-
for _, follow := range following {
258
-
loggedInUserFollowing[follow.SubjectDid] = struct{}{}
259
-
}
323
+
loggedInUserFollowing = make(map[string]struct{}, len(following))
324
+
for _, follow := range following {
325
+
loggedInUserFollowing[follow.SubjectDid] = struct{}{}
260
326
}
261
327
}
262
328
263
-
followCards := make([]pages.FollowCard, 0, len(follows))
264
-
for _, did := range followDids {
265
-
followStats, exists := followStatsMap[did]
266
-
if !exists {
267
-
followStats = db.FollowStats{}
268
-
}
329
+
followCards := make([]pages.FollowCard, len(follows))
330
+
for i, did := range followDids {
331
+
followStats := followStatsMap[did]
269
332
followStatus := db.IsNotFollowing
270
-
if loggedInUserFollowing != nil {
271
-
if _, exists := loggedInUserFollowing[did]; exists {
272
-
followStatus = db.IsFollowing
273
-
} else if loggedInUser.Did == did {
274
-
followStatus = db.IsSelf
275
-
}
333
+
if _, exists := loggedInUserFollowing[did]; exists {
334
+
followStatus = db.IsFollowing
335
+
} else if loggedInUser != nil && loggedInUser.Did == did {
336
+
followStatus = db.IsSelf
276
337
}
338
+
277
339
var profile *db.Profile
278
340
if p, exists := profiles[did]; exists {
279
341
profile = p
···
281
343
profile = &db.Profile{}
282
344
profile.Did = did
283
345
}
284
-
followCards = append(followCards, pages.FollowCard{
346
+
followCards[i] = pages.FollowCard{
285
347
UserDid: did,
286
348
FollowStatus: followStatus,
287
349
FollowersCount: followStats.Followers,
288
350
FollowingCount: followStats.Following,
289
351
Profile: profile,
290
-
})
352
+
}
291
353
}
292
354
293
-
return FollowsPageParams{
294
-
LoggedInUser: loggedInUser,
295
-
Follows: followCards,
296
-
Card: pageWithProfile.Card,
297
-
}, nil
355
+
params.Follows = followCards
356
+
357
+
return ¶ms, nil
298
358
}
299
359
300
360
func (s *State) followersPage(w http.ResponseWriter, r *http.Request) {
301
-
followPage, err := s.followPage(w, r, db.GetFollowers, func(f db.Follow) string { return f.UserDid })
361
+
followPage, err := s.followPage(r, db.GetFollowers, func(f db.Follow) string { return f.UserDid })
302
362
if err != nil {
303
363
s.pages.Notice(w, "all-followers", "Failed to load followers")
304
364
return
305
365
}
306
366
307
-
s.pages.FollowersPage(w, pages.FollowersPageParams{
308
-
LoggedInUser: followPage.LoggedInUser,
367
+
s.pages.ProfileFollowers(w, pages.ProfileFollowersParams{
368
+
LoggedInUser: s.oauth.GetUser(r),
309
369
Followers: followPage.Follows,
310
370
Card: followPage.Card,
311
371
})
312
372
}
313
373
314
374
func (s *State) followingPage(w http.ResponseWriter, r *http.Request) {
315
-
followPage, err := s.followPage(w, r, db.GetFollowing, func(f db.Follow) string { return f.SubjectDid })
375
+
followPage, err := s.followPage(r, db.GetFollowing, func(f db.Follow) string { return f.SubjectDid })
316
376
if err != nil {
317
377
s.pages.Notice(w, "all-following", "Failed to load following")
318
378
return
319
379
}
320
380
321
-
s.pages.FollowingPage(w, pages.FollowingPageParams{
322
-
LoggedInUser: followPage.LoggedInUser,
381
+
s.pages.ProfileFollowing(w, pages.ProfileFollowingParams{
382
+
LoggedInUser: s.oauth.GetUser(r),
323
383
Following: followPage.Follows,
324
384
Card: followPage.Card,
325
385
})
···
408
468
409
469
func (s *State) addIssueItems(ctx context.Context, feed *feeds.Feed, issues []*db.Issue, author *feeds.Author) error {
410
470
for _, issue := range issues {
411
-
owner, err := s.idResolver.ResolveIdent(ctx, issue.Metadata.Repo.Did)
471
+
owner, err := s.idResolver.ResolveIdent(ctx, issue.Repo.Did)
412
472
if err != nil {
413
473
return err
414
474
}
···
440
500
441
501
func (s *State) createIssueItem(issue *db.Issue, owner *identity.Identity, author *feeds.Author) *feeds.Item {
442
502
return &feeds.Item{
443
-
Title: fmt.Sprintf("%s created issue '%s' in @%s/%s", author.Name, issue.Title, owner.Handle, issue.Metadata.Repo.Name),
444
-
Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/issues/%d", s.config.Core.AppviewHost, owner.Handle, issue.Metadata.Repo.Name, issue.IssueId), Type: "text/html", Rel: "alternate"},
503
+
Title: fmt.Sprintf("%s created issue '%s' in @%s/%s", author.Name, issue.Title, owner.Handle, issue.Repo.Name),
504
+
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"},
445
505
Created: issue.Created,
446
506
Author: author,
447
507
}
···
642
702
log.Printf("getting profile data for %s: %s", user.Did, err)
643
703
}
644
704
645
-
repos, err := db.GetAllReposByDid(s.db, user.Did)
705
+
repos, err := db.GetRepos(s.db, 0, db.FilterEq("did", user.Did))
646
706
if err != nil {
647
707
log.Printf("getting repos for %s: %s", user.Did, err)
648
708
}
+4
-2
appview/state/router.go
+4
-2
appview/state/router.go
···
111
111
112
112
r.Handle("/static/*", s.pages.Static())
113
113
114
-
r.Get("/", s.Timeline)
114
+
r.Get("/", s.HomeOrTimeline)
115
+
r.Get("/timeline", s.Timeline)
116
+
r.With(middleware.AuthMiddleware(s.oauth)).Get("/upgradeBanner", s.UpgradeBanner)
115
117
116
118
r.Route("/repo", func(r chi.Router) {
117
119
r.Route("/new", func(r chi.Router) {
···
230
232
}
231
233
232
234
func (s *State) IssuesRouter(mw *middleware.Middleware) http.Handler {
233
-
issues := issues.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.notifier)
235
+
issues := issues.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.notifier, s.validator)
234
236
return issues.Router(mw)
235
237
}
236
238
+78
-4
appview/state/state.go
+78
-4
appview/state/state.go
···
28
28
"tangled.sh/tangled.sh/core/appview/pages"
29
29
posthogService "tangled.sh/tangled.sh/core/appview/posthog"
30
30
"tangled.sh/tangled.sh/core/appview/reporesolver"
31
+
"tangled.sh/tangled.sh/core/appview/validator"
31
32
xrpcclient "tangled.sh/tangled.sh/core/appview/xrpcclient"
32
33
"tangled.sh/tangled.sh/core/eventconsumer"
33
34
"tangled.sh/tangled.sh/core/idresolver"
···
53
54
knotstream *eventconsumer.Consumer
54
55
spindlestream *eventconsumer.Consumer
55
56
logger *slog.Logger
57
+
validator *validator.Validator
56
58
}
57
59
58
60
func Make(ctx context.Context, config *config.Config) (*State, error) {
···
73
75
}
74
76
75
77
pgs := pages.NewPages(config, res)
76
-
77
78
cache := cache.New(config.Redis.Addr)
78
79
sess := session.New(cache)
79
-
80
80
oauth := oauth.NewOAuth(config, sess)
81
+
validator := validator.New(d)
81
82
82
83
posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint})
83
84
if err != nil {
···
99
100
tangled.SpindleMemberNSID,
100
101
tangled.SpindleNSID,
101
102
tangled.StringNSID,
103
+
tangled.RepoIssueNSID,
104
+
tangled.RepoIssueCommentNSID,
102
105
},
103
106
nil,
104
107
slog.Default(),
···
119
122
IdResolver: res,
120
123
Config: config,
121
124
Logger: tlog.New("ingester"),
125
+
Validator: validator,
122
126
}
123
127
err = jc.StartJetstream(ctx, ingester.Ingest())
124
128
if err != nil {
···
158
162
knotstream,
159
163
spindlestream,
160
164
slog.Default(),
165
+
validator,
161
166
}
162
167
163
168
return state, nil
164
169
}
165
170
171
+
func (s *State) Close() error {
172
+
// other close up logic goes here
173
+
return s.db.Close()
174
+
}
175
+
166
176
func (s *State) Favicon(w http.ResponseWriter, r *http.Request) {
167
177
w.Header().Set("Content-Type", "image/svg+xml")
168
178
w.Header().Set("Cache-Control", "public, max-age=31536000") // one year
···
190
200
})
191
201
}
192
202
203
+
func (s *State) HomeOrTimeline(w http.ResponseWriter, r *http.Request) {
204
+
if s.oauth.GetUser(r) != nil {
205
+
s.Timeline(w, r)
206
+
return
207
+
}
208
+
s.Home(w, r)
209
+
}
210
+
193
211
func (s *State) Timeline(w http.ResponseWriter, r *http.Request) {
194
212
user := s.oauth.GetUser(r)
195
213
196
-
timeline, err := db.MakeTimeline(s.db)
214
+
timeline, err := db.MakeTimeline(s.db, 50)
197
215
if err != nil {
198
216
log.Println(err)
199
217
s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.")
···
213
231
})
214
232
}
215
233
234
+
func (s *State) UpgradeBanner(w http.ResponseWriter, r *http.Request) {
235
+
user := s.oauth.GetUser(r)
236
+
l := s.logger.With("handler", "UpgradeBanner")
237
+
l = l.With("did", user.Did)
238
+
l = l.With("handle", user.Handle)
239
+
240
+
regs, err := db.GetRegistrations(
241
+
s.db,
242
+
db.FilterEq("did", user.Did),
243
+
db.FilterEq("needs_upgrade", 1),
244
+
)
245
+
if err != nil {
246
+
l.Error("non-fatal: failed to get registrations", "err", err)
247
+
}
248
+
249
+
spindles, err := db.GetSpindles(
250
+
s.db,
251
+
db.FilterEq("owner", user.Did),
252
+
db.FilterEq("needs_upgrade", 1),
253
+
)
254
+
if err != nil {
255
+
l.Error("non-fatal: failed to get spindles", "err", err)
256
+
}
257
+
258
+
if regs == nil && spindles == nil {
259
+
return
260
+
}
261
+
262
+
s.pages.UpgradeBanner(w, pages.UpgradeBannerParams{
263
+
Registrations: regs,
264
+
Spindles: spindles,
265
+
})
266
+
}
267
+
268
+
func (s *State) Home(w http.ResponseWriter, r *http.Request) {
269
+
timeline, err := db.MakeTimeline(s.db, 5)
270
+
if err != nil {
271
+
log.Println(err)
272
+
s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.")
273
+
return
274
+
}
275
+
276
+
repos, err := db.GetTopStarredReposLastWeek(s.db)
277
+
if err != nil {
278
+
log.Println(err)
279
+
s.pages.Notice(w, "topstarredrepos", "Unable to load.")
280
+
return
281
+
}
282
+
283
+
s.pages.Home(w, pages.TimelineParams{
284
+
LoggedInUser: nil,
285
+
Timeline: timeline,
286
+
Repos: repos,
287
+
})
288
+
}
289
+
216
290
func (s *State) Keys(w http.ResponseWriter, r *http.Request) {
217
291
user := chi.URLParam(r, "user")
218
292
user = strings.TrimPrefix(user, "@")
···
241
315
242
316
for _, k := range pubKeys {
243
317
key := strings.TrimRight(k.Key, "\n")
244
-
w.Write([]byte(fmt.Sprintln(key)))
318
+
fmt.Fprintln(w, key)
245
319
}
246
320
}
247
321
+1
-59
appview/strings/strings.go
+1
-59
appview/strings/strings.go
···
5
5
"log/slog"
6
6
"net/http"
7
7
"path"
8
-
"slices"
9
8
"strconv"
10
9
"time"
11
10
···
161
160
}
162
161
163
162
func (s *Strings) dashboard(w http.ResponseWriter, r *http.Request) {
164
-
l := s.Logger.With("handler", "dashboard")
165
-
166
-
id, ok := r.Context().Value("resolvedId").(identity.Identity)
167
-
if !ok {
168
-
l.Error("malformed middleware")
169
-
w.WriteHeader(http.StatusInternalServerError)
170
-
return
171
-
}
172
-
l = l.With("did", id.DID, "handle", id.Handle)
173
-
174
-
all, err := db.GetStrings(
175
-
s.Db,
176
-
0,
177
-
db.FilterEq("did", id.DID),
178
-
)
179
-
if err != nil {
180
-
l.Error("failed to fetch strings", "err", err)
181
-
w.WriteHeader(http.StatusInternalServerError)
182
-
return
183
-
}
184
-
185
-
slices.SortFunc(all, func(a, b db.String) int {
186
-
if a.Created.After(b.Created) {
187
-
return -1
188
-
} else {
189
-
return 1
190
-
}
191
-
})
192
-
193
-
profile, err := db.GetProfile(s.Db, id.DID.String())
194
-
if err != nil {
195
-
l.Error("failed to fetch user profile", "err", err)
196
-
w.WriteHeader(http.StatusInternalServerError)
197
-
return
198
-
}
199
-
loggedInUser := s.OAuth.GetUser(r)
200
-
followStatus := db.IsNotFollowing
201
-
if loggedInUser != nil {
202
-
followStatus = db.GetFollowStatus(s.Db, loggedInUser.Did, id.DID.String())
203
-
}
204
-
205
-
followStats, err := db.GetFollowerFollowingCount(s.Db, id.DID.String())
206
-
if err != nil {
207
-
l.Error("failed to get follow stats", "err", err)
208
-
}
209
-
210
-
s.Pages.StringsDashboard(w, pages.StringsDashboardParams{
211
-
LoggedInUser: s.OAuth.GetUser(r),
212
-
Card: pages.ProfileCard{
213
-
UserDid: id.DID.String(),
214
-
UserHandle: id.Handle.String(),
215
-
Profile: profile,
216
-
FollowStatus: followStatus,
217
-
FollowersCount: followStats.Followers,
218
-
FollowingCount: followStats.Following,
219
-
},
220
-
Strings: all,
221
-
})
163
+
http.Redirect(w, r, fmt.Sprintf("/%s?tab=strings", chi.URLParam(r, "user")), http.StatusFound)
222
164
}
223
165
224
166
func (s *Strings) edit(w http.ResponseWriter, r *http.Request) {
+53
appview/validator/issue.go
+53
appview/validator/issue.go
···
1
+
package validator
2
+
3
+
import (
4
+
"fmt"
5
+
"strings"
6
+
7
+
"tangled.sh/tangled.sh/core/appview/db"
8
+
)
9
+
10
+
func (v *Validator) ValidateIssueComment(comment *db.IssueComment) error {
11
+
// if comments have parents, only ingest ones that are 1 level deep
12
+
if comment.ReplyTo != nil {
13
+
parents, err := db.GetIssueComments(v.db, db.FilterEq("at_uri", *comment.ReplyTo))
14
+
if err != nil {
15
+
return fmt.Errorf("failed to fetch parent comment: %w", err)
16
+
}
17
+
if len(parents) != 1 {
18
+
return fmt.Errorf("incorrect number of parent comments returned: %d", len(parents))
19
+
}
20
+
21
+
// depth check
22
+
parent := parents[0]
23
+
if parent.ReplyTo != nil {
24
+
return fmt.Errorf("incorrect depth, this comment is replying at depth >1")
25
+
}
26
+
}
27
+
28
+
if sb := strings.TrimSpace(v.sanitizer.SanitizeDefault(comment.Body)); sb == "" {
29
+
return fmt.Errorf("body is empty after HTML sanitization")
30
+
}
31
+
32
+
return nil
33
+
}
34
+
35
+
func (v *Validator) ValidateIssue(issue *db.Issue) error {
36
+
if issue.Title == "" {
37
+
return fmt.Errorf("issue title is empty")
38
+
}
39
+
40
+
if issue.Body == "" {
41
+
return fmt.Errorf("issue body is empty")
42
+
}
43
+
44
+
if st := strings.TrimSpace(v.sanitizer.SanitizeDescription(issue.Title)); st == "" {
45
+
return fmt.Errorf("title is empty after HTML sanitization")
46
+
}
47
+
48
+
if sb := strings.TrimSpace(v.sanitizer.SanitizeDefault(issue.Body)); sb == "" {
49
+
return fmt.Errorf("body is empty after HTML sanitization")
50
+
}
51
+
52
+
return nil
53
+
}
+18
appview/validator/validator.go
+18
appview/validator/validator.go
···
1
+
package validator
2
+
3
+
import (
4
+
"tangled.sh/tangled.sh/core/appview/db"
5
+
"tangled.sh/tangled.sh/core/appview/pages/markup"
6
+
)
7
+
8
+
type Validator struct {
9
+
db *db.DB
10
+
sanitizer markup.Sanitizer
11
+
}
12
+
13
+
func New(db *db.DB) *Validator {
14
+
return &Validator{
15
+
db: db,
16
+
sanitizer: markup.NewSanitizer(),
17
+
}
18
+
}
+11
-5
appview/xrpcclient/xrpc.go
+11
-5
appview/xrpcclient/xrpc.go
···
4
4
"bytes"
5
5
"context"
6
6
"errors"
7
-
"fmt"
8
7
"io"
9
8
"net/http"
10
9
···
12
11
"github.com/bluesky-social/indigo/xrpc"
13
12
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
14
13
oauth "tangled.sh/icyphox.sh/atproto-oauth"
14
+
)
15
+
16
+
var (
17
+
ErrXrpcUnsupported = errors.New("xrpc not supported on this knot")
18
+
ErrXrpcUnauthorized = errors.New("unauthorized xrpc request")
19
+
ErrXrpcFailed = errors.New("xrpc request failed")
20
+
ErrXrpcInvalid = errors.New("invalid xrpc request")
15
21
)
16
22
17
23
type Client struct {
···
115
121
116
122
var xrpcerr *indigoxrpc.Error
117
123
if ok := errors.As(err, &xrpcerr); !ok {
118
-
return fmt.Errorf("Recieved invalid XRPC error response.")
124
+
return ErrXrpcInvalid
119
125
}
120
126
121
127
switch xrpcerr.StatusCode {
122
128
case http.StatusNotFound:
123
-
return fmt.Errorf("XRPC is unsupported on this knot, consider upgrading your knot.")
129
+
return ErrXrpcUnsupported
124
130
case http.StatusUnauthorized:
125
-
return fmt.Errorf("Unauthorized XRPC request.")
131
+
return ErrXrpcUnauthorized
126
132
default:
127
-
return fmt.Errorf("Failed to perform operation. Try again later.")
133
+
return ErrXrpcFailed
128
134
}
129
135
}
+3
cmd/appview/main.go
+3
cmd/appview/main.go
+5
-4
cmd/gen.go
+5
-4
cmd/gen.go
···
18
18
tangled.FeedReaction{},
19
19
tangled.FeedStar{},
20
20
tangled.GitRefUpdate{},
21
+
tangled.GitRefUpdate_CommitCountBreakdown{},
22
+
tangled.GitRefUpdate_IndividualEmailCommitCount{},
23
+
tangled.GitRefUpdate_LangBreakdown{},
24
+
tangled.GitRefUpdate_IndividualLanguageSize{},
21
25
tangled.GitRefUpdate_Meta{},
22
-
tangled.GitRefUpdate_Meta_CommitCount{},
23
-
tangled.GitRefUpdate_Meta_CommitCount_ByEmail_Elem{},
24
-
tangled.GitRefUpdate_Meta_LangBreakdown{},
25
-
tangled.GitRefUpdate_Pair{},
26
26
tangled.GraphFollow{},
27
27
tangled.Knot{},
28
28
tangled.KnotMember{},
···
47
47
tangled.RepoPullComment{},
48
48
tangled.RepoPull_Source{},
49
49
tangled.RepoPullStatus{},
50
+
tangled.RepoPull_Target{},
50
51
tangled.Spindle{},
51
52
tangled.SpindleMember{},
52
53
tangled.String{},
+3
-3
docs/contributing.md
+3
-3
docs/contributing.md
···
11
11
### message format
12
12
13
13
```
14
-
<service/top-level directory>: <affected package/directory>: <short summary of change>
14
+
<service/top-level directory>/<affected package/directory>: <short summary of change>
15
15
16
16
17
17
Optional longer description can go here, if necessary. Explain what the
···
23
23
Here are some examples:
24
24
25
25
```
26
-
appview: state: fix token expiry check in middleware
26
+
appview/state: fix token expiry check in middleware
27
27
28
28
The previous check did not account for clock drift, leading to premature
29
29
token invalidation.
30
30
```
31
31
32
32
```
33
-
knotserver: git/service: improve error checking in upload-pack
33
+
knotserver/git/service: improve error checking in upload-pack
34
34
```
35
35
36
36
+53
-12
docs/hacking.md
+53
-12
docs/hacking.md
···
48
48
redis-server
49
49
```
50
50
51
-
## running a knot
51
+
## running knots and spindles
52
52
53
53
An end-to-end knot setup requires setting up a machine with
54
54
`sshd`, `AuthorizedKeysCommand`, and git user, which is
55
55
quite cumbersome. So the nix flake provides a
56
56
`nixosConfiguration` to do so.
57
57
58
-
To begin, grab your DID from http://localhost:3000/settings.
59
-
Then, set `TANGLED_VM_KNOT_OWNER` and
60
-
`TANGLED_VM_SPINDLE_OWNER` to your DID.
58
+
<details>
59
+
<summary><strong>MacOS users will have to setup a Nix Builder first</strong></summary>
60
+
61
+
In order to build Tangled's dev VM on macOS, you will
62
+
first need to set up a Linux Nix builder. The recommended
63
+
way to do so is to run a [`darwin.linux-builder`
64
+
VM](https://nixos.org/manual/nixpkgs/unstable/#sec-darwin-builder)
65
+
and to register it in `nix.conf` as a builder for Linux
66
+
with the same architecture as your Mac (`linux-aarch64` if
67
+
you are using Apple Silicon).
68
+
69
+
> IMPORTANT: You must build `darwin.linux-builder` somewhere other than inside
70
+
> the tangled repo so that it doesn't conflict with the other VM. For example,
71
+
> you can do
72
+
>
73
+
> ```shell
74
+
> cd $(mktemp -d buildervm.XXXXX) && nix run nixpkgs#darwin.linux-builder
75
+
> ```
76
+
>
77
+
> to store the builder VM in a temporary dir.
78
+
>
79
+
> You should read and follow [all the other intructions][darwin builder vm] to
80
+
> avoid subtle problems.
81
+
82
+
Alternatively, you can use any other method to set up a
83
+
Linux machine with `nix` installed that you can `sudo ssh`
84
+
into (in other words, root user on your Mac has to be able
85
+
to ssh into the Linux machine without entering a password)
86
+
and that has the same architecture as your Mac. See
87
+
[remote builder
88
+
instructions](https://nix.dev/manual/nix/2.28/advanced-topics/distributed-builds.html#requirements)
89
+
for how to register such a builder in `nix.conf`.
61
90
62
-
If you don't want to [set up a spindle](#running-a-spindle),
63
-
you can use any placeholder value.
91
+
> WARNING: If you'd like to use
92
+
> [`nixos-lima`](https://github.com/nixos-lima/nixos-lima) or
93
+
> [Orbstack](https://orbstack.dev/), note that setting them up so that `sudo
94
+
> ssh` works can be tricky. It seems to be [possible with
95
+
> Orbstack](https://github.com/orgs/orbstack/discussions/1669).
64
96
65
-
You can now start a lightweight NixOS VM like so:
97
+
</details>
98
+
99
+
To begin, grab your DID from http://localhost:3000/settings.
100
+
Then, set `TANGLED_VM_KNOT_OWNER` and
101
+
`TANGLED_VM_SPINDLE_OWNER` to your DID. You can now start a
102
+
lightweight NixOS VM like so:
66
103
67
104
```bash
68
105
nix run --impure .#vm
···
74
111
with `ssh` exposed on port 2222.
75
112
76
113
Once the services are running, head to
77
-
http://localhost:3000/knots and hit verify (and similarly,
78
-
http://localhost:3000/spindles to verify your spindle). It
79
-
should verify the ownership of the services instantly if
80
-
everything went smoothly.
114
+
http://localhost:3000/knots and hit verify. It should
115
+
verify the ownership of the services instantly if everything
116
+
went smoothly.
81
117
82
118
You can push repositories to this VM with this ssh config
83
119
block on your main machine:
···
97
133
git push local-dev main
98
134
```
99
135
100
-
## running a spindle
136
+
### running a spindle
101
137
102
138
The above VM should already be running a spindle on
103
139
`localhost:6555`. Head to http://localhost:3000/spindles and
···
119
155
# litecli has a nicer REPL interface:
120
156
litecli /var/lib/spindle/spindle.db
121
157
```
158
+
159
+
If for any reason you wish to disable either one of the
160
+
services in the VM, modify [nix/vm.nix](/nix/vm.nix) and set
161
+
`services.tangled-spindle.enable` (or
162
+
`services.tangled-knot.enable`) to `false`.
-35
docs/migrations/knot-1.7.0.md
-35
docs/migrations/knot-1.7.0.md
···
1
-
# Upgrading from v1.7.0
2
-
3
-
After v1.7.0, knot secrets have been deprecated. You no
4
-
longer need a secret from the appview to run a knot. All
5
-
authorized commands to knots are managed via [Inter-Service
6
-
Authentication](https://atproto.com/specs/xrpc#inter-service-authentication-jwt).
7
-
Knots will be read-only until upgraded.
8
-
9
-
Upgrading is quite easy, in essence:
10
-
11
-
- `KNOT_SERVER_SECRET` is no more, you can remove this
12
-
environment variable entirely
13
-
- `KNOT_SERVER_OWNER` is now required on boot, set this to
14
-
your DID. You can find your DID in the
15
-
[settings](https://tangled.sh/settings) page.
16
-
- Restart your knot once you have replaced the environment
17
-
variable
18
-
- Head to the [knot dashboard](https://tangled.sh/knots) and
19
-
hit the "retry" button to verify your knot. This simply
20
-
writes a `sh.tangled.knot` record to your PDS.
21
-
22
-
## Nix
23
-
24
-
If you use the nix module, simply bump the flake to the
25
-
latest revision, and change your config block like so:
26
-
27
-
```diff
28
-
services.tangled-knot = {
29
-
enable = true;
30
-
server = {
31
-
- secretFile = /path/to/secret;
32
-
+ owner = "did:plc:foo";
33
-
};
34
-
};
35
-
```
+60
docs/migrations.md
+60
docs/migrations.md
···
1
+
# Migrations
2
+
3
+
This document is laid out in reverse-chronological order.
4
+
Newer migration guides are listed first, and older guides
5
+
are further down the page.
6
+
7
+
## Upgrading from v1.8.x
8
+
9
+
After v1.8.2, the HTTP API for knot and spindles have been
10
+
deprecated and replaced with XRPC. Repositories on outdated
11
+
knots will not be viewable from the appview. Upgrading is
12
+
straightforward however.
13
+
14
+
For knots:
15
+
16
+
- Upgrade to latest tag (v1.9.0 or above)
17
+
- Head to the [knot dashboard](https://tangled.sh/knots) and
18
+
hit the "retry" button to verify your knot
19
+
20
+
For spindles:
21
+
22
+
- Upgrade to latest tag (v1.9.0 or above)
23
+
- Head to the [spindle
24
+
dashboard](https://tangled.sh/spindles) and hit the
25
+
"retry" button to verify your spindle
26
+
27
+
## Upgrading from v1.7.x
28
+
29
+
After v1.7.0, knot secrets have been deprecated. You no
30
+
longer need a secret from the appview to run a knot. All
31
+
authorized commands to knots are managed via [Inter-Service
32
+
Authentication](https://atproto.com/specs/xrpc#inter-service-authentication-jwt).
33
+
Knots will be read-only until upgraded.
34
+
35
+
Upgrading is quite easy, in essence:
36
+
37
+
- `KNOT_SERVER_SECRET` is no more, you can remove this
38
+
environment variable entirely
39
+
- `KNOT_SERVER_OWNER` is now required on boot, set this to
40
+
your DID. You can find your DID in the
41
+
[settings](https://tangled.sh/settings) page.
42
+
- Restart your knot once you have replaced the environment
43
+
variable
44
+
- Head to the [knot dashboard](https://tangled.sh/knots) and
45
+
hit the "retry" button to verify your knot. This simply
46
+
writes a `sh.tangled.knot` record to your PDS.
47
+
48
+
If you use the nix module, simply bump the flake to the
49
+
latest revision, and change your config block like so:
50
+
51
+
```diff
52
+
services.tangled-knot = {
53
+
enable = true;
54
+
server = {
55
+
- secretFile = /path/to/secret;
56
+
+ owner = "did:plc:foo";
57
+
};
58
+
};
59
+
```
60
+
+130
-54
docs/spindle/pipeline.md
+130
-54
docs/spindle/pipeline.md
···
1
-
# spindle pipeline manifest
1
+
# spindle pipelines
2
+
3
+
Spindle workflows allow you to write CI/CD pipelines in a simple format. They're located in the `.tangled/workflows` directory at the root of your repository, and are defined using YAML.
4
+
5
+
The fields are:
2
6
3
-
Spindle pipelines are defined under the `.tangled/workflows` directory in a
4
-
repo. Generally:
7
+
- [Trigger](#trigger): A **required** field that defines when a workflow should be triggered.
8
+
- [Engine](#engine): A **required** field that defines which engine a workflow should run on.
9
+
- [Clone options](#clone-options): An **optional** field that defines how the repository should be cloned.
10
+
- [Dependencies](#dependencies): An **optional** field that allows you to list dependencies you may need.
11
+
- [Environment](#environment): An **optional** field that allows you to define environment variables.
12
+
- [Steps](#steps): An **optional** field that allows you to define what steps should run in the workflow.
5
13
6
-
* Pipelines are defined in YAML.
7
-
* Workflows can run using different *engines*.
14
+
## Trigger
8
15
9
-
The most barebones workflow looks like this:
16
+
The first thing to add to a workflow is the trigger, which defines when a workflow runs. This is defined using a `when` field, which takes in a list of conditions. Each condition has the following fields:
17
+
18
+
- `event`: This is a **required** field that defines when your workflow should run. It's a list that can take one or more of the following values:
19
+
- `push`: The workflow should run every time a commit is pushed to the repository.
20
+
- `pull_request`: The workflow should run every time a pull request is made or updated.
21
+
- `manual`: The workflow can be triggered manually.
22
+
- `branch`: This is a **required** field that defines which branches the workflow should run for. If used with the `push` event, commits to the branch(es) listed here will trigger the workflow. If used with the `pull_request` event, updates to pull requests targeting the branch(es) listed here will trigger the workflow. This field has no effect with the `manual` event.
23
+
24
+
For example, if you'd like define a workflow that runs when commits are pushed to the `main` and `develop` branches, or when pull requests that target the `main` branch are updated, or manually, you can do so with:
10
25
11
26
```yaml
12
27
when:
13
-
- event: ["push"]
28
+
- event: ["push", "manual"]
29
+
branch: ["main", "develop"]
30
+
- event: ["pull_request"]
14
31
branch: ["main"]
32
+
```
15
33
34
+
## Engine
35
+
36
+
Next is the engine on which the workflow should run, defined using the **required** `engine` field. The currently supported engines are:
37
+
38
+
- `nixery`: This uses an instance of [Nixery](https://nixery.dev) to run steps, which allows you to add [dependencies](#dependencies) from [Nixpkgs](https://github.com/NixOS/nixpkgs). You can search for packages on https://search.nixos.org, and there's a pretty good chance the package(s) you're looking for will be there.
39
+
40
+
Example:
41
+
42
+
```yaml
16
43
engine: "nixery"
44
+
```
45
+
46
+
## Clone options
17
47
18
-
# optional
48
+
When a workflow starts, the first step is to clone the repository. You can customize this behavior using the **optional** `clone` field. It has the following fields:
49
+
50
+
- `skip`: Setting this to `true` will skip cloning the repository. This can be useful if your workflow is doing something that doesn't require anything from the repository itself. This is `false` by default.
51
+
- `depth`: This sets the number of commits, or the "clone depth", to fetch from the repository. For example, if you set this to 2, the last 2 commits will be fetched. By default, the depth is set to 1, meaning only the most recent commit will be fetched, which is the commit that triggered the workflow.
52
+
- `submodules`: If you use [git submodules](https://git-scm.com/book/en/v2/Git-Tools-Submodules) in your repository, setting this field to `true` will recursively fetch all submodules. This is `false` by default.
53
+
54
+
The default settings are:
55
+
56
+
```yaml
19
57
clone:
20
58
skip: false
21
-
depth: 50
22
-
submodules: true
59
+
depth: 1
60
+
submodules: false
23
61
```
24
62
25
-
The `when` and `engine` fields are required, while every other aspect
26
-
of how the definition is parsed is up to the engine. Currently, a spindle
27
-
provides at least one of these built-in engines:
63
+
## Dependencies
28
64
29
-
## `nixery`
65
+
Usually when you're running a workflow, you'll need additional dependencies. The `dependencies` field lets you define which dependencies to get, and from where. It's a key-value map, with the key being the registry to fetch dependencies from, and the value being the list of dependencies to fetch.
30
66
31
-
The Nixery engine uses an instance of [Nixery](https://nixery.dev) to run
32
-
steps that use dependencies from [Nixpkgs](https://github.com/NixOS/nixpkgs).
33
-
34
-
Here's an example that uses all fields:
67
+
Say you want to fetch Node.js and Go from `nixpkgs`, and a package called `my_pkg` you've made from your own registry at your repository at `https://tangled.sh/@example.com/my_pkg`. You can define those dependencies like so:
35
68
36
69
```yaml
37
-
# build_and_test.yaml
38
-
when:
39
-
- event: ["push", "pull_request"]
40
-
branch: ["main", "develop"]
41
-
- event: ["manual"]
42
-
43
70
dependencies:
44
-
## from nixpkgs
71
+
# nixpkgs
45
72
nixpkgs:
46
73
- nodejs
47
-
## custom registry
48
-
git+https://tangled.sh/@oppi.li/statix:
49
-
- statix
74
+
- go
75
+
# custom registry
76
+
git+https://tangled.sh/@example.com/my_pkg:
77
+
- my_pkg
78
+
```
50
79
51
-
steps:
52
-
- name: "Install dependencies"
53
-
command: "npm install"
54
-
environment:
55
-
NODE_ENV: "development"
56
-
CI: "true"
80
+
Now these dependencies are available to use in your workflow!
57
81
58
-
- name: "Run linter"
59
-
command: "npm run lint"
82
+
## Environment
83
+
84
+
The `environment` field allows you define environment variables that will be available throughout the entire workflow. **Do not put secrets here, these environment variables are visible to anyone viewing the repository. You can add secrets for pipelines in your repository's settings.**
85
+
86
+
Example:
87
+
88
+
```yaml
89
+
environment:
90
+
GOOS: "linux"
91
+
GOARCH: "arm64"
92
+
NODE_ENV: "production"
93
+
MY_ENV_VAR: "MY_ENV_VALUE"
94
+
```
60
95
61
-
- name: "Run tests"
62
-
command: "npm test"
63
-
environment:
64
-
NODE_ENV: "test"
65
-
JEST_WORKERS: "2"
96
+
## Steps
66
97
67
-
- name: "Build application"
98
+
The `steps` field allows you to define what steps should run in the workflow. It's a list of step objects, each with the following fields:
99
+
100
+
- `name`: This field allows you to give your step a name. This name is visible in your workflow runs, and is used to describe what the step is doing.
101
+
- `command`: This field allows you to define a command to run in that step. The step is run in a Bash shell, and the logs from the command will be visible in the pipelines page on the Tangled website. The [dependencies](#dependencies) you added will be available to use here.
102
+
- `environment`: Similar to the global [environment](#environment) config, this **optional** field is a key-value map that allows you to set environment variables for the step. **Do not put secrets here, these environment variables are visible to anyone viewing the repository. You can add secrets for pipelines in your repository's settings.**
103
+
104
+
Example:
105
+
106
+
```yaml
107
+
steps:
108
+
- name: "Build backend"
109
+
command: "go build"
110
+
environment:
111
+
GOOS: "darwin"
112
+
GOARCH: "arm64"
113
+
- name: "Build frontend"
68
114
command: "npm run build"
69
115
environment:
70
116
NODE_ENV: "production"
117
+
```
71
118
72
-
environment:
73
-
BUILD_NUMBER: "123"
74
-
GIT_BRANCH: "main"
119
+
## Complete workflow
75
120
76
-
## current repository is cloned and checked out at the target ref
77
-
## by default.
121
+
```yaml
122
+
# .tangled/workflows/build.yml
123
+
124
+
when:
125
+
- event: ["push", "manual"]
126
+
branch: ["main", "develop"]
127
+
- event: ["pull_request"]
128
+
branch: ["main"]
129
+
130
+
engine: "nixery"
131
+
132
+
# using the default values
78
133
clone:
79
134
skip: false
80
-
depth: 50
81
-
submodules: true
82
-
```
135
+
depth: 1
136
+
submodules: false
83
137
84
-
## git push options
138
+
dependencies:
139
+
# nixpkgs
140
+
nixpkgs:
141
+
- nodejs
142
+
- go
143
+
# custom registry
144
+
git+https://tangled.sh/@example.com/my_pkg:
145
+
- my_pkg
85
146
86
-
These are push options that can be used with the `--push-option (-o)` flag of git push:
147
+
environment:
148
+
GOOS: "linux"
149
+
GOARCH: "arm64"
150
+
NODE_ENV: "production"
151
+
MY_ENV_VAR: "MY_ENV_VALUE"
152
+
153
+
steps:
154
+
- name: "Build backend"
155
+
command: "go build"
156
+
environment:
157
+
GOOS: "darwin"
158
+
GOARCH: "arm64"
159
+
- name: "Build frontend"
160
+
command: "npm run build"
161
+
environment:
162
+
NODE_ENV: "production"
163
+
```
87
164
88
-
- `verbose-ci`, `ci-verbose`: enables diagnostics reporting for the CI pipeline, allowing you to see any issues when you push.
89
-
- `skip-ci`, `ci-skip`: skips triggering the CI pipeline.
165
+
If you want another example of a workflow, you can look at the one [Tangled uses to build the project](https://tangled.sh/@tangled.sh/core/blob/master/.tangled/workflows/build.yml).
+3
-1
go.mod
+3
-1
go.mod
···
39
39
github.com/stretchr/testify v1.10.0
40
40
github.com/urfave/cli/v3 v3.3.3
41
41
github.com/whyrusleeping/cbor-gen v0.3.1
42
-
github.com/yuin/goldmark v1.4.15
42
+
github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57
43
+
github.com/yuin/goldmark v1.7.12
43
44
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
44
45
golang.org/x/crypto v0.40.0
45
46
golang.org/x/net v0.42.0
···
154
155
github.com/vmihailenco/go-tinylfu v0.2.2 // indirect
155
156
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
156
157
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
158
+
github.com/wyatt915/treeblood v0.1.15 // indirect
157
159
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect
158
160
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect
159
161
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
+6
-1
go.sum
+6
-1
go.sum
···
426
426
github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw=
427
427
github.com/whyrusleeping/cbor-gen v0.3.1 h1:82ioxmhEYut7LBVGhGq8xoRkXPLElVuh5mV67AFfdv0=
428
428
github.com/whyrusleeping/cbor-gen v0.3.1/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so=
429
+
github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57 h1:UqtQdzLXnvdBdqn/go53qGyncw1wJ7Mq5SQdieM1/Ew=
430
+
github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57/go.mod h1:BxSCWByWSRSuembL3cDG1IBUbkBoO/oW/6tF19aA4hs=
431
+
github.com/wyatt915/treeblood v0.1.15 h1:3KZ3o2LpcKZAzOLqMoW9qeUzKEaKArKpbcPpTkNfQC8=
432
+
github.com/wyatt915/treeblood v0.1.15/go.mod h1:i7+yhhmzdDP17/97pIsOSffw74EK/xk+qJ0029cSXUY=
429
433
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
430
434
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
431
435
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
432
436
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
433
437
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
434
-
github.com/yuin/goldmark v1.4.15 h1:CFa84T0goNn/UIXYS+dmjjVxMyTAvpOmzld40N/nfK0=
435
438
github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
439
+
github.com/yuin/goldmark v1.7.12 h1:YwGP/rrea2/CnCtUHgjuolG/PnMxdQtPMO5PvaE2/nY=
440
+
github.com/yuin/goldmark v1.7.12/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
436
441
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
437
442
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
438
443
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA=
+1
-1
input.css
+1
-1
input.css
···
90
90
}
91
91
92
92
label {
93
-
@apply block mb-2 text-gray-900 text-sm font-bold py-2 uppercase dark:text-gray-100;
93
+
@apply block text-gray-900 text-sm font-bold py-2 uppercase dark:text-gray-100;
94
94
}
95
95
input {
96
96
@apply border border-gray-400 block rounded bg-gray-50 focus:ring-black p-3 dark:bg-gray-800 dark:border-gray-600 dark:text-white dark:focus:ring-gray-400;
-285
knotclient/unsigned.go
-285
knotclient/unsigned.go
···
1
-
package knotclient
2
-
3
-
import (
4
-
"bytes"
5
-
"encoding/json"
6
-
"fmt"
7
-
"io"
8
-
"log"
9
-
"net/http"
10
-
"net/url"
11
-
"strconv"
12
-
"time"
13
-
14
-
"tangled.sh/tangled.sh/core/types"
15
-
)
16
-
17
-
type UnsignedClient struct {
18
-
Url *url.URL
19
-
client *http.Client
20
-
}
21
-
22
-
func NewUnsignedClient(domain string, dev bool) (*UnsignedClient, error) {
23
-
client := &http.Client{
24
-
Timeout: 5 * time.Second,
25
-
}
26
-
27
-
scheme := "https"
28
-
if dev {
29
-
scheme = "http"
30
-
}
31
-
url, err := url.Parse(fmt.Sprintf("%s://%s", scheme, domain))
32
-
if err != nil {
33
-
return nil, err
34
-
}
35
-
36
-
unsignedClient := &UnsignedClient{
37
-
client: client,
38
-
Url: url,
39
-
}
40
-
41
-
return unsignedClient, nil
42
-
}
43
-
44
-
func (us *UnsignedClient) newRequest(method, endpoint string, query url.Values, body []byte) (*http.Request, error) {
45
-
reqUrl := us.Url.JoinPath(endpoint)
46
-
47
-
// add query parameters
48
-
if query != nil {
49
-
reqUrl.RawQuery = query.Encode()
50
-
}
51
-
52
-
return http.NewRequest(method, reqUrl.String(), bytes.NewReader(body))
53
-
}
54
-
55
-
func do[T any](us *UnsignedClient, req *http.Request) (*T, error) {
56
-
resp, err := us.client.Do(req)
57
-
if err != nil {
58
-
return nil, err
59
-
}
60
-
defer resp.Body.Close()
61
-
62
-
body, err := io.ReadAll(resp.Body)
63
-
if err != nil {
64
-
log.Printf("Error reading response body: %v", err)
65
-
return nil, err
66
-
}
67
-
68
-
var result T
69
-
err = json.Unmarshal(body, &result)
70
-
if err != nil {
71
-
log.Printf("Error unmarshalling response body: %v", err)
72
-
return nil, err
73
-
}
74
-
75
-
return &result, nil
76
-
}
77
-
78
-
func (us *UnsignedClient) Index(ownerDid, repoName, ref string) (*types.RepoIndexResponse, error) {
79
-
const (
80
-
Method = "GET"
81
-
)
82
-
83
-
endpoint := fmt.Sprintf("/%s/%s/tree/%s", ownerDid, repoName, ref)
84
-
if ref == "" {
85
-
endpoint = fmt.Sprintf("/%s/%s", ownerDid, repoName)
86
-
}
87
-
88
-
req, err := us.newRequest(Method, endpoint, nil, nil)
89
-
if err != nil {
90
-
return nil, err
91
-
}
92
-
93
-
return do[types.RepoIndexResponse](us, req)
94
-
}
95
-
96
-
func (us *UnsignedClient) Log(ownerDid, repoName, ref string, page int) (*types.RepoLogResponse, error) {
97
-
const (
98
-
Method = "GET"
99
-
)
100
-
101
-
endpoint := fmt.Sprintf("/%s/%s/log/%s", ownerDid, repoName, url.PathEscape(ref))
102
-
103
-
query := url.Values{}
104
-
query.Add("page", strconv.Itoa(page))
105
-
query.Add("per_page", strconv.Itoa(60))
106
-
107
-
req, err := us.newRequest(Method, endpoint, query, nil)
108
-
if err != nil {
109
-
return nil, err
110
-
}
111
-
112
-
return do[types.RepoLogResponse](us, req)
113
-
}
114
-
115
-
func (us *UnsignedClient) Branches(ownerDid, repoName string) (*types.RepoBranchesResponse, error) {
116
-
const (
117
-
Method = "GET"
118
-
)
119
-
120
-
endpoint := fmt.Sprintf("/%s/%s/branches", ownerDid, repoName)
121
-
122
-
req, err := us.newRequest(Method, endpoint, nil, nil)
123
-
if err != nil {
124
-
return nil, err
125
-
}
126
-
127
-
return do[types.RepoBranchesResponse](us, req)
128
-
}
129
-
130
-
func (us *UnsignedClient) Tags(ownerDid, repoName string) (*types.RepoTagsResponse, error) {
131
-
const (
132
-
Method = "GET"
133
-
)
134
-
135
-
endpoint := fmt.Sprintf("/%s/%s/tags", ownerDid, repoName)
136
-
137
-
req, err := us.newRequest(Method, endpoint, nil, nil)
138
-
if err != nil {
139
-
return nil, err
140
-
}
141
-
142
-
return do[types.RepoTagsResponse](us, req)
143
-
}
144
-
145
-
func (us *UnsignedClient) Branch(ownerDid, repoName, branch string) (*types.RepoBranchResponse, error) {
146
-
const (
147
-
Method = "GET"
148
-
)
149
-
150
-
endpoint := fmt.Sprintf("/%s/%s/branches/%s", ownerDid, repoName, url.PathEscape(branch))
151
-
152
-
req, err := us.newRequest(Method, endpoint, nil, nil)
153
-
if err != nil {
154
-
return nil, err
155
-
}
156
-
157
-
return do[types.RepoBranchResponse](us, req)
158
-
}
159
-
160
-
func (us *UnsignedClient) DefaultBranch(ownerDid, repoName string) (*types.RepoDefaultBranchResponse, error) {
161
-
const (
162
-
Method = "GET"
163
-
)
164
-
165
-
endpoint := fmt.Sprintf("/%s/%s/branches/default", ownerDid, repoName)
166
-
167
-
req, err := us.newRequest(Method, endpoint, nil, nil)
168
-
if err != nil {
169
-
return nil, err
170
-
}
171
-
172
-
resp, err := us.client.Do(req)
173
-
if err != nil {
174
-
return nil, err
175
-
}
176
-
defer resp.Body.Close()
177
-
178
-
var defaultBranch types.RepoDefaultBranchResponse
179
-
if err := json.NewDecoder(resp.Body).Decode(&defaultBranch); err != nil {
180
-
return nil, err
181
-
}
182
-
183
-
return &defaultBranch, nil
184
-
}
185
-
186
-
func (us *UnsignedClient) Capabilities() (*types.Capabilities, error) {
187
-
const (
188
-
Method = "GET"
189
-
Endpoint = "/capabilities"
190
-
)
191
-
192
-
req, err := us.newRequest(Method, Endpoint, nil, nil)
193
-
if err != nil {
194
-
return nil, err
195
-
}
196
-
197
-
resp, err := us.client.Do(req)
198
-
if err != nil {
199
-
return nil, err
200
-
}
201
-
defer resp.Body.Close()
202
-
203
-
var capabilities types.Capabilities
204
-
if err := json.NewDecoder(resp.Body).Decode(&capabilities); err != nil {
205
-
return nil, err
206
-
}
207
-
208
-
return &capabilities, nil
209
-
}
210
-
211
-
func (us *UnsignedClient) Compare(ownerDid, repoName, rev1, rev2 string) (*types.RepoFormatPatchResponse, error) {
212
-
const (
213
-
Method = "GET"
214
-
)
215
-
216
-
endpoint := fmt.Sprintf("/%s/%s/compare/%s/%s", ownerDid, repoName, url.PathEscape(rev1), url.PathEscape(rev2))
217
-
218
-
req, err := us.newRequest(Method, endpoint, nil, nil)
219
-
if err != nil {
220
-
return nil, fmt.Errorf("Failed to create request.")
221
-
}
222
-
223
-
compareResp, err := us.client.Do(req)
224
-
if err != nil {
225
-
return nil, fmt.Errorf("Failed to create request.")
226
-
}
227
-
defer compareResp.Body.Close()
228
-
229
-
switch compareResp.StatusCode {
230
-
case 404:
231
-
case 400:
232
-
return nil, fmt.Errorf("Branch comparisons not supported on this knot.")
233
-
}
234
-
235
-
respBody, err := io.ReadAll(compareResp.Body)
236
-
if err != nil {
237
-
log.Println("failed to compare across branches")
238
-
return nil, fmt.Errorf("Failed to compare branches.")
239
-
}
240
-
defer compareResp.Body.Close()
241
-
242
-
var formatPatchResponse types.RepoFormatPatchResponse
243
-
err = json.Unmarshal(respBody, &formatPatchResponse)
244
-
if err != nil {
245
-
log.Println("failed to unmarshal format-patch response", err)
246
-
return nil, fmt.Errorf("failed to compare branches.")
247
-
}
248
-
249
-
return &formatPatchResponse, nil
250
-
}
251
-
252
-
func (s *UnsignedClient) RepoLanguages(ownerDid, repoName, ref string) (*types.RepoLanguageResponse, error) {
253
-
const (
254
-
Method = "GET"
255
-
)
256
-
endpoint := fmt.Sprintf("/%s/%s/languages/%s", ownerDid, repoName, url.PathEscape(ref))
257
-
258
-
req, err := s.newRequest(Method, endpoint, nil, nil)
259
-
if err != nil {
260
-
return nil, err
261
-
}
262
-
263
-
resp, err := s.client.Do(req)
264
-
if err != nil {
265
-
return nil, err
266
-
}
267
-
268
-
var result types.RepoLanguageResponse
269
-
if resp.StatusCode != http.StatusOK {
270
-
log.Println("failed to calculate languages", resp.Status)
271
-
return &types.RepoLanguageResponse{}, nil
272
-
}
273
-
274
-
body, err := io.ReadAll(resp.Body)
275
-
if err != nil {
276
-
return nil, err
277
-
}
278
-
279
-
err = json.Unmarshal(body, &result)
280
-
if err != nil {
281
-
return nil, err
282
-
}
283
-
284
-
return &result, nil
285
-
}
+7
knotserver/config/config.go
+7
knotserver/config/config.go
···
27
27
Dev bool `env:"DEV, default=false"`
28
28
}
29
29
30
+
type Git struct {
31
+
// user name & email used as committer
32
+
UserName string `env:"USER_NAME, default=Tangled"`
33
+
UserEmail string `env:"USER_EMAIL, default=noreply@tangled.sh"`
34
+
}
35
+
30
36
func (s Server) Did() syntax.DID {
31
37
return syntax.DID(fmt.Sprintf("did:web:%s", s.Hostname))
32
38
}
···
34
40
type Config struct {
35
41
Repo Repo `env:",prefix=KNOT_REPO_"`
36
42
Server Server `env:",prefix=KNOT_SERVER_"`
43
+
Git Git `env:",prefix=KNOT_GIT_"`
37
44
AppViewEndpoint string `env:"APPVIEW_ENDPOINT, default=https://tangled.sh"`
38
45
}
39
46
+40
knotserver/db/pubkeys.go
+40
knotserver/db/pubkeys.go
···
1
1
package db
2
2
3
3
import (
4
+
"strconv"
4
5
"time"
5
6
6
7
"tangled.sh/tangled.sh/core/api/tangled"
···
99
100
100
101
return keys, nil
101
102
}
103
+
104
+
func (d *DB) GetPublicKeysPaginated(limit int, cursor string) ([]PublicKey, string, error) {
105
+
var keys []PublicKey
106
+
107
+
offset := 0
108
+
if cursor != "" {
109
+
if o, err := strconv.Atoi(cursor); err == nil && o >= 0 {
110
+
offset = o
111
+
}
112
+
}
113
+
114
+
query := `select key, did, created from public_keys order by created desc limit ? offset ?`
115
+
rows, err := d.db.Query(query, limit+1, offset) // +1 to check if there are more results
116
+
if err != nil {
117
+
return nil, "", err
118
+
}
119
+
defer rows.Close()
120
+
121
+
for rows.Next() {
122
+
var publicKey PublicKey
123
+
if err := rows.Scan(&publicKey.Key, &publicKey.Did, &publicKey.CreatedAt); err != nil {
124
+
return nil, "", err
125
+
}
126
+
keys = append(keys, publicKey)
127
+
}
128
+
129
+
if err := rows.Err(); err != nil {
130
+
return nil, "", err
131
+
}
132
+
133
+
// check if there are more results for pagination
134
+
var nextCursor string
135
+
if len(keys) > limit {
136
+
keys = keys[:limit] // remove the extra item
137
+
nextCursor = strconv.Itoa(offset + limit)
138
+
}
139
+
140
+
return keys, nextCursor, nil
141
+
}
+2
-2
knotserver/events.go
+2
-2
knotserver/events.go
···
15
15
WriteBufferSize: 1024,
16
16
}
17
17
18
-
func (h *Handle) Events(w http.ResponseWriter, r *http.Request) {
18
+
func (h *Knot) Events(w http.ResponseWriter, r *http.Request) {
19
19
l := h.l.With("handler", "OpLog")
20
20
l.Debug("received new connection")
21
21
···
83
83
}
84
84
}
85
85
86
-
func (h *Handle) streamOps(conn *websocket.Conn, cursor *int64) error {
86
+
func (h *Knot) streamOps(conn *websocket.Conn, cursor *int64) error {
87
87
events, err := h.db.GetEvents(*cursor)
88
88
if err != nil {
89
89
h.l.Error("failed to fetch events from db", "err", err, "cursor", cursor)
-48
knotserver/file.go
-48
knotserver/file.go
···
1
-
package knotserver
2
-
3
-
import (
4
-
"bytes"
5
-
"io"
6
-
"log/slog"
7
-
"net/http"
8
-
"strings"
9
-
10
-
"tangled.sh/tangled.sh/core/types"
11
-
)
12
-
13
-
func countLines(r io.Reader) (int, error) {
14
-
buf := make([]byte, 32*1024)
15
-
bufLen := 0
16
-
count := 0
17
-
nl := []byte{'\n'}
18
-
19
-
for {
20
-
c, err := r.Read(buf)
21
-
if c > 0 {
22
-
bufLen += c
23
-
}
24
-
count += bytes.Count(buf[:c], nl)
25
-
26
-
switch {
27
-
case err == io.EOF:
28
-
/* handle last line not having a newline at the end */
29
-
if bufLen >= 1 && buf[(bufLen-1)%(32*1024)] != '\n' {
30
-
count++
31
-
}
32
-
return count, nil
33
-
case err != nil:
34
-
return 0, err
35
-
}
36
-
}
37
-
}
38
-
39
-
func (h *Handle) showFile(resp types.RepoBlobResponse, w http.ResponseWriter, l *slog.Logger) {
40
-
lc, err := countLines(strings.NewReader(resp.Contents))
41
-
if err != nil {
42
-
// Non-fatal, we'll just skip showing line numbers in the template.
43
-
l.Warn("counting lines", "error", err)
44
-
}
45
-
46
-
resp.Lines = lc
47
-
writeJSON(w, resp)
48
-
}
+58
-72
knotserver/git/merge.go
+58
-72
knotserver/git/merge.go
···
12
12
"github.com/dgraph-io/ristretto"
13
13
"github.com/go-git/go-git/v5"
14
14
"github.com/go-git/go-git/v5/plumbing"
15
-
"tangled.sh/tangled.sh/core/patchutil"
16
15
)
17
16
18
17
type MergeCheckCache struct {
···
86
85
87
86
// MergeOptions specifies the configuration for a merge operation
88
87
type MergeOptions struct {
89
-
CommitMessage string
90
-
CommitBody string
91
-
AuthorName string
92
-
AuthorEmail string
93
-
FormatPatch bool
88
+
CommitMessage string
89
+
CommitBody string
90
+
AuthorName string
91
+
AuthorEmail string
92
+
CommitterName string
93
+
CommitterEmail string
94
+
FormatPatch bool
94
95
}
95
96
96
97
func (e ErrMerge) Error() string {
···
143
144
return tmpDir, nil
144
145
}
145
146
146
-
func (g *GitRepo) applyPatch(tmpDir, patchFile string, checkOnly bool, opts *MergeOptions) error {
147
+
func (g *GitRepo) checkPatch(tmpDir, patchFile string) error {
147
148
var stderr bytes.Buffer
148
-
var cmd *exec.Cmd
149
149
150
-
if checkOnly {
151
-
cmd = exec.Command("git", "-C", tmpDir, "apply", "--check", "-v", patchFile)
152
-
} else {
153
-
// if patch is a format-patch, apply using 'git am'
154
-
if opts.FormatPatch {
155
-
amCmd := exec.Command("git", "-C", tmpDir, "am", patchFile)
156
-
amCmd.Stderr = &stderr
157
-
if err := amCmd.Run(); err != nil {
158
-
return fmt.Errorf("patch application failed: %s", stderr.String())
159
-
}
160
-
return nil
150
+
cmd := exec.Command("git", "-C", tmpDir, "apply", "--check", "-v", patchFile)
151
+
cmd.Stderr = &stderr
152
+
153
+
if err := cmd.Run(); err != nil {
154
+
conflicts := parseGitApplyErrors(stderr.String())
155
+
return &ErrMerge{
156
+
Message: "patch cannot be applied cleanly",
157
+
Conflicts: conflicts,
158
+
HasConflict: len(conflicts) > 0,
159
+
OtherError: err,
161
160
}
162
-
163
-
// else, apply using 'git apply' and commit it manually
164
-
exec.Command("git", "-C", tmpDir, "config", "advice.mergeConflict", "false").Run()
165
-
if opts != nil {
166
-
applyCmd := exec.Command("git", "-C", tmpDir, "apply", patchFile)
167
-
applyCmd.Stderr = &stderr
168
-
if err := applyCmd.Run(); err != nil {
169
-
return fmt.Errorf("patch application failed: %s", stderr.String())
170
-
}
161
+
}
162
+
return nil
163
+
}
171
164
172
-
stageCmd := exec.Command("git", "-C", tmpDir, "add", ".")
173
-
if err := stageCmd.Run(); err != nil {
174
-
return fmt.Errorf("failed to stage changes: %w", err)
175
-
}
165
+
func (g *GitRepo) applyPatch(tmpDir, patchFile string, opts MergeOptions) error {
166
+
var stderr bytes.Buffer
167
+
var cmd *exec.Cmd
176
168
177
-
commitArgs := []string{"-C", tmpDir, "commit"}
169
+
// configure default git user before merge
170
+
exec.Command("git", "-C", tmpDir, "config", "user.name", opts.CommitterName).Run()
171
+
exec.Command("git", "-C", tmpDir, "config", "user.email", opts.CommitterEmail).Run()
172
+
exec.Command("git", "-C", tmpDir, "config", "advice.mergeConflict", "false").Run()
178
173
179
-
// Set author if provided
180
-
authorName := opts.AuthorName
181
-
authorEmail := opts.AuthorEmail
174
+
// if patch is a format-patch, apply using 'git am'
175
+
if opts.FormatPatch {
176
+
cmd = exec.Command("git", "-C", tmpDir, "am", patchFile)
177
+
} else {
178
+
// else, apply using 'git apply' and commit it manually
179
+
applyCmd := exec.Command("git", "-C", tmpDir, "apply", patchFile)
180
+
applyCmd.Stderr = &stderr
181
+
if err := applyCmd.Run(); err != nil {
182
+
return fmt.Errorf("patch application failed: %s", stderr.String())
183
+
}
182
184
183
-
if authorEmail == "" {
184
-
authorEmail = "noreply@tangled.sh"
185
-
}
185
+
stageCmd := exec.Command("git", "-C", tmpDir, "add", ".")
186
+
if err := stageCmd.Run(); err != nil {
187
+
return fmt.Errorf("failed to stage changes: %w", err)
188
+
}
186
189
187
-
if authorName == "" {
188
-
authorName = "Tangled"
189
-
}
190
+
commitArgs := []string{"-C", tmpDir, "commit"}
190
191
191
-
if authorName != "" {
192
-
commitArgs = append(commitArgs, "--author", fmt.Sprintf("%s <%s>", authorName, authorEmail))
193
-
}
192
+
// Set author if provided
193
+
authorName := opts.AuthorName
194
+
authorEmail := opts.AuthorEmail
194
195
195
-
commitArgs = append(commitArgs, "-m", opts.CommitMessage)
196
+
if authorName != "" && authorEmail != "" {
197
+
commitArgs = append(commitArgs, "--author", fmt.Sprintf("%s <%s>", authorName, authorEmail))
198
+
}
199
+
// else, will default to knot's global user.name & user.email configured via `KNOT_GIT_USER_*` env variables
196
200
197
-
if opts.CommitBody != "" {
198
-
commitArgs = append(commitArgs, "-m", opts.CommitBody)
199
-
}
201
+
commitArgs = append(commitArgs, "-m", opts.CommitMessage)
200
202
201
-
cmd = exec.Command("git", commitArgs...)
202
-
} else {
203
-
// If no commit message specified, use git-am which automatically creates a commit
204
-
cmd = exec.Command("git", "-C", tmpDir, "am", patchFile)
203
+
if opts.CommitBody != "" {
204
+
commitArgs = append(commitArgs, "-m", opts.CommitBody)
205
205
}
206
+
207
+
cmd = exec.Command("git", commitArgs...)
206
208
}
207
209
208
210
cmd.Stderr = &stderr
209
211
210
212
if err := cmd.Run(); err != nil {
211
-
if checkOnly {
212
-
conflicts := parseGitApplyErrors(stderr.String())
213
-
return &ErrMerge{
214
-
Message: "patch cannot be applied cleanly",
215
-
Conflicts: conflicts,
216
-
HasConflict: len(conflicts) > 0,
217
-
OtherError: err,
218
-
}
219
-
}
220
213
return fmt.Errorf("patch application failed: %s", stderr.String())
221
214
}
222
215
···
227
220
if val, ok := mergeCheckCache.Get(g, patchData, targetBranch); ok {
228
221
return val
229
222
}
230
-
231
-
var opts MergeOptions
232
-
opts.FormatPatch = patchutil.IsFormatPatch(string(patchData))
233
223
234
224
patchFile, err := g.createTempFileWithPatch(patchData)
235
225
if err != nil {
···
249
239
}
250
240
defer os.RemoveAll(tmpDir)
251
241
252
-
result := g.applyPatch(tmpDir, patchFile, true, &opts)
242
+
result := g.checkPatch(tmpDir, patchFile)
253
243
mergeCheckCache.Set(g, patchData, targetBranch, result)
254
244
return result
255
245
}
256
246
257
-
func (g *GitRepo) Merge(patchData []byte, targetBranch string) error {
258
-
return g.MergeWithOptions(patchData, targetBranch, nil)
259
-
}
260
-
261
-
func (g *GitRepo) MergeWithOptions(patchData []byte, targetBranch string, opts *MergeOptions) error {
247
+
func (g *GitRepo) MergeWithOptions(patchData []byte, targetBranch string, opts MergeOptions) error {
262
248
patchFile, err := g.createTempFileWithPatch(patchData)
263
249
if err != nil {
264
250
return &ErrMerge{
···
277
263
}
278
264
defer os.RemoveAll(tmpDir)
279
265
280
-
if err := g.applyPatch(tmpDir, patchFile, false, opts); err != nil {
266
+
if err := g.applyPatch(tmpDir, patchFile, opts); err != nil {
281
267
return err
282
268
}
283
269
+9
-10
knotserver/git/post_receive.go
+9
-10
knotserver/git/post_receive.go
···
145
145
}
146
146
147
147
func (m RefUpdateMeta) AsRecord() tangled.GitRefUpdate_Meta {
148
-
var byEmail []*tangled.GitRefUpdate_Meta_CommitCount_ByEmail_Elem
148
+
var byEmail []*tangled.GitRefUpdate_IndividualEmailCommitCount
149
149
for e, v := range m.CommitCount.ByEmail {
150
-
byEmail = append(byEmail, &tangled.GitRefUpdate_Meta_CommitCount_ByEmail_Elem{
150
+
byEmail = append(byEmail, &tangled.GitRefUpdate_IndividualEmailCommitCount{
151
151
Email: e,
152
152
Count: int64(v),
153
153
})
154
154
}
155
155
156
-
var langs []*tangled.GitRefUpdate_Pair
156
+
var langs []*tangled.GitRefUpdate_IndividualLanguageSize
157
157
for lang, size := range m.LangBreakdown {
158
-
langs = append(langs, &tangled.GitRefUpdate_Pair{
158
+
langs = append(langs, &tangled.GitRefUpdate_IndividualLanguageSize{
159
159
Lang: lang,
160
160
Size: size,
161
161
})
162
162
}
163
-
langBreakdown := &tangled.GitRefUpdate_Meta_LangBreakdown{
164
-
Inputs: langs,
165
-
}
166
163
167
164
return tangled.GitRefUpdate_Meta{
168
-
CommitCount: &tangled.GitRefUpdate_Meta_CommitCount{
165
+
CommitCount: &tangled.GitRefUpdate_CommitCountBreakdown{
169
166
ByEmail: byEmail,
170
167
},
171
-
IsDefaultRef: m.IsDefaultRef,
172
-
LangBreakdown: langBreakdown,
168
+
IsDefaultRef: m.IsDefaultRef,
169
+
LangBreakdown: &tangled.GitRefUpdate_LangBreakdown{
170
+
Inputs: langs,
171
+
},
173
172
}
174
173
}
+4
-4
knotserver/git.go
+4
-4
knotserver/git.go
···
13
13
"tangled.sh/tangled.sh/core/knotserver/git/service"
14
14
)
15
15
16
-
func (d *Handle) InfoRefs(w http.ResponseWriter, r *http.Request) {
16
+
func (d *Knot) InfoRefs(w http.ResponseWriter, r *http.Request) {
17
17
did := chi.URLParam(r, "did")
18
18
name := chi.URLParam(r, "name")
19
19
repoName, err := securejoin.SecureJoin(did, name)
···
56
56
}
57
57
}
58
58
59
-
func (d *Handle) UploadPack(w http.ResponseWriter, r *http.Request) {
59
+
func (d *Knot) UploadPack(w http.ResponseWriter, r *http.Request) {
60
60
did := chi.URLParam(r, "did")
61
61
name := chi.URLParam(r, "name")
62
62
repo, err := securejoin.SecureJoin(d.c.Repo.ScanPath, filepath.Join(did, name))
···
105
105
}
106
106
}
107
107
108
-
func (d *Handle) ReceivePack(w http.ResponseWriter, r *http.Request) {
108
+
func (d *Knot) ReceivePack(w http.ResponseWriter, r *http.Request) {
109
109
did := chi.URLParam(r, "did")
110
110
name := chi.URLParam(r, "name")
111
111
_, err := securejoin.SecureJoin(d.c.Repo.ScanPath, filepath.Join(did, name))
···
118
118
d.RejectPush(w, r, name)
119
119
}
120
120
121
-
func (d *Handle) RejectPush(w http.ResponseWriter, r *http.Request, unqualifiedRepoName string) {
121
+
func (d *Knot) RejectPush(w http.ResponseWriter, r *http.Request, unqualifiedRepoName string) {
122
122
// A text/plain response will cause git to print each line of the body
123
123
// prefixed with "remote: ".
124
124
w.Header().Set("content-type", "text/plain; charset=UTF-8")
-1069
knotserver/handler.go
-1069
knotserver/handler.go
···
1
-
package knotserver
2
-
3
-
import (
4
-
"compress/gzip"
5
-
"context"
6
-
"crypto/sha256"
7
-
"encoding/json"
8
-
"errors"
9
-
"fmt"
10
-
"log"
11
-
"net/http"
12
-
"net/url"
13
-
"path/filepath"
14
-
"strconv"
15
-
"strings"
16
-
"sync"
17
-
"time"
18
-
19
-
securejoin "github.com/cyphar/filepath-securejoin"
20
-
"github.com/gliderlabs/ssh"
21
-
"github.com/go-chi/chi/v5"
22
-
"github.com/go-git/go-git/v5/plumbing"
23
-
"github.com/go-git/go-git/v5/plumbing/object"
24
-
"tangled.sh/tangled.sh/core/knotserver/db"
25
-
"tangled.sh/tangled.sh/core/knotserver/git"
26
-
"tangled.sh/tangled.sh/core/types"
27
-
)
28
-
29
-
func (h *Handle) Index(w http.ResponseWriter, r *http.Request) {
30
-
w.Write([]byte("This is a knot server. More info at https://tangled.sh"))
31
-
}
32
-
33
-
func (h *Handle) Capabilities(w http.ResponseWriter, r *http.Request) {
34
-
w.Header().Set("Content-Type", "application/json")
35
-
36
-
capabilities := map[string]any{
37
-
"pull_requests": map[string]any{
38
-
"format_patch": true,
39
-
"patch_submissions": true,
40
-
"branch_submissions": true,
41
-
"fork_submissions": true,
42
-
},
43
-
"xrpc": true,
44
-
}
45
-
46
-
jsonData, err := json.Marshal(capabilities)
47
-
if err != nil {
48
-
http.Error(w, "Failed to serialize JSON", http.StatusInternalServerError)
49
-
return
50
-
}
51
-
52
-
w.Write(jsonData)
53
-
}
54
-
55
-
func (h *Handle) RepoIndex(w http.ResponseWriter, r *http.Request) {
56
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
57
-
l := h.l.With("path", path, "handler", "RepoIndex")
58
-
ref := chi.URLParam(r, "ref")
59
-
ref, _ = url.PathUnescape(ref)
60
-
61
-
gr, err := git.Open(path, ref)
62
-
if err != nil {
63
-
plain, err2 := git.PlainOpen(path)
64
-
if err2 != nil {
65
-
l.Error("opening repo", "error", err2.Error())
66
-
notFound(w)
67
-
return
68
-
}
69
-
branches, _ := plain.Branches()
70
-
71
-
log.Println(err)
72
-
73
-
if errors.Is(err, plumbing.ErrReferenceNotFound) {
74
-
resp := types.RepoIndexResponse{
75
-
IsEmpty: true,
76
-
Branches: branches,
77
-
}
78
-
writeJSON(w, resp)
79
-
return
80
-
} else {
81
-
l.Error("opening repo", "error", err.Error())
82
-
notFound(w)
83
-
return
84
-
}
85
-
}
86
-
87
-
var (
88
-
commits []*object.Commit
89
-
total int
90
-
branches []types.Branch
91
-
files []types.NiceTree
92
-
tags []object.Tag
93
-
)
94
-
95
-
var wg sync.WaitGroup
96
-
errorsCh := make(chan error, 5)
97
-
98
-
wg.Add(1)
99
-
go func() {
100
-
defer wg.Done()
101
-
cs, err := gr.Commits(0, 60)
102
-
if err != nil {
103
-
errorsCh <- fmt.Errorf("commits: %w", err)
104
-
return
105
-
}
106
-
commits = cs
107
-
}()
108
-
109
-
wg.Add(1)
110
-
go func() {
111
-
defer wg.Done()
112
-
t, err := gr.TotalCommits()
113
-
if err != nil {
114
-
errorsCh <- fmt.Errorf("calculating total: %w", err)
115
-
return
116
-
}
117
-
total = t
118
-
}()
119
-
120
-
wg.Add(1)
121
-
go func() {
122
-
defer wg.Done()
123
-
bs, err := gr.Branches()
124
-
if err != nil {
125
-
errorsCh <- fmt.Errorf("fetching branches: %w", err)
126
-
return
127
-
}
128
-
branches = bs
129
-
}()
130
-
131
-
wg.Add(1)
132
-
go func() {
133
-
defer wg.Done()
134
-
ts, err := gr.Tags()
135
-
if err != nil {
136
-
errorsCh <- fmt.Errorf("fetching tags: %w", err)
137
-
return
138
-
}
139
-
tags = ts
140
-
}()
141
-
142
-
wg.Add(1)
143
-
go func() {
144
-
defer wg.Done()
145
-
fs, err := gr.FileTree(r.Context(), "")
146
-
if err != nil {
147
-
errorsCh <- fmt.Errorf("fetching filetree: %w", err)
148
-
return
149
-
}
150
-
files = fs
151
-
}()
152
-
153
-
wg.Wait()
154
-
close(errorsCh)
155
-
156
-
// show any errors
157
-
for err := range errorsCh {
158
-
l.Error("loading repo", "error", err.Error())
159
-
writeError(w, err.Error(), http.StatusInternalServerError)
160
-
return
161
-
}
162
-
163
-
rtags := []*types.TagReference{}
164
-
for _, tag := range tags {
165
-
var target *object.Tag
166
-
if tag.Target != plumbing.ZeroHash {
167
-
target = &tag
168
-
}
169
-
tr := types.TagReference{
170
-
Tag: target,
171
-
}
172
-
173
-
tr.Reference = types.Reference{
174
-
Name: tag.Name,
175
-
Hash: tag.Hash.String(),
176
-
}
177
-
178
-
if tag.Message != "" {
179
-
tr.Message = tag.Message
180
-
}
181
-
182
-
rtags = append(rtags, &tr)
183
-
}
184
-
185
-
var readmeContent string
186
-
var readmeFile string
187
-
for _, readme := range h.c.Repo.Readme {
188
-
content, _ := gr.FileContent(readme)
189
-
if len(content) > 0 {
190
-
readmeContent = string(content)
191
-
readmeFile = readme
192
-
}
193
-
}
194
-
195
-
if ref == "" {
196
-
mainBranch, err := gr.FindMainBranch()
197
-
if err != nil {
198
-
writeError(w, err.Error(), http.StatusInternalServerError)
199
-
l.Error("finding main branch", "error", err.Error())
200
-
return
201
-
}
202
-
ref = mainBranch
203
-
}
204
-
205
-
resp := types.RepoIndexResponse{
206
-
IsEmpty: false,
207
-
Ref: ref,
208
-
Commits: commits,
209
-
Description: getDescription(path),
210
-
Readme: readmeContent,
211
-
ReadmeFileName: readmeFile,
212
-
Files: files,
213
-
Branches: branches,
214
-
Tags: rtags,
215
-
TotalCommits: total,
216
-
}
217
-
218
-
writeJSON(w, resp)
219
-
}
220
-
221
-
func (h *Handle) RepoTree(w http.ResponseWriter, r *http.Request) {
222
-
treePath := chi.URLParam(r, "*")
223
-
ref := chi.URLParam(r, "ref")
224
-
ref, _ = url.PathUnescape(ref)
225
-
226
-
l := h.l.With("handler", "RepoTree", "ref", ref, "treePath", treePath)
227
-
228
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
229
-
gr, err := git.Open(path, ref)
230
-
if err != nil {
231
-
notFound(w)
232
-
return
233
-
}
234
-
235
-
files, err := gr.FileTree(r.Context(), treePath)
236
-
if err != nil {
237
-
writeError(w, err.Error(), http.StatusInternalServerError)
238
-
l.Error("file tree", "error", err.Error())
239
-
return
240
-
}
241
-
242
-
resp := types.RepoTreeResponse{
243
-
Ref: ref,
244
-
Parent: treePath,
245
-
Description: getDescription(path),
246
-
DotDot: filepath.Dir(treePath),
247
-
Files: files,
248
-
}
249
-
250
-
writeJSON(w, resp)
251
-
}
252
-
253
-
func (h *Handle) BlobRaw(w http.ResponseWriter, r *http.Request) {
254
-
treePath := chi.URLParam(r, "*")
255
-
ref := chi.URLParam(r, "ref")
256
-
ref, _ = url.PathUnescape(ref)
257
-
258
-
l := h.l.With("handler", "BlobRaw", "ref", ref, "treePath", treePath)
259
-
260
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
261
-
gr, err := git.Open(path, ref)
262
-
if err != nil {
263
-
notFound(w)
264
-
return
265
-
}
266
-
267
-
contents, err := gr.RawContent(treePath)
268
-
if err != nil {
269
-
writeError(w, err.Error(), http.StatusBadRequest)
270
-
l.Error("file content", "error", err.Error())
271
-
return
272
-
}
273
-
274
-
mimeType := http.DetectContentType(contents)
275
-
276
-
// exception for svg
277
-
if filepath.Ext(treePath) == ".svg" {
278
-
mimeType = "image/svg+xml"
279
-
}
280
-
281
-
contentHash := sha256.Sum256(contents)
282
-
eTag := fmt.Sprintf("\"%x\"", contentHash)
283
-
284
-
// allow image, video, and text/plain files to be served directly
285
-
switch {
286
-
case strings.HasPrefix(mimeType, "image/"), strings.HasPrefix(mimeType, "video/"):
287
-
if clientETag := r.Header.Get("If-None-Match"); clientETag == eTag {
288
-
w.WriteHeader(http.StatusNotModified)
289
-
return
290
-
}
291
-
w.Header().Set("ETag", eTag)
292
-
293
-
case strings.HasPrefix(mimeType, "text/plain"):
294
-
w.Header().Set("Cache-Control", "public, no-cache")
295
-
296
-
default:
297
-
l.Error("attempted to serve disallowed file type", "mimetype", mimeType)
298
-
writeError(w, "only image, video, and text files can be accessed directly", http.StatusForbidden)
299
-
return
300
-
}
301
-
302
-
w.Header().Set("Content-Type", mimeType)
303
-
w.Write(contents)
304
-
}
305
-
306
-
func (h *Handle) Blob(w http.ResponseWriter, r *http.Request) {
307
-
treePath := chi.URLParam(r, "*")
308
-
ref := chi.URLParam(r, "ref")
309
-
ref, _ = url.PathUnescape(ref)
310
-
311
-
l := h.l.With("handler", "Blob", "ref", ref, "treePath", treePath)
312
-
313
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
314
-
gr, err := git.Open(path, ref)
315
-
if err != nil {
316
-
notFound(w)
317
-
return
318
-
}
319
-
320
-
var isBinaryFile bool = false
321
-
contents, err := gr.FileContent(treePath)
322
-
if errors.Is(err, git.ErrBinaryFile) {
323
-
isBinaryFile = true
324
-
} else if errors.Is(err, object.ErrFileNotFound) {
325
-
notFound(w)
326
-
return
327
-
} else if err != nil {
328
-
writeError(w, err.Error(), http.StatusInternalServerError)
329
-
return
330
-
}
331
-
332
-
bytes := []byte(contents)
333
-
// safe := string(sanitize(bytes))
334
-
sizeHint := len(bytes)
335
-
336
-
resp := types.RepoBlobResponse{
337
-
Ref: ref,
338
-
Contents: string(bytes),
339
-
Path: treePath,
340
-
IsBinary: isBinaryFile,
341
-
SizeHint: uint64(sizeHint),
342
-
}
343
-
344
-
h.showFile(resp, w, l)
345
-
}
346
-
347
-
func (h *Handle) Archive(w http.ResponseWriter, r *http.Request) {
348
-
name := chi.URLParam(r, "name")
349
-
file := chi.URLParam(r, "file")
350
-
351
-
l := h.l.With("handler", "Archive", "name", name, "file", file)
352
-
353
-
// TODO: extend this to add more files compression (e.g.: xz)
354
-
if !strings.HasSuffix(file, ".tar.gz") {
355
-
notFound(w)
356
-
return
357
-
}
358
-
359
-
ref := strings.TrimSuffix(file, ".tar.gz")
360
-
361
-
unescapedRef, err := url.PathUnescape(ref)
362
-
if err != nil {
363
-
notFound(w)
364
-
return
365
-
}
366
-
367
-
safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(unescapedRef).Short(), "/", "-")
368
-
369
-
// This allows the browser to use a proper name for the file when
370
-
// downloading
371
-
filename := fmt.Sprintf("%s-%s.tar.gz", name, safeRefFilename)
372
-
setContentDisposition(w, filename)
373
-
setGZipMIME(w)
374
-
375
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
376
-
gr, err := git.Open(path, unescapedRef)
377
-
if err != nil {
378
-
notFound(w)
379
-
return
380
-
}
381
-
382
-
gw := gzip.NewWriter(w)
383
-
defer gw.Close()
384
-
385
-
prefix := fmt.Sprintf("%s-%s", name, safeRefFilename)
386
-
err = gr.WriteTar(gw, prefix)
387
-
if err != nil {
388
-
// once we start writing to the body we can't report error anymore
389
-
// so we are only left with printing the error.
390
-
l.Error("writing tar file", "error", err.Error())
391
-
return
392
-
}
393
-
394
-
err = gw.Flush()
395
-
if err != nil {
396
-
// once we start writing to the body we can't report error anymore
397
-
// so we are only left with printing the error.
398
-
l.Error("flushing?", "error", err.Error())
399
-
return
400
-
}
401
-
}
402
-
403
-
func (h *Handle) Log(w http.ResponseWriter, r *http.Request) {
404
-
ref := chi.URLParam(r, "ref")
405
-
ref, _ = url.PathUnescape(ref)
406
-
407
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
408
-
409
-
l := h.l.With("handler", "Log", "ref", ref, "path", path)
410
-
411
-
gr, err := git.Open(path, ref)
412
-
if err != nil {
413
-
notFound(w)
414
-
return
415
-
}
416
-
417
-
// Get page parameters
418
-
page := 1
419
-
pageSize := 30
420
-
421
-
if pageParam := r.URL.Query().Get("page"); pageParam != "" {
422
-
if p, err := strconv.Atoi(pageParam); err == nil && p > 0 {
423
-
page = p
424
-
}
425
-
}
426
-
427
-
if pageSizeParam := r.URL.Query().Get("per_page"); pageSizeParam != "" {
428
-
if ps, err := strconv.Atoi(pageSizeParam); err == nil && ps > 0 {
429
-
pageSize = ps
430
-
}
431
-
}
432
-
433
-
// convert to offset/limit
434
-
offset := (page - 1) * pageSize
435
-
limit := pageSize
436
-
437
-
commits, err := gr.Commits(offset, limit)
438
-
if err != nil {
439
-
writeError(w, err.Error(), http.StatusInternalServerError)
440
-
l.Error("fetching commits", "error", err.Error())
441
-
return
442
-
}
443
-
444
-
total := len(commits)
445
-
446
-
resp := types.RepoLogResponse{
447
-
Commits: commits,
448
-
Ref: ref,
449
-
Description: getDescription(path),
450
-
Log: true,
451
-
Total: total,
452
-
Page: page,
453
-
PerPage: pageSize,
454
-
}
455
-
456
-
writeJSON(w, resp)
457
-
}
458
-
459
-
func (h *Handle) Diff(w http.ResponseWriter, r *http.Request) {
460
-
ref := chi.URLParam(r, "ref")
461
-
ref, _ = url.PathUnescape(ref)
462
-
463
-
l := h.l.With("handler", "Diff", "ref", ref)
464
-
465
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
466
-
gr, err := git.Open(path, ref)
467
-
if err != nil {
468
-
notFound(w)
469
-
return
470
-
}
471
-
472
-
diff, err := gr.Diff()
473
-
if err != nil {
474
-
writeError(w, err.Error(), http.StatusInternalServerError)
475
-
l.Error("getting diff", "error", err.Error())
476
-
return
477
-
}
478
-
479
-
resp := types.RepoCommitResponse{
480
-
Ref: ref,
481
-
Diff: diff,
482
-
}
483
-
484
-
writeJSON(w, resp)
485
-
}
486
-
487
-
func (h *Handle) Tags(w http.ResponseWriter, r *http.Request) {
488
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
489
-
l := h.l.With("handler", "Refs")
490
-
491
-
gr, err := git.Open(path, "")
492
-
if err != nil {
493
-
notFound(w)
494
-
return
495
-
}
496
-
497
-
tags, err := gr.Tags()
498
-
if err != nil {
499
-
// Non-fatal, we *should* have at least one branch to show.
500
-
l.Warn("getting tags", "error", err.Error())
501
-
}
502
-
503
-
rtags := []*types.TagReference{}
504
-
for _, tag := range tags {
505
-
var target *object.Tag
506
-
if tag.Target != plumbing.ZeroHash {
507
-
target = &tag
508
-
}
509
-
tr := types.TagReference{
510
-
Tag: target,
511
-
}
512
-
513
-
tr.Reference = types.Reference{
514
-
Name: tag.Name,
515
-
Hash: tag.Hash.String(),
516
-
}
517
-
518
-
if tag.Message != "" {
519
-
tr.Message = tag.Message
520
-
}
521
-
522
-
rtags = append(rtags, &tr)
523
-
}
524
-
525
-
resp := types.RepoTagsResponse{
526
-
Tags: rtags,
527
-
}
528
-
529
-
writeJSON(w, resp)
530
-
}
531
-
532
-
func (h *Handle) Branches(w http.ResponseWriter, r *http.Request) {
533
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
534
-
535
-
gr, err := git.PlainOpen(path)
536
-
if err != nil {
537
-
notFound(w)
538
-
return
539
-
}
540
-
541
-
branches, _ := gr.Branches()
542
-
543
-
resp := types.RepoBranchesResponse{
544
-
Branches: branches,
545
-
}
546
-
547
-
writeJSON(w, resp)
548
-
}
549
-
550
-
func (h *Handle) Branch(w http.ResponseWriter, r *http.Request) {
551
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
552
-
branchName := chi.URLParam(r, "branch")
553
-
branchName, _ = url.PathUnescape(branchName)
554
-
555
-
l := h.l.With("handler", "Branch")
556
-
557
-
gr, err := git.PlainOpen(path)
558
-
if err != nil {
559
-
notFound(w)
560
-
return
561
-
}
562
-
563
-
ref, err := gr.Branch(branchName)
564
-
if err != nil {
565
-
l.Error("getting branch", "error", err.Error())
566
-
writeError(w, err.Error(), http.StatusInternalServerError)
567
-
return
568
-
}
569
-
570
-
commit, err := gr.Commit(ref.Hash())
571
-
if err != nil {
572
-
l.Error("getting commit object", "error", err.Error())
573
-
writeError(w, err.Error(), http.StatusInternalServerError)
574
-
return
575
-
}
576
-
577
-
defaultBranch, err := gr.FindMainBranch()
578
-
isDefault := false
579
-
if err != nil {
580
-
l.Error("getting default branch", "error", err.Error())
581
-
// do not quit though
582
-
} else if defaultBranch == branchName {
583
-
isDefault = true
584
-
}
585
-
586
-
resp := types.RepoBranchResponse{
587
-
Branch: types.Branch{
588
-
Reference: types.Reference{
589
-
Name: ref.Name().Short(),
590
-
Hash: ref.Hash().String(),
591
-
},
592
-
Commit: commit,
593
-
IsDefault: isDefault,
594
-
},
595
-
}
596
-
597
-
writeJSON(w, resp)
598
-
}
599
-
600
-
func (h *Handle) Keys(w http.ResponseWriter, r *http.Request) {
601
-
l := h.l.With("handler", "Keys")
602
-
603
-
switch r.Method {
604
-
case http.MethodGet:
605
-
keys, err := h.db.GetAllPublicKeys()
606
-
if err != nil {
607
-
writeError(w, err.Error(), http.StatusInternalServerError)
608
-
l.Error("getting public keys", "error", err.Error())
609
-
return
610
-
}
611
-
612
-
data := make([]map[string]any, 0)
613
-
for _, key := range keys {
614
-
j := key.JSON()
615
-
data = append(data, j)
616
-
}
617
-
writeJSON(w, data)
618
-
return
619
-
620
-
case http.MethodPut:
621
-
pk := db.PublicKey{}
622
-
if err := json.NewDecoder(r.Body).Decode(&pk); err != nil {
623
-
writeError(w, "invalid request body", http.StatusBadRequest)
624
-
return
625
-
}
626
-
627
-
_, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pk.Key))
628
-
if err != nil {
629
-
writeError(w, "invalid pubkey", http.StatusBadRequest)
630
-
}
631
-
632
-
if err := h.db.AddPublicKey(pk); err != nil {
633
-
writeError(w, err.Error(), http.StatusInternalServerError)
634
-
l.Error("adding public key", "error", err.Error())
635
-
return
636
-
}
637
-
638
-
w.WriteHeader(http.StatusNoContent)
639
-
return
640
-
}
641
-
}
642
-
643
-
// func (h *Handle) RepoForkAheadBehind(w http.ResponseWriter, r *http.Request) {
644
-
// l := h.l.With("handler", "RepoForkSync")
645
-
//
646
-
// data := struct {
647
-
// Did string `json:"did"`
648
-
// Source string `json:"source"`
649
-
// Name string `json:"name,omitempty"`
650
-
// HiddenRef string `json:"hiddenref"`
651
-
// }{}
652
-
//
653
-
// if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
654
-
// writeError(w, "invalid request body", http.StatusBadRequest)
655
-
// return
656
-
// }
657
-
//
658
-
// did := data.Did
659
-
// source := data.Source
660
-
//
661
-
// if did == "" || source == "" {
662
-
// l.Error("invalid request body, empty did or name")
663
-
// w.WriteHeader(http.StatusBadRequest)
664
-
// return
665
-
// }
666
-
//
667
-
// var name string
668
-
// if data.Name != "" {
669
-
// name = data.Name
670
-
// } else {
671
-
// name = filepath.Base(source)
672
-
// }
673
-
//
674
-
// branch := chi.URLParam(r, "branch")
675
-
// branch, _ = url.PathUnescape(branch)
676
-
//
677
-
// relativeRepoPath := filepath.Join(did, name)
678
-
// repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath)
679
-
//
680
-
// gr, err := git.PlainOpen(repoPath)
681
-
// if err != nil {
682
-
// log.Println(err)
683
-
// notFound(w)
684
-
// return
685
-
// }
686
-
//
687
-
// forkCommit, err := gr.ResolveRevision(branch)
688
-
// if err != nil {
689
-
// l.Error("error resolving ref revision", "msg", err.Error())
690
-
// writeError(w, fmt.Sprintf("error resolving revision %s", branch), http.StatusBadRequest)
691
-
// return
692
-
// }
693
-
//
694
-
// sourceCommit, err := gr.ResolveRevision(data.HiddenRef)
695
-
// if err != nil {
696
-
// l.Error("error resolving hidden ref revision", "msg", err.Error())
697
-
// writeError(w, fmt.Sprintf("error resolving revision %s", data.HiddenRef), http.StatusBadRequest)
698
-
// return
699
-
// }
700
-
//
701
-
// status := types.UpToDate
702
-
// if forkCommit.Hash.String() != sourceCommit.Hash.String() {
703
-
// isAncestor, err := forkCommit.IsAncestor(sourceCommit)
704
-
// if err != nil {
705
-
// log.Printf("error resolving whether %s is ancestor of %s: %s", branch, data.HiddenRef, err)
706
-
// return
707
-
// }
708
-
//
709
-
// if isAncestor {
710
-
// status = types.FastForwardable
711
-
// } else {
712
-
// status = types.Conflict
713
-
// }
714
-
// }
715
-
//
716
-
// w.Header().Set("Content-Type", "application/json")
717
-
// json.NewEncoder(w).Encode(types.AncestorCheckResponse{Status: status})
718
-
// }
719
-
720
-
func (h *Handle) RepoLanguages(w http.ResponseWriter, r *http.Request) {
721
-
repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
722
-
ref := chi.URLParam(r, "ref")
723
-
ref, _ = url.PathUnescape(ref)
724
-
725
-
l := h.l.With("handler", "RepoLanguages")
726
-
727
-
gr, err := git.Open(repoPath, ref)
728
-
if err != nil {
729
-
l.Error("opening repo", "error", err.Error())
730
-
notFound(w)
731
-
return
732
-
}
733
-
734
-
ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second)
735
-
defer cancel()
736
-
737
-
sizes, err := gr.AnalyzeLanguages(ctx)
738
-
if err != nil {
739
-
l.Error("failed to analyze languages", "error", err.Error())
740
-
writeError(w, err.Error(), http.StatusNoContent)
741
-
return
742
-
}
743
-
744
-
resp := types.RepoLanguageResponse{Languages: sizes}
745
-
746
-
writeJSON(w, resp)
747
-
}
748
-
749
-
// func (h *Handle) RepoForkSync(w http.ResponseWriter, r *http.Request) {
750
-
// l := h.l.With("handler", "RepoForkSync")
751
-
//
752
-
// data := struct {
753
-
// Did string `json:"did"`
754
-
// Source string `json:"source"`
755
-
// Name string `json:"name,omitempty"`
756
-
// }{}
757
-
//
758
-
// if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
759
-
// writeError(w, "invalid request body", http.StatusBadRequest)
760
-
// return
761
-
// }
762
-
//
763
-
// did := data.Did
764
-
// source := data.Source
765
-
//
766
-
// if did == "" || source == "" {
767
-
// l.Error("invalid request body, empty did or name")
768
-
// w.WriteHeader(http.StatusBadRequest)
769
-
// return
770
-
// }
771
-
//
772
-
// var name string
773
-
// if data.Name != "" {
774
-
// name = data.Name
775
-
// } else {
776
-
// name = filepath.Base(source)
777
-
// }
778
-
//
779
-
// branch := chi.URLParam(r, "branch")
780
-
// branch, _ = url.PathUnescape(branch)
781
-
//
782
-
// relativeRepoPath := filepath.Join(did, name)
783
-
// repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath)
784
-
//
785
-
// gr, err := git.Open(repoPath, branch)
786
-
// if err != nil {
787
-
// log.Println(err)
788
-
// notFound(w)
789
-
// return
790
-
// }
791
-
//
792
-
// err = gr.Sync()
793
-
// if err != nil {
794
-
// l.Error("error syncing repo fork", "error", err.Error())
795
-
// writeError(w, err.Error(), http.StatusInternalServerError)
796
-
// return
797
-
// }
798
-
//
799
-
// w.WriteHeader(http.StatusNoContent)
800
-
// }
801
-
802
-
// func (h *Handle) RepoFork(w http.ResponseWriter, r *http.Request) {
803
-
// l := h.l.With("handler", "RepoFork")
804
-
//
805
-
// data := struct {
806
-
// Did string `json:"did"`
807
-
// Source string `json:"source"`
808
-
// Name string `json:"name,omitempty"`
809
-
// }{}
810
-
//
811
-
// if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
812
-
// writeError(w, "invalid request body", http.StatusBadRequest)
813
-
// return
814
-
// }
815
-
//
816
-
// did := data.Did
817
-
// source := data.Source
818
-
//
819
-
// if did == "" || source == "" {
820
-
// l.Error("invalid request body, empty did or name")
821
-
// w.WriteHeader(http.StatusBadRequest)
822
-
// return
823
-
// }
824
-
//
825
-
// var name string
826
-
// if data.Name != "" {
827
-
// name = data.Name
828
-
// } else {
829
-
// name = filepath.Base(source)
830
-
// }
831
-
//
832
-
// relativeRepoPath := filepath.Join(did, name)
833
-
// repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath)
834
-
//
835
-
// err := git.Fork(repoPath, source)
836
-
// if err != nil {
837
-
// l.Error("forking repo", "error", err.Error())
838
-
// writeError(w, err.Error(), http.StatusInternalServerError)
839
-
// return
840
-
// }
841
-
//
842
-
// // add perms for this user to access the repo
843
-
// err = h.e.AddRepo(did, rbac.ThisServer, relativeRepoPath)
844
-
// if err != nil {
845
-
// l.Error("adding repo permissions", "error", err.Error())
846
-
// writeError(w, err.Error(), http.StatusInternalServerError)
847
-
// return
848
-
// }
849
-
//
850
-
// hook.SetupRepo(
851
-
// hook.Config(
852
-
// hook.WithScanPath(h.c.Repo.ScanPath),
853
-
// hook.WithInternalApi(h.c.Server.InternalListenAddr),
854
-
// ),
855
-
// repoPath,
856
-
// )
857
-
//
858
-
// w.WriteHeader(http.StatusNoContent)
859
-
// }
860
-
861
-
// func (h *Handle) RemoveRepo(w http.ResponseWriter, r *http.Request) {
862
-
// l := h.l.With("handler", "RemoveRepo")
863
-
//
864
-
// data := struct {
865
-
// Did string `json:"did"`
866
-
// Name string `json:"name"`
867
-
// }{}
868
-
//
869
-
// if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
870
-
// writeError(w, "invalid request body", http.StatusBadRequest)
871
-
// return
872
-
// }
873
-
//
874
-
// did := data.Did
875
-
// name := data.Name
876
-
//
877
-
// if did == "" || name == "" {
878
-
// l.Error("invalid request body, empty did or name")
879
-
// w.WriteHeader(http.StatusBadRequest)
880
-
// return
881
-
// }
882
-
//
883
-
// relativeRepoPath := filepath.Join(did, name)
884
-
// repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath)
885
-
// err := os.RemoveAll(repoPath)
886
-
// if err != nil {
887
-
// l.Error("removing repo", "error", err.Error())
888
-
// writeError(w, err.Error(), http.StatusInternalServerError)
889
-
// return
890
-
// }
891
-
//
892
-
// w.WriteHeader(http.StatusNoContent)
893
-
//
894
-
// }
895
-
896
-
// func (h *Handle) Merge(w http.ResponseWriter, r *http.Request) {
897
-
// path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
898
-
//
899
-
// data := types.MergeRequest{}
900
-
//
901
-
// if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
902
-
// writeError(w, err.Error(), http.StatusBadRequest)
903
-
// h.l.Error("git: failed to unmarshal json patch", "handler", "Merge", "error", err)
904
-
// return
905
-
// }
906
-
//
907
-
// mo := &git.MergeOptions{
908
-
// AuthorName: data.AuthorName,
909
-
// AuthorEmail: data.AuthorEmail,
910
-
// CommitBody: data.CommitBody,
911
-
// CommitMessage: data.CommitMessage,
912
-
// }
913
-
//
914
-
// patch := data.Patch
915
-
// branch := data.Branch
916
-
// gr, err := git.Open(path, branch)
917
-
// if err != nil {
918
-
// notFound(w)
919
-
// return
920
-
// }
921
-
//
922
-
// mo.FormatPatch = patchutil.IsFormatPatch(patch)
923
-
//
924
-
// if err := gr.MergeWithOptions([]byte(patch), branch, mo); err != nil {
925
-
// var mergeErr *git.ErrMerge
926
-
// if errors.As(err, &mergeErr) {
927
-
// conflicts := make([]types.ConflictInfo, len(mergeErr.Conflicts))
928
-
// for i, conflict := range mergeErr.Conflicts {
929
-
// conflicts[i] = types.ConflictInfo{
930
-
// Filename: conflict.Filename,
931
-
// Reason: conflict.Reason,
932
-
// }
933
-
// }
934
-
// response := types.MergeCheckResponse{
935
-
// IsConflicted: true,
936
-
// Conflicts: conflicts,
937
-
// Message: mergeErr.Message,
938
-
// }
939
-
// writeConflict(w, response)
940
-
// h.l.Error("git: merge conflict", "handler", "Merge", "error", mergeErr)
941
-
// } else {
942
-
// writeError(w, err.Error(), http.StatusBadRequest)
943
-
// h.l.Error("git: failed to merge", "handler", "Merge", "error", err.Error())
944
-
// }
945
-
// return
946
-
// }
947
-
//
948
-
// w.WriteHeader(http.StatusOK)
949
-
// }
950
-
951
-
// func (h *Handle) MergeCheck(w http.ResponseWriter, r *http.Request) {
952
-
// path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
953
-
//
954
-
// var data struct {
955
-
// Patch string `json:"patch"`
956
-
// Branch string `json:"branch"`
957
-
// }
958
-
//
959
-
// if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
960
-
// writeError(w, err.Error(), http.StatusBadRequest)
961
-
// h.l.Error("git: failed to unmarshal json patch", "handler", "MergeCheck", "error", err)
962
-
// return
963
-
// }
964
-
//
965
-
// patch := data.Patch
966
-
// branch := data.Branch
967
-
// gr, err := git.Open(path, branch)
968
-
// if err != nil {
969
-
// notFound(w)
970
-
// return
971
-
// }
972
-
//
973
-
// err = gr.MergeCheck([]byte(patch), branch)
974
-
// if err == nil {
975
-
// response := types.MergeCheckResponse{
976
-
// IsConflicted: false,
977
-
// }
978
-
// writeJSON(w, response)
979
-
// return
980
-
// }
981
-
//
982
-
// var mergeErr *git.ErrMerge
983
-
// if errors.As(err, &mergeErr) {
984
-
// conflicts := make([]types.ConflictInfo, len(mergeErr.Conflicts))
985
-
// for i, conflict := range mergeErr.Conflicts {
986
-
// conflicts[i] = types.ConflictInfo{
987
-
// Filename: conflict.Filename,
988
-
// Reason: conflict.Reason,
989
-
// }
990
-
// }
991
-
// response := types.MergeCheckResponse{
992
-
// IsConflicted: true,
993
-
// Conflicts: conflicts,
994
-
// Message: mergeErr.Message,
995
-
// }
996
-
// writeConflict(w, response)
997
-
// h.l.Error("git: merge conflict", "handler", "MergeCheck", "error", mergeErr.Error())
998
-
// return
999
-
// }
1000
-
// writeError(w, err.Error(), http.StatusInternalServerError)
1001
-
// h.l.Error("git: failed to check merge", "handler", "MergeCheck", "error", err.Error())
1002
-
// }
1003
-
1004
-
func (h *Handle) Compare(w http.ResponseWriter, r *http.Request) {
1005
-
rev1 := chi.URLParam(r, "rev1")
1006
-
rev1, _ = url.PathUnescape(rev1)
1007
-
1008
-
rev2 := chi.URLParam(r, "rev2")
1009
-
rev2, _ = url.PathUnescape(rev2)
1010
-
1011
-
l := h.l.With("handler", "Compare", "r1", rev1, "r2", rev2)
1012
-
1013
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
1014
-
gr, err := git.PlainOpen(path)
1015
-
if err != nil {
1016
-
notFound(w)
1017
-
return
1018
-
}
1019
-
1020
-
commit1, err := gr.ResolveRevision(rev1)
1021
-
if err != nil {
1022
-
l.Error("error resolving revision 1", "msg", err.Error())
1023
-
writeError(w, fmt.Sprintf("error resolving revision %s", rev1), http.StatusBadRequest)
1024
-
return
1025
-
}
1026
-
1027
-
commit2, err := gr.ResolveRevision(rev2)
1028
-
if err != nil {
1029
-
l.Error("error resolving revision 2", "msg", err.Error())
1030
-
writeError(w, fmt.Sprintf("error resolving revision %s", rev2), http.StatusBadRequest)
1031
-
return
1032
-
}
1033
-
1034
-
rawPatch, formatPatch, err := gr.FormatPatch(commit1, commit2)
1035
-
if err != nil {
1036
-
l.Error("error comparing revisions", "msg", err.Error())
1037
-
writeError(w, "error comparing revisions", http.StatusBadRequest)
1038
-
return
1039
-
}
1040
-
1041
-
writeJSON(w, types.RepoFormatPatchResponse{
1042
-
Rev1: commit1.Hash.String(),
1043
-
Rev2: commit2.Hash.String(),
1044
-
FormatPatch: formatPatch,
1045
-
Patch: rawPatch,
1046
-
})
1047
-
}
1048
-
1049
-
func (h *Handle) DefaultBranch(w http.ResponseWriter, r *http.Request) {
1050
-
l := h.l.With("handler", "DefaultBranch")
1051
-
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
1052
-
1053
-
gr, err := git.Open(path, "")
1054
-
if err != nil {
1055
-
notFound(w)
1056
-
return
1057
-
}
1058
-
1059
-
branch, err := gr.FindMainBranch()
1060
-
if err != nil {
1061
-
writeError(w, err.Error(), http.StatusInternalServerError)
1062
-
l.Error("getting default branch", "error", err.Error())
1063
-
return
1064
-
}
1065
-
1066
-
writeJSON(w, types.RepoDefaultBranchResponse{
1067
-
Branch: branch,
1068
-
})
1069
-
}
+15
-10
knotserver/ingester.go
+15
-10
knotserver/ingester.go
···
24
24
"tangled.sh/tangled.sh/core/workflow"
25
25
)
26
26
27
-
func (h *Handle) processPublicKey(ctx context.Context, event *models.Event) error {
27
+
func (h *Knot) processPublicKey(ctx context.Context, event *models.Event) error {
28
28
l := log.FromContext(ctx)
29
29
raw := json.RawMessage(event.Commit.Record)
30
30
did := event.Did
···
46
46
return nil
47
47
}
48
48
49
-
func (h *Handle) processKnotMember(ctx context.Context, event *models.Event) error {
49
+
func (h *Knot) processKnotMember(ctx context.Context, event *models.Event) error {
50
50
l := log.FromContext(ctx)
51
51
raw := json.RawMessage(event.Commit.Record)
52
52
did := event.Did
···
86
86
return nil
87
87
}
88
88
89
-
func (h *Handle) processPull(ctx context.Context, event *models.Event) error {
89
+
func (h *Knot) processPull(ctx context.Context, event *models.Event) error {
90
90
raw := json.RawMessage(event.Commit.Record)
91
91
did := event.Did
92
92
···
98
98
l := log.FromContext(ctx)
99
99
l = l.With("handler", "processPull")
100
100
l = l.With("did", did)
101
-
l = l.With("target_repo", record.TargetRepo)
102
-
l = l.With("target_branch", record.TargetBranch)
101
+
102
+
if record.Target == nil {
103
+
return fmt.Errorf("ignoring pull record: target repo is nil")
104
+
}
105
+
106
+
l = l.With("target_repo", record.Target.Repo)
107
+
l = l.With("target_branch", record.Target.Branch)
103
108
104
109
if record.Source == nil {
105
110
return fmt.Errorf("ignoring pull record: not a branch-based pull request")
···
109
114
return fmt.Errorf("ignoring pull record: fork based pull")
110
115
}
111
116
112
-
repoAt, err := syntax.ParseATURI(record.TargetRepo)
117
+
repoAt, err := syntax.ParseATURI(record.Target.Repo)
113
118
if err != nil {
114
119
return fmt.Errorf("failed to parse ATURI: %w", err)
115
120
}
···
178
183
Action: "create",
179
184
SourceBranch: record.Source.Branch,
180
185
SourceSha: record.Source.Sha,
181
-
TargetBranch: record.TargetBranch,
186
+
TargetBranch: record.Target.Branch,
182
187
}
183
188
184
189
compiler := workflow.Compiler{
···
214
219
}
215
220
216
221
// duplicated from add collaborator
217
-
func (h *Handle) processCollaborator(ctx context.Context, event *models.Event) error {
222
+
func (h *Knot) processCollaborator(ctx context.Context, event *models.Event) error {
218
223
raw := json.RawMessage(event.Commit.Record)
219
224
did := event.Did
220
225
···
275
280
return h.fetchAndAddKeys(ctx, subjectId.DID.String())
276
281
}
277
282
278
-
func (h *Handle) fetchAndAddKeys(ctx context.Context, did string) error {
283
+
func (h *Knot) fetchAndAddKeys(ctx context.Context, did string) error {
279
284
l := log.FromContext(ctx)
280
285
281
286
keysEndpoint, err := url.JoinPath(h.c.AppViewEndpoint, "keys", did)
···
318
323
return nil
319
324
}
320
325
321
-
func (h *Handle) processMessages(ctx context.Context, event *models.Event) error {
326
+
func (h *Knot) processMessages(ctx context.Context, event *models.Event) error {
322
327
if event.Kind != models.EventKindCommit {
323
328
return nil
324
329
}
+152
knotserver/router.go
+152
knotserver/router.go
···
1
+
package knotserver
2
+
3
+
import (
4
+
"context"
5
+
"fmt"
6
+
"log/slog"
7
+
"net/http"
8
+
9
+
"github.com/go-chi/chi/v5"
10
+
"tangled.sh/tangled.sh/core/idresolver"
11
+
"tangled.sh/tangled.sh/core/jetstream"
12
+
"tangled.sh/tangled.sh/core/knotserver/config"
13
+
"tangled.sh/tangled.sh/core/knotserver/db"
14
+
"tangled.sh/tangled.sh/core/knotserver/xrpc"
15
+
tlog "tangled.sh/tangled.sh/core/log"
16
+
"tangled.sh/tangled.sh/core/notifier"
17
+
"tangled.sh/tangled.sh/core/rbac"
18
+
"tangled.sh/tangled.sh/core/xrpc/serviceauth"
19
+
)
20
+
21
+
type Knot struct {
22
+
c *config.Config
23
+
db *db.DB
24
+
jc *jetstream.JetstreamClient
25
+
e *rbac.Enforcer
26
+
l *slog.Logger
27
+
n *notifier.Notifier
28
+
resolver *idresolver.Resolver
29
+
}
30
+
31
+
func Setup(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, jc *jetstream.JetstreamClient, l *slog.Logger, n *notifier.Notifier) (http.Handler, error) {
32
+
r := chi.NewRouter()
33
+
34
+
h := Knot{
35
+
c: c,
36
+
db: db,
37
+
e: e,
38
+
l: l,
39
+
jc: jc,
40
+
n: n,
41
+
resolver: idresolver.DefaultResolver(),
42
+
}
43
+
44
+
err := e.AddKnot(rbac.ThisServer)
45
+
if err != nil {
46
+
return nil, fmt.Errorf("failed to setup enforcer: %w", err)
47
+
}
48
+
49
+
// configure owner
50
+
if err = h.configureOwner(); err != nil {
51
+
return nil, err
52
+
}
53
+
h.l.Info("owner set", "did", h.c.Server.Owner)
54
+
h.jc.AddDid(h.c.Server.Owner)
55
+
56
+
// configure known-dids in jetstream consumer
57
+
dids, err := h.db.GetAllDids()
58
+
if err != nil {
59
+
return nil, fmt.Errorf("failed to get all dids: %w", err)
60
+
}
61
+
for _, d := range dids {
62
+
jc.AddDid(d)
63
+
}
64
+
65
+
err = h.jc.StartJetstream(ctx, h.processMessages)
66
+
if err != nil {
67
+
return nil, fmt.Errorf("failed to start jetstream: %w", err)
68
+
}
69
+
70
+
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
71
+
w.Write([]byte("This is a knot server. More info at https://tangled.sh"))
72
+
})
73
+
74
+
r.Route("/{did}", func(r chi.Router) {
75
+
r.Route("/{name}", func(r chi.Router) {
76
+
// routes for git operations
77
+
r.Get("/info/refs", h.InfoRefs)
78
+
r.Post("/git-upload-pack", h.UploadPack)
79
+
r.Post("/git-receive-pack", h.ReceivePack)
80
+
})
81
+
})
82
+
83
+
// xrpc apis
84
+
r.Mount("/xrpc", h.XrpcRouter())
85
+
86
+
// Socket that streams git oplogs
87
+
r.Get("/events", h.Events)
88
+
89
+
return r, nil
90
+
}
91
+
92
+
func (h *Knot) XrpcRouter() http.Handler {
93
+
logger := tlog.New("knots")
94
+
95
+
serviceAuth := serviceauth.NewServiceAuth(h.l, h.resolver, h.c.Server.Did().String())
96
+
97
+
xrpc := &xrpc.Xrpc{
98
+
Config: h.c,
99
+
Db: h.db,
100
+
Ingester: h.jc,
101
+
Enforcer: h.e,
102
+
Logger: logger,
103
+
Notifier: h.n,
104
+
Resolver: h.resolver,
105
+
ServiceAuth: serviceAuth,
106
+
}
107
+
return xrpc.Router()
108
+
}
109
+
110
+
func (h *Knot) configureOwner() error {
111
+
cfgOwner := h.c.Server.Owner
112
+
113
+
rbacDomain := "thisserver"
114
+
115
+
existing, err := h.e.GetKnotUsersByRole("server:owner", rbacDomain)
116
+
if err != nil {
117
+
return err
118
+
}
119
+
120
+
switch len(existing) {
121
+
case 0:
122
+
// no owner configured, continue
123
+
case 1:
124
+
// find existing owner
125
+
existingOwner := existing[0]
126
+
127
+
// no ownership change, this is okay
128
+
if existingOwner == h.c.Server.Owner {
129
+
break
130
+
}
131
+
132
+
// remove existing owner
133
+
if err = h.db.RemoveDid(existingOwner); err != nil {
134
+
return err
135
+
}
136
+
if err = h.e.RemoveKnotOwner(rbacDomain, existingOwner); err != nil {
137
+
return err
138
+
}
139
+
140
+
default:
141
+
return fmt.Errorf("more than one owner in DB, try deleting %q and starting over", h.c.Server.DBPath)
142
+
}
143
+
144
+
if err = h.db.AddDid(cfgOwner); err != nil {
145
+
return fmt.Errorf("failed to add owner to DB: %w", err)
146
+
}
147
+
if err := h.e.AddKnotOwner(rbacDomain, cfgOwner); err != nil {
148
+
return fmt.Errorf("failed to add owner to RBAC: %w", err)
149
+
}
150
+
151
+
return nil
152
+
}
-207
knotserver/routes.go
-207
knotserver/routes.go
···
1
-
package knotserver
2
-
3
-
import (
4
-
"context"
5
-
"fmt"
6
-
"log/slog"
7
-
"net/http"
8
-
"runtime/debug"
9
-
10
-
"github.com/go-chi/chi/v5"
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/knotserver/xrpc"
16
-
tlog "tangled.sh/tangled.sh/core/log"
17
-
"tangled.sh/tangled.sh/core/notifier"
18
-
"tangled.sh/tangled.sh/core/rbac"
19
-
"tangled.sh/tangled.sh/core/xrpc/serviceauth"
20
-
)
21
-
22
-
type Handle struct {
23
-
c *config.Config
24
-
db *db.DB
25
-
jc *jetstream.JetstreamClient
26
-
e *rbac.Enforcer
27
-
l *slog.Logger
28
-
n *notifier.Notifier
29
-
resolver *idresolver.Resolver
30
-
}
31
-
32
-
func Setup(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, jc *jetstream.JetstreamClient, l *slog.Logger, n *notifier.Notifier) (http.Handler, error) {
33
-
r := chi.NewRouter()
34
-
35
-
h := Handle{
36
-
c: c,
37
-
db: db,
38
-
e: e,
39
-
l: l,
40
-
jc: jc,
41
-
n: n,
42
-
resolver: idresolver.DefaultResolver(),
43
-
}
44
-
45
-
err := e.AddKnot(rbac.ThisServer)
46
-
if err != nil {
47
-
return nil, fmt.Errorf("failed to setup enforcer: %w", err)
48
-
}
49
-
50
-
// configure owner
51
-
if err = h.configureOwner(); err != nil {
52
-
return nil, err
53
-
}
54
-
h.l.Info("owner set", "did", h.c.Server.Owner)
55
-
h.jc.AddDid(h.c.Server.Owner)
56
-
57
-
// configure known-dids in jetstream consumer
58
-
dids, err := h.db.GetAllDids()
59
-
if err != nil {
60
-
return nil, fmt.Errorf("failed to get all dids: %w", err)
61
-
}
62
-
for _, d := range dids {
63
-
jc.AddDid(d)
64
-
}
65
-
66
-
err = h.jc.StartJetstream(ctx, h.processMessages)
67
-
if err != nil {
68
-
return nil, fmt.Errorf("failed to start jetstream: %w", err)
69
-
}
70
-
71
-
r.Get("/", h.Index)
72
-
r.Get("/capabilities", h.Capabilities)
73
-
r.Get("/version", h.Version)
74
-
r.Get("/owner", func(w http.ResponseWriter, r *http.Request) {
75
-
w.Write([]byte(h.c.Server.Owner))
76
-
})
77
-
r.Route("/{did}", func(r chi.Router) {
78
-
// Repo routes
79
-
r.Route("/{name}", func(r chi.Router) {
80
-
81
-
r.Route("/languages", func(r chi.Router) {
82
-
r.Get("/", h.RepoLanguages)
83
-
r.Get("/{ref}", h.RepoLanguages)
84
-
})
85
-
86
-
r.Get("/", h.RepoIndex)
87
-
r.Get("/info/refs", h.InfoRefs)
88
-
r.Post("/git-upload-pack", h.UploadPack)
89
-
r.Post("/git-receive-pack", h.ReceivePack)
90
-
r.Get("/compare/{rev1}/{rev2}", h.Compare) // git diff-tree compare of two objects
91
-
92
-
r.Route("/tree/{ref}", func(r chi.Router) {
93
-
r.Get("/", h.RepoIndex)
94
-
r.Get("/*", h.RepoTree)
95
-
})
96
-
97
-
r.Route("/blob/{ref}", func(r chi.Router) {
98
-
r.Get("/*", h.Blob)
99
-
})
100
-
101
-
r.Route("/raw/{ref}", func(r chi.Router) {
102
-
r.Get("/*", h.BlobRaw)
103
-
})
104
-
105
-
r.Get("/log/{ref}", h.Log)
106
-
r.Get("/archive/{file}", h.Archive)
107
-
r.Get("/commit/{ref}", h.Diff)
108
-
r.Get("/tags", h.Tags)
109
-
r.Route("/branches", func(r chi.Router) {
110
-
r.Get("/", h.Branches)
111
-
r.Get("/{branch}", h.Branch)
112
-
r.Get("/default", h.DefaultBranch)
113
-
})
114
-
})
115
-
})
116
-
117
-
// xrpc apis
118
-
r.Mount("/xrpc", h.XrpcRouter())
119
-
120
-
// Socket that streams git oplogs
121
-
r.Get("/events", h.Events)
122
-
123
-
// All public keys on the knot.
124
-
r.Get("/keys", h.Keys)
125
-
126
-
return r, nil
127
-
}
128
-
129
-
func (h *Handle) XrpcRouter() http.Handler {
130
-
logger := tlog.New("knots")
131
-
132
-
serviceAuth := serviceauth.NewServiceAuth(h.l, h.resolver, h.c.Server.Did().String())
133
-
134
-
xrpc := &xrpc.Xrpc{
135
-
Config: h.c,
136
-
Db: h.db,
137
-
Ingester: h.jc,
138
-
Enforcer: h.e,
139
-
Logger: logger,
140
-
Notifier: h.n,
141
-
Resolver: h.resolver,
142
-
ServiceAuth: serviceAuth,
143
-
}
144
-
return xrpc.Router()
145
-
}
146
-
147
-
// version is set during build time.
148
-
var version string
149
-
150
-
func (h *Handle) Version(w http.ResponseWriter, r *http.Request) {
151
-
if version == "" {
152
-
info, ok := debug.ReadBuildInfo()
153
-
if !ok {
154
-
http.Error(w, "failed to read build info", http.StatusInternalServerError)
155
-
return
156
-
}
157
-
158
-
var modVer string
159
-
for _, mod := range info.Deps {
160
-
if mod.Path == "tangled.sh/tangled.sh/knotserver" {
161
-
version = mod.Version
162
-
break
163
-
}
164
-
}
165
-
166
-
if modVer == "" {
167
-
version = "unknown"
168
-
}
169
-
}
170
-
171
-
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
172
-
fmt.Fprintf(w, "knotserver/%s", version)
173
-
}
174
-
175
-
func (h *Handle) configureOwner() error {
176
-
cfgOwner := h.c.Server.Owner
177
-
178
-
rbacDomain := "thisserver"
179
-
180
-
existing, err := h.e.GetKnotUsersByRole("server:owner", rbacDomain)
181
-
if err != nil {
182
-
return err
183
-
}
184
-
185
-
switch len(existing) {
186
-
case 0:
187
-
// no owner configured, continue
188
-
case 1:
189
-
// find existing owner
190
-
existingOwner := existing[0]
191
-
192
-
// no ownership change, this is okay
193
-
if existingOwner == h.c.Server.Owner {
194
-
break
195
-
}
196
-
197
-
// remove existing owner
198
-
err = h.e.RemoveKnotOwner(rbacDomain, existingOwner)
199
-
if err != nil {
200
-
return nil
201
-
}
202
-
default:
203
-
return fmt.Errorf("more than one owner in DB, try deleting %q and starting over", h.c.Server.DBPath)
204
-
}
205
-
206
-
return h.e.AddKnotOwner(rbacDomain, cfgOwner)
207
-
}
+16
-13
knotserver/server.go
+16
-13
knotserver/server.go
···
22
22
Usage: "run a knot server",
23
23
Action: Run,
24
24
Description: `
25
-
Environment variables:
26
-
KNOT_SERVER_SECRET (required)
27
-
KNOT_SERVER_HOSTNAME (required)
28
-
KNOT_SERVER_LISTEN_ADDR (default: 0.0.0.0:5555)
29
-
KNOT_SERVER_INTERNAL_LISTEN_ADDR (default: 127.0.0.1:5444)
30
-
KNOT_SERVER_DB_PATH (default: knotserver.db)
31
-
KNOT_SERVER_JETSTREAM_ENDPOINT (default: wss://jetstream1.us-west.bsky.network/subscribe)
32
-
KNOT_SERVER_DEV (default: false)
33
-
KNOT_REPO_SCAN_PATH (default: /home/git)
34
-
KNOT_REPO_README (comma-separated list)
35
-
KNOT_REPO_MAIN_BRANCH (default: main)
36
-
APPVIEW_ENDPOINT (default: https://tangled.sh)
37
-
`,
25
+
Environment variables:
26
+
KNOT_SERVER_LISTEN_ADDR (default: 0.0.0.0:5555)
27
+
KNOT_SERVER_INTERNAL_LISTEN_ADDR (default: 127.0.0.1:5444)
28
+
KNOT_SERVER_DB_PATH (default: knotserver.db)
29
+
KNOT_SERVER_HOSTNAME (required)
30
+
KNOT_SERVER_JETSTREAM_ENDPOINT (default: wss://jetstream1.us-west.bsky.network/subscribe)
31
+
KNOT_SERVER_OWNER (required)
32
+
KNOT_SERVER_LOG_DIDS (default: true)
33
+
KNOT_SERVER_DEV (default: false)
34
+
KNOT_REPO_SCAN_PATH (default: /home/git)
35
+
KNOT_REPO_README (comma-separated list)
36
+
KNOT_REPO_MAIN_BRANCH (default: main)
37
+
KNOT_GIT_USER_NAME (default: Tangled)
38
+
KNOT_GIT_USER_EMAIL (default: noreply@tangled.sh)
39
+
APPVIEW_ENDPOINT (default: https://tangled.sh)
40
+
`,
38
41
}
39
42
}
40
43
+58
knotserver/xrpc/list_keys.go
+58
knotserver/xrpc/list_keys.go
···
1
+
package xrpc
2
+
3
+
import (
4
+
"encoding/json"
5
+
"net/http"
6
+
"strconv"
7
+
8
+
"tangled.sh/tangled.sh/core/api/tangled"
9
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
10
+
)
11
+
12
+
func (x *Xrpc) ListKeys(w http.ResponseWriter, r *http.Request) {
13
+
cursor := r.URL.Query().Get("cursor")
14
+
15
+
limit := 100 // default
16
+
if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
17
+
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 1000 {
18
+
limit = l
19
+
}
20
+
}
21
+
22
+
keys, nextCursor, err := x.Db.GetPublicKeysPaginated(limit, cursor)
23
+
if err != nil {
24
+
x.Logger.Error("failed to get public keys", "error", err)
25
+
writeError(w, xrpcerr.NewXrpcError(
26
+
xrpcerr.WithTag("InternalServerError"),
27
+
xrpcerr.WithMessage("failed to retrieve public keys"),
28
+
), http.StatusInternalServerError)
29
+
return
30
+
}
31
+
32
+
publicKeys := make([]*tangled.KnotListKeys_PublicKey, 0, len(keys))
33
+
for _, key := range keys {
34
+
publicKeys = append(publicKeys, &tangled.KnotListKeys_PublicKey{
35
+
Did: key.Did,
36
+
Key: key.Key,
37
+
CreatedAt: key.CreatedAt,
38
+
})
39
+
}
40
+
41
+
response := tangled.KnotListKeys_Output{
42
+
Keys: publicKeys,
43
+
}
44
+
45
+
if nextCursor != "" {
46
+
response.Cursor = &nextCursor
47
+
}
48
+
49
+
w.Header().Set("Content-Type", "application/json")
50
+
if err := json.NewEncoder(w).Encode(response); err != nil {
51
+
x.Logger.Error("failed to encode response", "error", err)
52
+
writeError(w, xrpcerr.NewXrpcError(
53
+
xrpcerr.WithTag("InternalServerError"),
54
+
xrpcerr.WithMessage("failed to encode response"),
55
+
), http.StatusInternalServerError)
56
+
return
57
+
}
58
+
}
+3
-1
knotserver/xrpc/merge.go
+3
-1
knotserver/xrpc/merge.go
···
67
67
return
68
68
}
69
69
70
-
mo := &git.MergeOptions{}
70
+
mo := git.MergeOptions{}
71
71
if data.AuthorName != nil {
72
72
mo.AuthorName = *data.AuthorName
73
73
}
···
81
81
mo.CommitMessage = *data.CommitMessage
82
82
}
83
83
84
+
mo.CommitterName = x.Config.Git.UserName
85
+
mo.CommitterEmail = x.Config.Git.UserEmail
84
86
mo.FormatPatch = patchutil.IsFormatPatch(data.Patch)
85
87
86
88
err = gr.MergeWithOptions([]byte(data.Patch), data.Branch, mo)
+31
knotserver/xrpc/owner.go
+31
knotserver/xrpc/owner.go
···
1
+
package xrpc
2
+
3
+
import (
4
+
"encoding/json"
5
+
"net/http"
6
+
7
+
"tangled.sh/tangled.sh/core/api/tangled"
8
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
9
+
)
10
+
11
+
func (x *Xrpc) Owner(w http.ResponseWriter, r *http.Request) {
12
+
owner := x.Config.Server.Owner
13
+
if owner == "" {
14
+
writeError(w, xrpcerr.OwnerNotFoundError, http.StatusInternalServerError)
15
+
return
16
+
}
17
+
18
+
response := tangled.Owner_Output{
19
+
Owner: owner,
20
+
}
21
+
22
+
w.Header().Set("Content-Type", "application/json")
23
+
if err := json.NewEncoder(w).Encode(response); err != nil {
24
+
x.Logger.Error("failed to encode response", "error", err)
25
+
writeError(w, xrpcerr.NewXrpcError(
26
+
xrpcerr.WithTag("InternalServerError"),
27
+
xrpcerr.WithMessage("failed to encode response"),
28
+
), http.StatusInternalServerError)
29
+
return
30
+
}
31
+
}
+80
knotserver/xrpc/repo_archive.go
+80
knotserver/xrpc/repo_archive.go
···
1
+
package xrpc
2
+
3
+
import (
4
+
"compress/gzip"
5
+
"fmt"
6
+
"net/http"
7
+
"strings"
8
+
9
+
"github.com/go-git/go-git/v5/plumbing"
10
+
11
+
"tangled.sh/tangled.sh/core/knotserver/git"
12
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
13
+
)
14
+
15
+
func (x *Xrpc) RepoArchive(w http.ResponseWriter, r *http.Request) {
16
+
repo, repoPath, unescapedRef, err := x.parseStandardParams(r)
17
+
if err != nil {
18
+
writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest)
19
+
return
20
+
}
21
+
22
+
format := r.URL.Query().Get("format")
23
+
if format == "" {
24
+
format = "tar.gz" // default
25
+
}
26
+
27
+
prefix := r.URL.Query().Get("prefix")
28
+
29
+
if format != "tar.gz" {
30
+
writeError(w, xrpcerr.NewXrpcError(
31
+
xrpcerr.WithTag("InvalidRequest"),
32
+
xrpcerr.WithMessage("only tar.gz format is supported"),
33
+
), http.StatusBadRequest)
34
+
return
35
+
}
36
+
37
+
gr, err := git.Open(repoPath, unescapedRef)
38
+
if err != nil {
39
+
writeError(w, xrpcerr.NewXrpcError(
40
+
xrpcerr.WithTag("RefNotFound"),
41
+
xrpcerr.WithMessage("repository or ref not found"),
42
+
), http.StatusNotFound)
43
+
return
44
+
}
45
+
46
+
repoParts := strings.Split(repo, "/")
47
+
repoName := repoParts[len(repoParts)-1]
48
+
49
+
safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(unescapedRef).Short(), "/", "-")
50
+
51
+
var archivePrefix string
52
+
if prefix != "" {
53
+
archivePrefix = prefix
54
+
} else {
55
+
archivePrefix = fmt.Sprintf("%s-%s", repoName, safeRefFilename)
56
+
}
57
+
58
+
filename := fmt.Sprintf("%s-%s.tar.gz", repoName, safeRefFilename)
59
+
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
60
+
w.Header().Set("Content-Type", "application/gzip")
61
+
62
+
gw := gzip.NewWriter(w)
63
+
defer gw.Close()
64
+
65
+
err = gr.WriteTar(gw, archivePrefix)
66
+
if err != nil {
67
+
// once we start writing to the body we can't report error anymore
68
+
// so we are only left with logging the error
69
+
x.Logger.Error("writing tar file", "error", err.Error())
70
+
return
71
+
}
72
+
73
+
err = gw.Flush()
74
+
if err != nil {
75
+
// once we start writing to the body we can't report error anymore
76
+
// so we are only left with logging the error
77
+
x.Logger.Error("flushing", "error", err.Error())
78
+
return
79
+
}
80
+
}
+151
knotserver/xrpc/repo_blob.go
+151
knotserver/xrpc/repo_blob.go
···
1
+
package xrpc
2
+
3
+
import (
4
+
"crypto/sha256"
5
+
"encoding/base64"
6
+
"encoding/json"
7
+
"fmt"
8
+
"net/http"
9
+
"path/filepath"
10
+
"slices"
11
+
"strings"
12
+
13
+
"tangled.sh/tangled.sh/core/api/tangled"
14
+
"tangled.sh/tangled.sh/core/knotserver/git"
15
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
16
+
)
17
+
18
+
func (x *Xrpc) RepoBlob(w http.ResponseWriter, r *http.Request) {
19
+
_, repoPath, ref, err := x.parseStandardParams(r)
20
+
if err != nil {
21
+
writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest)
22
+
return
23
+
}
24
+
25
+
treePath := r.URL.Query().Get("path")
26
+
if treePath == "" {
27
+
writeError(w, xrpcerr.NewXrpcError(
28
+
xrpcerr.WithTag("InvalidRequest"),
29
+
xrpcerr.WithMessage("missing path parameter"),
30
+
), http.StatusBadRequest)
31
+
return
32
+
}
33
+
34
+
raw := r.URL.Query().Get("raw") == "true"
35
+
36
+
gr, err := git.Open(repoPath, ref)
37
+
if err != nil {
38
+
writeError(w, xrpcerr.NewXrpcError(
39
+
xrpcerr.WithTag("RefNotFound"),
40
+
xrpcerr.WithMessage("repository or ref not found"),
41
+
), http.StatusNotFound)
42
+
return
43
+
}
44
+
45
+
contents, err := gr.RawContent(treePath)
46
+
if err != nil {
47
+
x.Logger.Error("file content", "error", err.Error())
48
+
writeError(w, xrpcerr.NewXrpcError(
49
+
xrpcerr.WithTag("FileNotFound"),
50
+
xrpcerr.WithMessage("file not found at the specified path"),
51
+
), http.StatusNotFound)
52
+
return
53
+
}
54
+
55
+
mimeType := http.DetectContentType(contents)
56
+
57
+
if filepath.Ext(treePath) == ".svg" {
58
+
mimeType = "image/svg+xml"
59
+
}
60
+
61
+
if raw {
62
+
contentHash := sha256.Sum256(contents)
63
+
eTag := fmt.Sprintf("\"%x\"", contentHash)
64
+
65
+
switch {
66
+
case strings.HasPrefix(mimeType, "image/"), strings.HasPrefix(mimeType, "video/"):
67
+
if clientETag := r.Header.Get("If-None-Match"); clientETag == eTag {
68
+
w.WriteHeader(http.StatusNotModified)
69
+
return
70
+
}
71
+
w.Header().Set("ETag", eTag)
72
+
w.Header().Set("Content-Type", mimeType)
73
+
74
+
case strings.HasPrefix(mimeType, "text/"):
75
+
w.Header().Set("Cache-Control", "public, no-cache")
76
+
// serve all text content as text/plain
77
+
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
78
+
79
+
case isTextualMimeType(mimeType):
80
+
// handle textual application types (json, xml, etc.) as text/plain
81
+
w.Header().Set("Cache-Control", "public, no-cache")
82
+
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
83
+
84
+
default:
85
+
x.Logger.Error("attempted to serve disallowed file type", "mimetype", mimeType)
86
+
writeError(w, xrpcerr.NewXrpcError(
87
+
xrpcerr.WithTag("InvalidRequest"),
88
+
xrpcerr.WithMessage("only image, video, and text files can be accessed directly"),
89
+
), http.StatusForbidden)
90
+
return
91
+
}
92
+
w.Write(contents)
93
+
return
94
+
}
95
+
96
+
isTextual := func(mt string) bool {
97
+
return strings.HasPrefix(mt, "text/") || isTextualMimeType(mt)
98
+
}
99
+
100
+
var content string
101
+
var encoding string
102
+
103
+
isBinary := !isTextual(mimeType)
104
+
105
+
if isBinary {
106
+
content = base64.StdEncoding.EncodeToString(contents)
107
+
encoding = "base64"
108
+
} else {
109
+
content = string(contents)
110
+
encoding = "utf-8"
111
+
}
112
+
113
+
response := tangled.RepoBlob_Output{
114
+
Ref: ref,
115
+
Path: treePath,
116
+
Content: content,
117
+
Encoding: &encoding,
118
+
Size: &[]int64{int64(len(contents))}[0],
119
+
IsBinary: &isBinary,
120
+
}
121
+
122
+
if mimeType != "" {
123
+
response.MimeType = &mimeType
124
+
}
125
+
126
+
w.Header().Set("Content-Type", "application/json")
127
+
if err := json.NewEncoder(w).Encode(response); err != nil {
128
+
x.Logger.Error("failed to encode response", "error", err)
129
+
writeError(w, xrpcerr.NewXrpcError(
130
+
xrpcerr.WithTag("InternalServerError"),
131
+
xrpcerr.WithMessage("failed to encode response"),
132
+
), http.StatusInternalServerError)
133
+
return
134
+
}
135
+
}
136
+
137
+
// isTextualMimeType returns true if the MIME type represents textual content
138
+
// that should be served as text/plain for security reasons
139
+
func isTextualMimeType(mimeType string) bool {
140
+
textualTypes := []string{
141
+
"application/json",
142
+
"application/xml",
143
+
"application/yaml",
144
+
"application/x-yaml",
145
+
"application/toml",
146
+
"application/javascript",
147
+
"application/ecmascript",
148
+
}
149
+
150
+
return slices.Contains(textualTypes, mimeType)
151
+
}
+96
knotserver/xrpc/repo_branch.go
+96
knotserver/xrpc/repo_branch.go
···
1
+
package xrpc
2
+
3
+
import (
4
+
"encoding/json"
5
+
"net/http"
6
+
"net/url"
7
+
8
+
"tangled.sh/tangled.sh/core/api/tangled"
9
+
"tangled.sh/tangled.sh/core/knotserver/git"
10
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
11
+
)
12
+
13
+
func (x *Xrpc) RepoBranch(w http.ResponseWriter, r *http.Request) {
14
+
repo := r.URL.Query().Get("repo")
15
+
repoPath, err := x.parseRepoParam(repo)
16
+
if err != nil {
17
+
writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest)
18
+
return
19
+
}
20
+
21
+
name := r.URL.Query().Get("name")
22
+
if name == "" {
23
+
writeError(w, xrpcerr.NewXrpcError(
24
+
xrpcerr.WithTag("InvalidRequest"),
25
+
xrpcerr.WithMessage("missing name parameter"),
26
+
), http.StatusBadRequest)
27
+
return
28
+
}
29
+
30
+
branchName, _ := url.PathUnescape(name)
31
+
32
+
gr, err := git.PlainOpen(repoPath)
33
+
if err != nil {
34
+
writeError(w, xrpcerr.NewXrpcError(
35
+
xrpcerr.WithTag("RepoNotFound"),
36
+
xrpcerr.WithMessage("repository not found"),
37
+
), http.StatusNotFound)
38
+
return
39
+
}
40
+
41
+
ref, err := gr.Branch(branchName)
42
+
if err != nil {
43
+
x.Logger.Error("getting branch", "error", err.Error())
44
+
writeError(w, xrpcerr.NewXrpcError(
45
+
xrpcerr.WithTag("BranchNotFound"),
46
+
xrpcerr.WithMessage("branch not found"),
47
+
), http.StatusNotFound)
48
+
return
49
+
}
50
+
51
+
commit, err := gr.Commit(ref.Hash())
52
+
if err != nil {
53
+
x.Logger.Error("getting commit object", "error", err.Error())
54
+
writeError(w, xrpcerr.NewXrpcError(
55
+
xrpcerr.WithTag("BranchNotFound"),
56
+
xrpcerr.WithMessage("failed to get commit object"),
57
+
), http.StatusInternalServerError)
58
+
return
59
+
}
60
+
61
+
defaultBranch, err := gr.FindMainBranch()
62
+
isDefault := false
63
+
if err != nil {
64
+
x.Logger.Error("getting default branch", "error", err.Error())
65
+
} else if defaultBranch == branchName {
66
+
isDefault = true
67
+
}
68
+
69
+
response := tangled.RepoBranch_Output{
70
+
Name: ref.Name().Short(),
71
+
Hash: ref.Hash().String(),
72
+
ShortHash: &[]string{ref.Hash().String()[:7]}[0],
73
+
When: commit.Author.When.Format("2006-01-02T15:04:05.000Z"),
74
+
IsDefault: &isDefault,
75
+
}
76
+
77
+
if commit.Message != "" {
78
+
response.Message = &commit.Message
79
+
}
80
+
81
+
response.Author = &tangled.RepoBranch_Signature{
82
+
Name: commit.Author.Name,
83
+
Email: commit.Author.Email,
84
+
When: commit.Author.When.Format("2006-01-02T15:04:05.000Z"),
85
+
}
86
+
87
+
w.Header().Set("Content-Type", "application/json")
88
+
if err := json.NewEncoder(w).Encode(response); err != nil {
89
+
x.Logger.Error("failed to encode response", "error", err)
90
+
writeError(w, xrpcerr.NewXrpcError(
91
+
xrpcerr.WithTag("InternalServerError"),
92
+
xrpcerr.WithMessage("failed to encode response"),
93
+
), http.StatusInternalServerError)
94
+
return
95
+
}
96
+
}
+72
knotserver/xrpc/repo_branches.go
+72
knotserver/xrpc/repo_branches.go
···
1
+
package xrpc
2
+
3
+
import (
4
+
"encoding/json"
5
+
"net/http"
6
+
"strconv"
7
+
8
+
"tangled.sh/tangled.sh/core/knotserver/git"
9
+
"tangled.sh/tangled.sh/core/types"
10
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
11
+
)
12
+
13
+
func (x *Xrpc) RepoBranches(w http.ResponseWriter, r *http.Request) {
14
+
repo := r.URL.Query().Get("repo")
15
+
repoPath, err := x.parseRepoParam(repo)
16
+
if err != nil {
17
+
writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest)
18
+
return
19
+
}
20
+
21
+
cursor := r.URL.Query().Get("cursor")
22
+
23
+
// limit := 50 // default
24
+
// if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
25
+
// if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
26
+
// limit = l
27
+
// }
28
+
// }
29
+
30
+
limit := 500
31
+
32
+
gr, err := git.PlainOpen(repoPath)
33
+
if err != nil {
34
+
writeError(w, xrpcerr.NewXrpcError(
35
+
xrpcerr.WithTag("RepoNotFound"),
36
+
xrpcerr.WithMessage("repository not found"),
37
+
), http.StatusNotFound)
38
+
return
39
+
}
40
+
41
+
branches, _ := gr.Branches()
42
+
43
+
offset := 0
44
+
if cursor != "" {
45
+
if o, err := strconv.Atoi(cursor); err == nil && o >= 0 && o < len(branches) {
46
+
offset = o
47
+
}
48
+
}
49
+
50
+
end := offset + limit
51
+
if end > len(branches) {
52
+
end = len(branches)
53
+
}
54
+
55
+
paginatedBranches := branches[offset:end]
56
+
57
+
// Create response using existing types.RepoBranchesResponse
58
+
response := types.RepoBranchesResponse{
59
+
Branches: paginatedBranches,
60
+
}
61
+
62
+
// Write JSON response directly
63
+
w.Header().Set("Content-Type", "application/json")
64
+
if err := json.NewEncoder(w).Encode(response); err != nil {
65
+
x.Logger.Error("failed to encode response", "error", err)
66
+
writeError(w, xrpcerr.NewXrpcError(
67
+
xrpcerr.WithTag("InternalServerError"),
68
+
xrpcerr.WithMessage("failed to encode response"),
69
+
), http.StatusInternalServerError)
70
+
return
71
+
}
72
+
}
+98
knotserver/xrpc/repo_compare.go
+98
knotserver/xrpc/repo_compare.go
···
1
+
package xrpc
2
+
3
+
import (
4
+
"encoding/json"
5
+
"fmt"
6
+
"net/http"
7
+
"net/url"
8
+
9
+
"tangled.sh/tangled.sh/core/knotserver/git"
10
+
"tangled.sh/tangled.sh/core/types"
11
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
12
+
)
13
+
14
+
func (x *Xrpc) RepoCompare(w http.ResponseWriter, r *http.Request) {
15
+
repo := r.URL.Query().Get("repo")
16
+
repoPath, err := x.parseRepoParam(repo)
17
+
if err != nil {
18
+
writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest)
19
+
return
20
+
}
21
+
22
+
rev1Param := r.URL.Query().Get("rev1")
23
+
if rev1Param == "" {
24
+
writeError(w, xrpcerr.NewXrpcError(
25
+
xrpcerr.WithTag("InvalidRequest"),
26
+
xrpcerr.WithMessage("missing rev1 parameter"),
27
+
), http.StatusBadRequest)
28
+
return
29
+
}
30
+
31
+
rev2Param := r.URL.Query().Get("rev2")
32
+
if rev2Param == "" {
33
+
writeError(w, xrpcerr.NewXrpcError(
34
+
xrpcerr.WithTag("InvalidRequest"),
35
+
xrpcerr.WithMessage("missing rev2 parameter"),
36
+
), http.StatusBadRequest)
37
+
return
38
+
}
39
+
40
+
rev1, _ := url.PathUnescape(rev1Param)
41
+
rev2, _ := url.PathUnescape(rev2Param)
42
+
43
+
gr, err := git.PlainOpen(repoPath)
44
+
if err != nil {
45
+
writeError(w, xrpcerr.NewXrpcError(
46
+
xrpcerr.WithTag("RepoNotFound"),
47
+
xrpcerr.WithMessage("repository not found"),
48
+
), http.StatusNotFound)
49
+
return
50
+
}
51
+
52
+
commit1, err := gr.ResolveRevision(rev1)
53
+
if err != nil {
54
+
x.Logger.Error("error resolving revision 1", "msg", err.Error())
55
+
writeError(w, xrpcerr.NewXrpcError(
56
+
xrpcerr.WithTag("RevisionNotFound"),
57
+
xrpcerr.WithMessage(fmt.Sprintf("error resolving revision %s", rev1)),
58
+
), http.StatusBadRequest)
59
+
return
60
+
}
61
+
62
+
commit2, err := gr.ResolveRevision(rev2)
63
+
if err != nil {
64
+
x.Logger.Error("error resolving revision 2", "msg", err.Error())
65
+
writeError(w, xrpcerr.NewXrpcError(
66
+
xrpcerr.WithTag("RevisionNotFound"),
67
+
xrpcerr.WithMessage(fmt.Sprintf("error resolving revision %s", rev2)),
68
+
), http.StatusBadRequest)
69
+
return
70
+
}
71
+
72
+
rawPatch, formatPatch, err := gr.FormatPatch(commit1, commit2)
73
+
if err != nil {
74
+
x.Logger.Error("error comparing revisions", "msg", err.Error())
75
+
writeError(w, xrpcerr.NewXrpcError(
76
+
xrpcerr.WithTag("CompareError"),
77
+
xrpcerr.WithMessage("error comparing revisions"),
78
+
), http.StatusBadRequest)
79
+
return
80
+
}
81
+
82
+
resp := types.RepoFormatPatchResponse{
83
+
Rev1: commit1.Hash.String(),
84
+
Rev2: commit2.Hash.String(),
85
+
FormatPatch: formatPatch,
86
+
Patch: rawPatch,
87
+
}
88
+
89
+
w.Header().Set("Content-Type", "application/json")
90
+
if err := json.NewEncoder(w).Encode(resp); err != nil {
91
+
x.Logger.Error("failed to encode response", "error", err)
92
+
writeError(w, xrpcerr.NewXrpcError(
93
+
xrpcerr.WithTag("InternalServerError"),
94
+
xrpcerr.WithMessage("failed to encode response"),
95
+
), http.StatusInternalServerError)
96
+
return
97
+
}
98
+
}
+65
knotserver/xrpc/repo_diff.go
+65
knotserver/xrpc/repo_diff.go
···
1
+
package xrpc
2
+
3
+
import (
4
+
"encoding/json"
5
+
"net/http"
6
+
"net/url"
7
+
8
+
"tangled.sh/tangled.sh/core/knotserver/git"
9
+
"tangled.sh/tangled.sh/core/types"
10
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
11
+
)
12
+
13
+
func (x *Xrpc) RepoDiff(w http.ResponseWriter, r *http.Request) {
14
+
repo := r.URL.Query().Get("repo")
15
+
repoPath, err := x.parseRepoParam(repo)
16
+
if err != nil {
17
+
writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest)
18
+
return
19
+
}
20
+
21
+
refParam := r.URL.Query().Get("ref")
22
+
if refParam == "" {
23
+
writeError(w, xrpcerr.NewXrpcError(
24
+
xrpcerr.WithTag("InvalidRequest"),
25
+
xrpcerr.WithMessage("missing ref parameter"),
26
+
), http.StatusBadRequest)
27
+
return
28
+
}
29
+
30
+
ref, _ := url.QueryUnescape(refParam)
31
+
32
+
gr, err := git.Open(repoPath, ref)
33
+
if err != nil {
34
+
writeError(w, xrpcerr.NewXrpcError(
35
+
xrpcerr.WithTag("RefNotFound"),
36
+
xrpcerr.WithMessage("repository or ref not found"),
37
+
), http.StatusNotFound)
38
+
return
39
+
}
40
+
41
+
diff, err := gr.Diff()
42
+
if err != nil {
43
+
x.Logger.Error("getting diff", "error", err.Error())
44
+
writeError(w, xrpcerr.NewXrpcError(
45
+
xrpcerr.WithTag("RefNotFound"),
46
+
xrpcerr.WithMessage("failed to generate diff"),
47
+
), http.StatusInternalServerError)
48
+
return
49
+
}
50
+
51
+
resp := types.RepoCommitResponse{
52
+
Ref: ref,
53
+
Diff: diff,
54
+
}
55
+
56
+
w.Header().Set("Content-Type", "application/json")
57
+
if err := json.NewEncoder(w).Encode(resp); err != nil {
58
+
x.Logger.Error("failed to encode response", "error", err)
59
+
writeError(w, xrpcerr.NewXrpcError(
60
+
xrpcerr.WithTag("InternalServerError"),
61
+
xrpcerr.WithMessage("failed to encode response"),
62
+
), http.StatusInternalServerError)
63
+
return
64
+
}
65
+
}
+54
knotserver/xrpc/repo_get_default_branch.go
+54
knotserver/xrpc/repo_get_default_branch.go
···
1
+
package xrpc
2
+
3
+
import (
4
+
"encoding/json"
5
+
"net/http"
6
+
7
+
"tangled.sh/tangled.sh/core/api/tangled"
8
+
"tangled.sh/tangled.sh/core/knotserver/git"
9
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
10
+
)
11
+
12
+
func (x *Xrpc) RepoGetDefaultBranch(w http.ResponseWriter, r *http.Request) {
13
+
repo := r.URL.Query().Get("repo")
14
+
repoPath, err := x.parseRepoParam(repo)
15
+
if err != nil {
16
+
writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest)
17
+
return
18
+
}
19
+
20
+
gr, err := git.Open(repoPath, "")
21
+
if err != nil {
22
+
writeError(w, xrpcerr.NewXrpcError(
23
+
xrpcerr.WithTag("RepoNotFound"),
24
+
xrpcerr.WithMessage("repository not found"),
25
+
), http.StatusNotFound)
26
+
return
27
+
}
28
+
29
+
branch, err := gr.FindMainBranch()
30
+
if err != nil {
31
+
x.Logger.Error("getting default branch", "error", err.Error())
32
+
writeError(w, xrpcerr.NewXrpcError(
33
+
xrpcerr.WithTag("InvalidRequest"),
34
+
xrpcerr.WithMessage("failed to get default branch"),
35
+
), http.StatusInternalServerError)
36
+
return
37
+
}
38
+
39
+
response := tangled.RepoGetDefaultBranch_Output{
40
+
Name: branch,
41
+
Hash: "",
42
+
When: "1970-01-01T00:00:00.000Z",
43
+
}
44
+
45
+
w.Header().Set("Content-Type", "application/json")
46
+
if err := json.NewEncoder(w).Encode(response); err != nil {
47
+
x.Logger.Error("failed to encode response", "error", err)
48
+
writeError(w, xrpcerr.NewXrpcError(
49
+
xrpcerr.WithTag("InternalServerError"),
50
+
xrpcerr.WithMessage("failed to encode response"),
51
+
), http.StatusInternalServerError)
52
+
return
53
+
}
54
+
}
+93
knotserver/xrpc/repo_languages.go
+93
knotserver/xrpc/repo_languages.go
···
1
+
package xrpc
2
+
3
+
import (
4
+
"context"
5
+
"encoding/json"
6
+
"math"
7
+
"net/http"
8
+
"net/url"
9
+
"time"
10
+
11
+
"tangled.sh/tangled.sh/core/api/tangled"
12
+
"tangled.sh/tangled.sh/core/knotserver/git"
13
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
14
+
)
15
+
16
+
func (x *Xrpc) RepoLanguages(w http.ResponseWriter, r *http.Request) {
17
+
refParam := r.URL.Query().Get("ref")
18
+
if refParam == "" {
19
+
refParam = "HEAD" // default
20
+
}
21
+
ref, _ := url.PathUnescape(refParam)
22
+
23
+
repo := r.URL.Query().Get("repo")
24
+
repoPath, err := x.parseRepoParam(repo)
25
+
if err != nil {
26
+
writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest)
27
+
return
28
+
}
29
+
30
+
gr, err := git.Open(repoPath, ref)
31
+
if err != nil {
32
+
x.Logger.Error("opening repo", "error", err.Error())
33
+
writeError(w, xrpcerr.NewXrpcError(
34
+
xrpcerr.WithTag("RefNotFound"),
35
+
xrpcerr.WithMessage("repository or ref not found"),
36
+
), http.StatusNotFound)
37
+
return
38
+
}
39
+
40
+
ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second)
41
+
defer cancel()
42
+
43
+
sizes, err := gr.AnalyzeLanguages(ctx)
44
+
if err != nil {
45
+
x.Logger.Error("failed to analyze languages", "error", err.Error())
46
+
writeError(w, xrpcerr.NewXrpcError(
47
+
xrpcerr.WithTag("InvalidRequest"),
48
+
xrpcerr.WithMessage("failed to analyze repository languages"),
49
+
), http.StatusNoContent)
50
+
return
51
+
}
52
+
53
+
var apiLanguages []*tangled.RepoLanguages_Language
54
+
var totalSize int64
55
+
56
+
for _, size := range sizes {
57
+
totalSize += size
58
+
}
59
+
60
+
for name, size := range sizes {
61
+
percentagef64 := float64(size) / float64(totalSize) * 100
62
+
percentage := math.Round(percentagef64)
63
+
64
+
lang := &tangled.RepoLanguages_Language{
65
+
Name: name,
66
+
Size: size,
67
+
Percentage: int64(percentage),
68
+
}
69
+
70
+
apiLanguages = append(apiLanguages, lang)
71
+
}
72
+
73
+
response := tangled.RepoLanguages_Output{
74
+
Ref: ref,
75
+
Languages: apiLanguages,
76
+
}
77
+
78
+
if totalSize > 0 {
79
+
response.TotalSize = &totalSize
80
+
totalFiles := int64(len(sizes))
81
+
response.TotalFiles = &totalFiles
82
+
}
83
+
84
+
w.Header().Set("Content-Type", "application/json")
85
+
if err := json.NewEncoder(w).Encode(response); err != nil {
86
+
x.Logger.Error("failed to encode response", "error", err)
87
+
writeError(w, xrpcerr.NewXrpcError(
88
+
xrpcerr.WithTag("InternalServerError"),
89
+
xrpcerr.WithMessage("failed to encode response"),
90
+
), http.StatusInternalServerError)
91
+
return
92
+
}
93
+
}
+111
knotserver/xrpc/repo_log.go
+111
knotserver/xrpc/repo_log.go
···
1
+
package xrpc
2
+
3
+
import (
4
+
"encoding/json"
5
+
"net/http"
6
+
"net/url"
7
+
"strconv"
8
+
9
+
"tangled.sh/tangled.sh/core/knotserver/git"
10
+
"tangled.sh/tangled.sh/core/types"
11
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
12
+
)
13
+
14
+
func (x *Xrpc) RepoLog(w http.ResponseWriter, r *http.Request) {
15
+
repo := r.URL.Query().Get("repo")
16
+
repoPath, err := x.parseRepoParam(repo)
17
+
if err != nil {
18
+
writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest)
19
+
return
20
+
}
21
+
22
+
refParam := r.URL.Query().Get("ref")
23
+
if refParam == "" {
24
+
writeError(w, xrpcerr.NewXrpcError(
25
+
xrpcerr.WithTag("InvalidRequest"),
26
+
xrpcerr.WithMessage("missing ref parameter"),
27
+
), http.StatusBadRequest)
28
+
return
29
+
}
30
+
31
+
path := r.URL.Query().Get("path")
32
+
cursor := r.URL.Query().Get("cursor")
33
+
34
+
limit := 50 // default
35
+
if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
36
+
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
37
+
limit = l
38
+
}
39
+
}
40
+
41
+
ref, err := url.QueryUnescape(refParam)
42
+
if err != nil {
43
+
writeError(w, xrpcerr.NewXrpcError(
44
+
xrpcerr.WithTag("InvalidRequest"),
45
+
xrpcerr.WithMessage("invalid ref parameter"),
46
+
), http.StatusBadRequest)
47
+
return
48
+
}
49
+
50
+
gr, err := git.Open(repoPath, ref)
51
+
if err != nil {
52
+
writeError(w, xrpcerr.NewXrpcError(
53
+
xrpcerr.WithTag("RefNotFound"),
54
+
xrpcerr.WithMessage("repository or ref not found"),
55
+
), http.StatusNotFound)
56
+
return
57
+
}
58
+
59
+
offset := 0
60
+
if cursor != "" {
61
+
if o, err := strconv.Atoi(cursor); err == nil && o >= 0 {
62
+
offset = o
63
+
}
64
+
}
65
+
66
+
commits, err := gr.Commits(offset, limit)
67
+
if err != nil {
68
+
x.Logger.Error("fetching commits", "error", err.Error())
69
+
writeError(w, xrpcerr.NewXrpcError(
70
+
xrpcerr.WithTag("PathNotFound"),
71
+
xrpcerr.WithMessage("failed to read commit log"),
72
+
), http.StatusNotFound)
73
+
return
74
+
}
75
+
76
+
total, err := gr.TotalCommits()
77
+
if err != nil {
78
+
x.Logger.Error("fetching total commits", "error", err.Error())
79
+
writeError(w, xrpcerr.NewXrpcError(
80
+
xrpcerr.WithTag("InternalServerError"),
81
+
xrpcerr.WithMessage("failed to fetch total commits"),
82
+
), http.StatusNotFound)
83
+
return
84
+
}
85
+
86
+
// Create response using existing types.RepoLogResponse
87
+
response := types.RepoLogResponse{
88
+
Commits: commits,
89
+
Ref: ref,
90
+
Page: (offset / limit) + 1,
91
+
PerPage: limit,
92
+
Total: total,
93
+
}
94
+
95
+
if path != "" {
96
+
response.Description = path
97
+
}
98
+
99
+
response.Log = true
100
+
101
+
// Write JSON response directly
102
+
w.Header().Set("Content-Type", "application/json")
103
+
if err := json.NewEncoder(w).Encode(response); err != nil {
104
+
x.Logger.Error("failed to encode response", "error", err)
105
+
writeError(w, xrpcerr.NewXrpcError(
106
+
xrpcerr.WithTag("InternalServerError"),
107
+
xrpcerr.WithMessage("failed to encode response"),
108
+
), http.StatusInternalServerError)
109
+
return
110
+
}
111
+
}
+116
knotserver/xrpc/repo_tree.go
+116
knotserver/xrpc/repo_tree.go
···
1
+
package xrpc
2
+
3
+
import (
4
+
"encoding/json"
5
+
"net/http"
6
+
"net/url"
7
+
"path/filepath"
8
+
9
+
"tangled.sh/tangled.sh/core/api/tangled"
10
+
"tangled.sh/tangled.sh/core/knotserver/git"
11
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
12
+
)
13
+
14
+
func (x *Xrpc) RepoTree(w http.ResponseWriter, r *http.Request) {
15
+
ctx := r.Context()
16
+
17
+
repo := r.URL.Query().Get("repo")
18
+
repoPath, err := x.parseRepoParam(repo)
19
+
if err != nil {
20
+
writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest)
21
+
return
22
+
}
23
+
24
+
refParam := r.URL.Query().Get("ref")
25
+
if refParam == "" {
26
+
writeError(w, xrpcerr.NewXrpcError(
27
+
xrpcerr.WithTag("InvalidRequest"),
28
+
xrpcerr.WithMessage("missing ref parameter"),
29
+
), http.StatusBadRequest)
30
+
return
31
+
}
32
+
33
+
path := r.URL.Query().Get("path")
34
+
// path can be empty (defaults to root)
35
+
36
+
ref, err := url.QueryUnescape(refParam)
37
+
if err != nil {
38
+
writeError(w, xrpcerr.NewXrpcError(
39
+
xrpcerr.WithTag("InvalidRequest"),
40
+
xrpcerr.WithMessage("invalid ref parameter"),
41
+
), http.StatusBadRequest)
42
+
return
43
+
}
44
+
45
+
gr, err := git.Open(repoPath, ref)
46
+
if err != nil {
47
+
x.Logger.Error("failed to open git repository", "error", err, "path", repoPath, "ref", ref)
48
+
writeError(w, xrpcerr.NewXrpcError(
49
+
xrpcerr.WithTag("RefNotFound"),
50
+
xrpcerr.WithMessage("repository or ref not found"),
51
+
), http.StatusNotFound)
52
+
return
53
+
}
54
+
55
+
files, err := gr.FileTree(ctx, path)
56
+
if err != nil {
57
+
x.Logger.Error("failed to get file tree", "error", err, "path", path)
58
+
writeError(w, xrpcerr.NewXrpcError(
59
+
xrpcerr.WithTag("PathNotFound"),
60
+
xrpcerr.WithMessage("failed to read repository tree"),
61
+
), http.StatusNotFound)
62
+
return
63
+
}
64
+
65
+
// convert NiceTree -> tangled.RepoTree_TreeEntry
66
+
treeEntries := make([]*tangled.RepoTree_TreeEntry, len(files))
67
+
for i, file := range files {
68
+
entry := &tangled.RepoTree_TreeEntry{
69
+
Name: file.Name,
70
+
Mode: file.Mode,
71
+
Size: file.Size,
72
+
Is_file: file.IsFile,
73
+
Is_subtree: file.IsSubtree,
74
+
}
75
+
76
+
if file.LastCommit != nil {
77
+
entry.Last_commit = &tangled.RepoTree_LastCommit{
78
+
Hash: file.LastCommit.Hash.String(),
79
+
Message: file.LastCommit.Message,
80
+
When: file.LastCommit.When.Format("2006-01-02T15:04:05.000Z"),
81
+
}
82
+
}
83
+
84
+
treeEntries[i] = entry
85
+
}
86
+
87
+
var parentPtr *string
88
+
if path != "" {
89
+
parentPtr = &path
90
+
}
91
+
92
+
var dotdotPtr *string
93
+
if path != "" {
94
+
dotdot := filepath.Dir(path)
95
+
if dotdot != "." {
96
+
dotdotPtr = &dotdot
97
+
}
98
+
}
99
+
100
+
response := tangled.RepoTree_Output{
101
+
Ref: ref,
102
+
Parent: parentPtr,
103
+
Dotdot: dotdotPtr,
104
+
Files: treeEntries,
105
+
}
106
+
107
+
w.Header().Set("Content-Type", "application/json")
108
+
if err := json.NewEncoder(w).Encode(response); err != nil {
109
+
x.Logger.Error("failed to encode response", "error", err)
110
+
writeError(w, xrpcerr.NewXrpcError(
111
+
xrpcerr.WithTag("InternalServerError"),
112
+
xrpcerr.WithMessage("failed to encode response"),
113
+
), http.StatusInternalServerError)
114
+
return
115
+
}
116
+
}
+70
knotserver/xrpc/version.go
+70
knotserver/xrpc/version.go
···
1
+
package xrpc
2
+
3
+
import (
4
+
"encoding/json"
5
+
"fmt"
6
+
"net/http"
7
+
"runtime/debug"
8
+
9
+
"tangled.sh/tangled.sh/core/api/tangled"
10
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
11
+
)
12
+
13
+
// version is set during build time.
14
+
var version string
15
+
16
+
func (x *Xrpc) Version(w http.ResponseWriter, r *http.Request) {
17
+
if version == "" {
18
+
info, ok := debug.ReadBuildInfo()
19
+
if !ok {
20
+
http.Error(w, "failed to read build info", http.StatusInternalServerError)
21
+
return
22
+
}
23
+
24
+
var modVer string
25
+
var sha string
26
+
var modified bool
27
+
28
+
for _, mod := range info.Deps {
29
+
if mod.Path == "tangled.sh/tangled.sh/knotserver/xrpc" {
30
+
modVer = mod.Version
31
+
break
32
+
}
33
+
}
34
+
35
+
for _, setting := range info.Settings {
36
+
switch setting.Key {
37
+
case "vcs.revision":
38
+
sha = setting.Value
39
+
case "vcs.modified":
40
+
modified = setting.Value == "true"
41
+
}
42
+
}
43
+
44
+
if modVer == "" {
45
+
modVer = "unknown"
46
+
}
47
+
48
+
if sha == "" {
49
+
version = modVer
50
+
} else if modified {
51
+
version = fmt.Sprintf("%s (%s with modifications)", modVer, sha)
52
+
} else {
53
+
version = fmt.Sprintf("%s (%s)", modVer, sha)
54
+
}
55
+
}
56
+
57
+
response := tangled.KnotVersion_Output{
58
+
Version: version,
59
+
}
60
+
61
+
w.Header().Set("Content-Type", "application/json")
62
+
if err := json.NewEncoder(w).Encode(response); err != nil {
63
+
x.Logger.Error("failed to encode response", "error", err)
64
+
writeError(w, xrpcerr.NewXrpcError(
65
+
xrpcerr.WithTag("InternalServerError"),
66
+
xrpcerr.WithMessage("failed to encode response"),
67
+
), http.StatusInternalServerError)
68
+
return
69
+
}
70
+
}
+88
knotserver/xrpc/xrpc.go
+88
knotserver/xrpc/xrpc.go
···
4
4
"encoding/json"
5
5
"log/slog"
6
6
"net/http"
7
+
"net/url"
8
+
"strings"
7
9
10
+
securejoin "github.com/cyphar/filepath-securejoin"
8
11
"tangled.sh/tangled.sh/core/api/tangled"
9
12
"tangled.sh/tangled.sh/core/idresolver"
10
13
"tangled.sh/tangled.sh/core/jetstream"
···
50
53
// - we can calculate on PR submit/resubmit/gitRefUpdate etc.
51
54
// - use ETags on clients to keep requests to a minimum
52
55
r.Post("/"+tangled.RepoMergeCheckNSID, x.MergeCheck)
56
+
57
+
// repo query endpoints (no auth required)
58
+
r.Get("/"+tangled.RepoTreeNSID, x.RepoTree)
59
+
r.Get("/"+tangled.RepoLogNSID, x.RepoLog)
60
+
r.Get("/"+tangled.RepoBranchesNSID, x.RepoBranches)
61
+
r.Get("/"+tangled.RepoTagsNSID, x.RepoTags)
62
+
r.Get("/"+tangled.RepoBlobNSID, x.RepoBlob)
63
+
r.Get("/"+tangled.RepoDiffNSID, x.RepoDiff)
64
+
r.Get("/"+tangled.RepoCompareNSID, x.RepoCompare)
65
+
r.Get("/"+tangled.RepoGetDefaultBranchNSID, x.RepoGetDefaultBranch)
66
+
r.Get("/"+tangled.RepoBranchNSID, x.RepoBranch)
67
+
r.Get("/"+tangled.RepoArchiveNSID, x.RepoArchive)
68
+
r.Get("/"+tangled.RepoLanguagesNSID, x.RepoLanguages)
69
+
70
+
// knot query endpoints (no auth required)
71
+
r.Get("/"+tangled.KnotListKeysNSID, x.ListKeys)
72
+
r.Get("/"+tangled.KnotVersionNSID, x.Version)
73
+
74
+
// service query endpoints (no auth required)
75
+
r.Get("/"+tangled.OwnerNSID, x.Owner)
76
+
53
77
return r
78
+
}
79
+
80
+
// parseRepoParam parses a repo parameter in 'did/repoName' format and returns
81
+
// the full repository path on disk
82
+
func (x *Xrpc) parseRepoParam(repo string) (string, error) {
83
+
if repo == "" {
84
+
return "", xrpcerr.NewXrpcError(
85
+
xrpcerr.WithTag("InvalidRequest"),
86
+
xrpcerr.WithMessage("missing repo parameter"),
87
+
)
88
+
}
89
+
90
+
// Parse repo string (did/repoName format)
91
+
parts := strings.Split(repo, "/")
92
+
if len(parts) < 2 {
93
+
return "", xrpcerr.NewXrpcError(
94
+
xrpcerr.WithTag("InvalidRequest"),
95
+
xrpcerr.WithMessage("invalid repo format, expected 'did/repoName'"),
96
+
)
97
+
}
98
+
99
+
did := strings.Join(parts[:len(parts)-1], "/")
100
+
repoName := parts[len(parts)-1]
101
+
102
+
// Construct repository path using the same logic as didPath
103
+
didRepoPath, err := securejoin.SecureJoin(did, repoName)
104
+
if err != nil {
105
+
return "", xrpcerr.NewXrpcError(
106
+
xrpcerr.WithTag("RepoNotFound"),
107
+
xrpcerr.WithMessage("failed to access repository"),
108
+
)
109
+
}
110
+
111
+
repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, didRepoPath)
112
+
if err != nil {
113
+
return "", xrpcerr.NewXrpcError(
114
+
xrpcerr.WithTag("RepoNotFound"),
115
+
xrpcerr.WithMessage("failed to access repository"),
116
+
)
117
+
}
118
+
119
+
return repoPath, nil
120
+
}
121
+
122
+
// parseStandardParams parses common query parameters used by most handlers
123
+
func (x *Xrpc) parseStandardParams(r *http.Request) (repo, repoPath, ref string, err error) {
124
+
// Parse repo parameter
125
+
repo = r.URL.Query().Get("repo")
126
+
repoPath, err = x.parseRepoParam(repo)
127
+
if err != nil {
128
+
return "", "", "", err
129
+
}
130
+
131
+
// Parse and unescape ref parameter
132
+
refParam := r.URL.Query().Get("ref")
133
+
if refParam == "" {
134
+
return "", "", "", xrpcerr.NewXrpcError(
135
+
xrpcerr.WithTag("InvalidRequest"),
136
+
xrpcerr.WithMessage("missing ref parameter"),
137
+
)
138
+
}
139
+
140
+
ref, _ = url.QueryUnescape(refParam)
141
+
return repo, repoPath, ref, nil
54
142
}
55
143
56
144
func writeError(w http.ResponseWriter, e xrpcerr.XrpcError, status int) {
+158
legal/privacy.md
+158
legal/privacy.md
···
1
+
# Privacy Policy
2
+
3
+
**Last updated:** January 15, 2025
4
+
5
+
This Privacy Policy describes how Tangled ("we," "us," or "our")
6
+
collects, uses, and shares your personal information when you use our
7
+
platform and services (the "Service").
8
+
9
+
## 1. Information We Collect
10
+
11
+
### Account Information
12
+
13
+
When you create an account, we collect:
14
+
15
+
- Your chosen username
16
+
- Email address
17
+
- Profile information you choose to provide
18
+
- Authentication data
19
+
20
+
### Content and Activity
21
+
22
+
We store:
23
+
24
+
- Code repositories and associated metadata
25
+
- Issues, pull requests, and comments
26
+
- Activity logs and usage patterns
27
+
- Public keys for authentication
28
+
29
+
## 2. Data Location and Hosting
30
+
31
+
### EU Data Hosting
32
+
33
+
**All Tangled service data is hosted within the European Union.**
34
+
Specifically:
35
+
36
+
- **Personal Data Servers (PDS):** Accounts hosted on Tangled PDS
37
+
(*.tngl.sh) are located in Finland
38
+
- **Application Data:** All other service data is stored on EU-based
39
+
servers
40
+
- **Data Processing:** All data processing occurs within EU
41
+
jurisdiction
42
+
43
+
### External PDS Notice
44
+
45
+
**Important:** If your account is hosted on Bluesky's PDS or other
46
+
self-hosted Personal Data Servers (not *.tngl.sh), we do not control
47
+
that data. The data protection, storage location, and privacy
48
+
practices for such accounts are governed by the respective PDS
49
+
provider's policies, not this Privacy Policy. We only control data
50
+
processing within our own services and infrastructure.
51
+
52
+
## 3. Third-Party Data Processors
53
+
54
+
We only share your data with the following third-party processors:
55
+
56
+
### Resend (Email Services)
57
+
58
+
- **Purpose:** Sending transactional emails (account verification,
59
+
notifications)
60
+
- **Data Shared:** Email address and necessary message content
61
+
62
+
### Cloudflare (Image Caching)
63
+
64
+
- **Purpose:** Caching and optimizing image delivery
65
+
- **Data Shared:** Public images and associated metadata for caching
66
+
purposes
67
+
68
+
### Posthog (Usage Metrics Tracking)
69
+
70
+
- **Purpose:** Tracking usage and platform metrics
71
+
- **Data Shared:** Anonymous usage data, IP addresses, DIDs, and browser
72
+
information
73
+
74
+
## 4. How We Use Your Information
75
+
76
+
We use your information to:
77
+
78
+
- Provide and maintain the Service
79
+
- Process your transactions and requests
80
+
- Send you technical notices and support messages
81
+
- Improve and develop new features
82
+
- Ensure security and prevent fraud
83
+
- Comply with legal obligations
84
+
85
+
## 5. Data Sharing and Disclosure
86
+
87
+
We do not sell, trade, or rent your personal information. We may share
88
+
your information only in the following circumstances:
89
+
90
+
- With the third-party processors listed above
91
+
- When required by law or legal process
92
+
- To protect our rights, property, or safety, or that of our users
93
+
- In connection with a merger, acquisition, or sale of assets (with
94
+
appropriate protections)
95
+
96
+
## 6. Data Security
97
+
98
+
We implement appropriate technical and organizational measures to
99
+
protect your personal information against unauthorized access,
100
+
alteration, disclosure, or destruction. However, no method of
101
+
transmission over the Internet is 100% secure.
102
+
103
+
## 7. Data Retention
104
+
105
+
We retain your personal information for as long as necessary to provide
106
+
the Service and fulfill the purposes outlined in this Privacy Policy,
107
+
unless a longer retention period is required by law.
108
+
109
+
## 8. Your Rights
110
+
111
+
Under applicable data protection laws, you have the right to:
112
+
113
+
- Access your personal information
114
+
- Correct inaccurate information
115
+
- Request deletion of your information
116
+
- Object to processing of your information
117
+
- Data portability
118
+
- Withdraw consent (where applicable)
119
+
120
+
## 9. Cookies and Tracking
121
+
122
+
We use cookies and similar technologies to:
123
+
124
+
- Maintain your login session
125
+
- Remember your preferences
126
+
- Analyze usage patterns to improve the Service
127
+
128
+
You can control cookie settings through your browser preferences.
129
+
130
+
## 10. Children's Privacy
131
+
132
+
The Service is not intended for children under 16 years of age. We do
133
+
not knowingly collect personal information from children under 16. If
134
+
we become aware that we have collected such information, we will take
135
+
steps to delete it.
136
+
137
+
## 11. International Data Transfers
138
+
139
+
While all our primary data processing occurs within the EU, some of our
140
+
third-party processors may process data outside the EU. When this
141
+
occurs, we ensure appropriate safeguards are in place, such as Standard
142
+
Contractual Clauses or adequacy decisions.
143
+
144
+
## 12. Changes to This Privacy Policy
145
+
146
+
We may update this Privacy Policy from time to time. We will notify you
147
+
of any changes by posting the new Privacy Policy on this page and
148
+
updating the "Last updated" date.
149
+
150
+
## 13. Contact Information
151
+
152
+
If you have any questions about this Privacy Policy or wish to exercise
153
+
your rights, please contact us through our platform or via email.
154
+
155
+
---
156
+
157
+
This Privacy Policy complies with the EU General Data Protection
158
+
Regulation (GDPR) and other applicable data protection laws.
+109
legal/terms.md
+109
legal/terms.md
···
1
+
# Terms of Service
2
+
3
+
**Last updated:** January 15, 2025
4
+
5
+
Welcome to Tangled. These Terms of Service ("Terms") govern your access
6
+
to and use of the Tangled platform and services (the "Service")
7
+
operated by us ("Tangled," "we," "us," or "our").
8
+
9
+
## 1. Acceptance of Terms
10
+
11
+
By accessing or using our Service, you agree to be bound by these Terms.
12
+
If you disagree with any part of these terms, then you may not access
13
+
the Service.
14
+
15
+
## 2. Account Registration
16
+
17
+
To use certain features of the Service, you must register for an
18
+
account. You agree to provide accurate, current, and complete
19
+
information during the registration process and to update such
20
+
information to keep it accurate, current, and complete.
21
+
22
+
## 3. Account Termination
23
+
24
+
> **Important Notice**
25
+
>
26
+
> **We reserve the right to terminate, suspend, or restrict access to
27
+
> your account at any time, for any reason, or for no reason at all, at
28
+
> our sole discretion.** This includes, but is not limited to,
29
+
> termination for violation of these Terms, inappropriate conduct, spam,
30
+
> abuse, or any other behavior we deem harmful to the Service or other
31
+
> users.
32
+
>
33
+
> Account termination may result in the loss of access to your
34
+
> repositories, data, and other content associated with your account. We
35
+
> are not obligated to provide advance notice of termination, though we
36
+
> may do so in our discretion.
37
+
38
+
## 4. Acceptable Use
39
+
40
+
You agree not to use the Service to:
41
+
42
+
- Violate any applicable laws or regulations
43
+
- Infringe upon the rights of others
44
+
- Upload, store, or share content that is illegal, harmful, threatening,
45
+
abusive, harassing, defamatory, vulgar, obscene, or otherwise
46
+
objectionable
47
+
- Engage in spam, phishing, or other deceptive practices
48
+
- Attempt to gain unauthorized access to the Service or other users'
49
+
accounts
50
+
- Interfere with or disrupt the Service or servers connected to the
51
+
Service
52
+
53
+
## 5. Content and Intellectual Property
54
+
55
+
You retain ownership of the content you upload to the Service. By
56
+
uploading content, you grant us a non-exclusive, worldwide, royalty-free
57
+
license to use, reproduce, modify, and distribute your content as
58
+
necessary to provide the Service.
59
+
60
+
## 6. Privacy
61
+
62
+
Your privacy is important to us. Please review our [Privacy
63
+
Policy](/privacy), which also governs your use of the Service.
64
+
65
+
## 7. Disclaimers
66
+
67
+
The Service is provided on an "AS IS" and "AS AVAILABLE" basis. We make
68
+
no warranties, expressed or implied, and hereby disclaim and negate all
69
+
other warranties including without limitation, implied warranties or
70
+
conditions of merchantability, fitness for a particular purpose, or
71
+
non-infringement of intellectual property or other violation of rights.
72
+
73
+
## 8. Limitation of Liability
74
+
75
+
In no event shall Tangled, nor its directors, employees, partners,
76
+
agents, suppliers, or affiliates, be liable for any indirect,
77
+
incidental, special, consequential, or punitive damages, including
78
+
without limitation, loss of profits, data, use, goodwill, or other
79
+
intangible losses, resulting from your use of the Service.
80
+
81
+
## 9. Indemnification
82
+
83
+
You agree to defend, indemnify, and hold harmless Tangled and its
84
+
affiliates, officers, directors, employees, and agents from and against
85
+
any and all claims, damages, obligations, losses, liabilities, costs,
86
+
or debt, and expenses (including attorney's fees).
87
+
88
+
## 10. Governing Law
89
+
90
+
These Terms shall be interpreted and governed by the laws of Finland,
91
+
without regard to its conflict of law provisions.
92
+
93
+
## 11. Changes to Terms
94
+
95
+
We reserve the right to modify or replace these Terms at any time. If a
96
+
revision is material, we will try to provide at least 30 days notice
97
+
prior to any new terms taking effect.
98
+
99
+
## 12. Contact Information
100
+
101
+
If you have any questions about these Terms of Service, please contact
102
+
us through our platform or via email.
103
+
104
+
---
105
+
106
+
These terms are effective as of the last updated date shown above and
107
+
will remain in effect except with respect to any changes in their
108
+
provisions in the future, which will be in effect immediately after
109
+
being posted on this page.
+59
-52
lexicons/git/refUpdate.json
+59
-52
lexicons/git/refUpdate.json
···
51
51
"maxLength": 40
52
52
},
53
53
"meta": {
54
-
"type": "object",
55
-
"required": [
56
-
"isDefaultRef",
57
-
"commitCount"
58
-
],
59
-
"properties": {
60
-
"isDefaultRef": {
61
-
"type": "boolean",
62
-
"default": "false"
63
-
},
64
-
"langBreakdown": {
65
-
"type": "object",
66
-
"properties": {
67
-
"inputs": {
68
-
"type": "array",
69
-
"items": {
70
-
"type": "ref",
71
-
"ref": "#pair"
72
-
}
73
-
}
74
-
}
75
-
},
76
-
"commitCount": {
77
-
"type": "object",
78
-
"required": [],
79
-
"properties": {
80
-
"byEmail": {
81
-
"type": "array",
82
-
"items": {
83
-
"type": "object",
84
-
"required": [
85
-
"email",
86
-
"count"
87
-
],
88
-
"properties": {
89
-
"email": {
90
-
"type": "string"
91
-
},
92
-
"count": {
93
-
"type": "integer"
94
-
}
95
-
}
96
-
}
97
-
}
98
-
}
99
-
}
100
-
}
54
+
"type": "ref",
55
+
"ref": "#meta"
56
+
}
57
+
}
58
+
}
59
+
},
60
+
"meta": {
61
+
"type": "object",
62
+
"required": ["isDefaultRef", "commitCount"],
63
+
"properties": {
64
+
"isDefaultRef": {
65
+
"type": "boolean",
66
+
"default": false
67
+
},
68
+
"langBreakdown": {
69
+
"type": "ref",
70
+
"ref": "#langBreakdown"
71
+
},
72
+
"commitCount": {
73
+
"type": "ref",
74
+
"ref": "#commitCountBreakdown"
75
+
}
76
+
}
77
+
},
78
+
"langBreakdown": {
79
+
"type": "object",
80
+
"properties": {
81
+
"inputs": {
82
+
"type": "array",
83
+
"items": {
84
+
"type": "ref",
85
+
"ref": "#individualLanguageSize"
101
86
}
102
87
}
103
88
}
104
89
},
105
-
"pair": {
90
+
"individualLanguageSize": {
106
91
"type": "object",
107
-
"required": [
108
-
"lang",
109
-
"size"
110
-
],
92
+
"required": ["lang", "size"],
111
93
"properties": {
112
94
"lang": {
113
95
"type": "string"
114
96
},
115
97
"size": {
98
+
"type": "integer"
99
+
}
100
+
}
101
+
},
102
+
"commitCountBreakdown": {
103
+
"type": "object",
104
+
"required": [],
105
+
"properties": {
106
+
"byEmail": {
107
+
"type": "array",
108
+
"items": {
109
+
"type": "ref",
110
+
"ref": "#individualEmailCommitCount"
111
+
}
112
+
}
113
+
}
114
+
},
115
+
"individualEmailCommitCount": {
116
+
"type": "object",
117
+
"required": ["email", "count"],
118
+
"properties": {
119
+
"email": {
120
+
"type": "string"
121
+
},
122
+
"count": {
116
123
"type": "integer"
117
124
}
118
125
}
+4
-11
lexicons/issue/comment.json
+4
-11
lexicons/issue/comment.json
···
19
19
"type": "string",
20
20
"format": "at-uri"
21
21
},
22
-
"repo": {
23
-
"type": "string",
24
-
"format": "at-uri"
25
-
},
26
-
"commentId": {
27
-
"type": "integer"
28
-
},
29
-
"owner": {
30
-
"type": "string",
31
-
"format": "did"
32
-
},
33
22
"body": {
34
23
"type": "string"
35
24
},
36
25
"createdAt": {
37
26
"type": "string",
38
27
"format": "datetime"
28
+
},
29
+
"replyTo": {
30
+
"type": "string",
31
+
"format": "at-uri"
39
32
}
40
33
}
41
34
}
+1
-14
lexicons/issue/issue.json
+1
-14
lexicons/issue/issue.json
···
9
9
"key": "tid",
10
10
"record": {
11
11
"type": "object",
12
-
"required": [
13
-
"repo",
14
-
"issueId",
15
-
"owner",
16
-
"title",
17
-
"createdAt"
18
-
],
12
+
"required": ["repo", "title", "createdAt"],
19
13
"properties": {
20
14
"repo": {
21
15
"type": "string",
22
16
"format": "at-uri"
23
-
},
24
-
"issueId": {
25
-
"type": "integer"
26
-
},
27
-
"owner": {
28
-
"type": "string",
29
-
"format": "did"
30
17
},
31
18
"title": {
32
19
"type": "string"
+73
lexicons/knot/listKeys.json
+73
lexicons/knot/listKeys.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.knot.listKeys",
4
+
"defs": {
5
+
"main": {
6
+
"type": "query",
7
+
"description": "List all public keys stored in the knot server",
8
+
"parameters": {
9
+
"type": "params",
10
+
"properties": {
11
+
"limit": {
12
+
"type": "integer",
13
+
"description": "Maximum number of keys to return",
14
+
"minimum": 1,
15
+
"maximum": 1000,
16
+
"default": 100
17
+
},
18
+
"cursor": {
19
+
"type": "string",
20
+
"description": "Pagination cursor"
21
+
}
22
+
}
23
+
},
24
+
"output": {
25
+
"encoding": "application/json",
26
+
"schema": {
27
+
"type": "object",
28
+
"required": ["keys"],
29
+
"properties": {
30
+
"keys": {
31
+
"type": "array",
32
+
"items": {
33
+
"type": "ref",
34
+
"ref": "#publicKey"
35
+
}
36
+
},
37
+
"cursor": {
38
+
"type": "string",
39
+
"description": "Pagination cursor for next page"
40
+
}
41
+
}
42
+
}
43
+
},
44
+
"errors": [
45
+
{
46
+
"name": "InternalServerError",
47
+
"description": "Failed to retrieve public keys"
48
+
}
49
+
]
50
+
},
51
+
"publicKey": {
52
+
"type": "object",
53
+
"required": ["did", "key", "createdAt"],
54
+
"properties": {
55
+
"did": {
56
+
"type": "string",
57
+
"format": "did",
58
+
"description": "DID associated with the public key"
59
+
},
60
+
"key": {
61
+
"type": "string",
62
+
"maxLength": 4096,
63
+
"description": "Public key contents"
64
+
},
65
+
"createdAt": {
66
+
"type": "string",
67
+
"format": "datetime",
68
+
"description": "Key upload timestamp"
69
+
}
70
+
}
71
+
}
72
+
}
73
+
}
+25
lexicons/knot/version.json
+25
lexicons/knot/version.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.knot.version",
4
+
"defs": {
5
+
"main": {
6
+
"type": "query",
7
+
"description": "Get the version of a knot",
8
+
"output": {
9
+
"encoding": "application/json",
10
+
"schema": {
11
+
"type": "object",
12
+
"required": [
13
+
"version"
14
+
],
15
+
"properties": {
16
+
"version": {
17
+
"type": "string"
18
+
}
19
+
}
20
+
}
21
+
},
22
+
"errors": []
23
+
}
24
+
}
25
+
}
+31
lexicons/owner.json
+31
lexicons/owner.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.owner",
4
+
"defs": {
5
+
"main": {
6
+
"type": "query",
7
+
"description": "Get the owner of a service",
8
+
"output": {
9
+
"encoding": "application/json",
10
+
"schema": {
11
+
"type": "object",
12
+
"required": [
13
+
"owner"
14
+
],
15
+
"properties": {
16
+
"owner": {
17
+
"type": "string",
18
+
"format": "did"
19
+
}
20
+
}
21
+
}
22
+
},
23
+
"errors": [
24
+
{
25
+
"name": "OwnerNotFound",
26
+
"description": "Owner is not set for this service"
27
+
}
28
+
]
29
+
}
30
+
}
31
+
}
-11
lexicons/pulls/comment.json
-11
lexicons/pulls/comment.json
+20
-12
lexicons/pulls/pull.json
+20
-12
lexicons/pulls/pull.json
···
10
10
"record": {
11
11
"type": "object",
12
12
"required": [
13
-
"targetRepo",
14
-
"targetBranch",
15
-
"pullId",
13
+
"target",
16
14
"title",
17
15
"patch",
18
16
"createdAt"
19
17
],
20
18
"properties": {
21
-
"targetRepo": {
22
-
"type": "string",
23
-
"format": "at-uri"
24
-
},
25
-
"targetBranch": {
26
-
"type": "string"
27
-
},
28
-
"pullId": {
29
-
"type": "integer"
19
+
"target": {
20
+
"type": "ref",
21
+
"ref": "#target"
30
22
},
31
23
"title": {
32
24
"type": "string"
···
45
37
"type": "string",
46
38
"format": "datetime"
47
39
}
40
+
}
41
+
}
42
+
},
43
+
"target": {
44
+
"type": "object",
45
+
"required": [
46
+
"repo",
47
+
"branch"
48
+
],
49
+
"properties": {
50
+
"repo": {
51
+
"type": "string",
52
+
"format": "at-uri"
53
+
},
54
+
"branch": {
55
+
"type": "string"
48
56
}
49
57
}
50
58
},
+55
lexicons/repo/archive.json
+55
lexicons/repo/archive.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.archive",
4
+
"defs": {
5
+
"main": {
6
+
"type": "query",
7
+
"parameters": {
8
+
"type": "params",
9
+
"required": ["repo", "ref"],
10
+
"properties": {
11
+
"repo": {
12
+
"type": "string",
13
+
"description": "Repository identifier in format 'did:plc:.../repoName'"
14
+
},
15
+
"ref": {
16
+
"type": "string",
17
+
"description": "Git reference (branch, tag, or commit SHA)"
18
+
},
19
+
"format": {
20
+
"type": "string",
21
+
"description": "Archive format",
22
+
"enum": ["tar", "zip", "tar.gz", "tar.bz2", "tar.xz"],
23
+
"default": "tar.gz"
24
+
},
25
+
"prefix": {
26
+
"type": "string",
27
+
"description": "Prefix for files in the archive"
28
+
}
29
+
}
30
+
},
31
+
"output": {
32
+
"encoding": "*/*",
33
+
"description": "Binary archive data"
34
+
},
35
+
"errors": [
36
+
{
37
+
"name": "RepoNotFound",
38
+
"description": "Repository not found or access denied"
39
+
},
40
+
{
41
+
"name": "RefNotFound",
42
+
"description": "Git reference not found"
43
+
},
44
+
{
45
+
"name": "InvalidRequest",
46
+
"description": "Invalid request parameters"
47
+
},
48
+
{
49
+
"name": "ArchiveError",
50
+
"description": "Failed to create archive"
51
+
}
52
+
]
53
+
}
54
+
}
55
+
}
+138
lexicons/repo/blob.json
+138
lexicons/repo/blob.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.blob",
4
+
"defs": {
5
+
"main": {
6
+
"type": "query",
7
+
"parameters": {
8
+
"type": "params",
9
+
"required": ["repo", "ref", "path"],
10
+
"properties": {
11
+
"repo": {
12
+
"type": "string",
13
+
"description": "Repository identifier in format 'did:plc:.../repoName'"
14
+
},
15
+
"ref": {
16
+
"type": "string",
17
+
"description": "Git reference (branch, tag, or commit SHA)"
18
+
},
19
+
"path": {
20
+
"type": "string",
21
+
"description": "Path to the file within the repository"
22
+
},
23
+
"raw": {
24
+
"type": "boolean",
25
+
"description": "Return raw file content instead of JSON response",
26
+
"default": false
27
+
}
28
+
}
29
+
},
30
+
"output": {
31
+
"encoding": "application/json",
32
+
"schema": {
33
+
"type": "object",
34
+
"required": ["ref", "path", "content"],
35
+
"properties": {
36
+
"ref": {
37
+
"type": "string",
38
+
"description": "The git reference used"
39
+
},
40
+
"path": {
41
+
"type": "string",
42
+
"description": "The file path"
43
+
},
44
+
"content": {
45
+
"type": "string",
46
+
"description": "File content (base64 encoded for binary files)"
47
+
},
48
+
"encoding": {
49
+
"type": "string",
50
+
"description": "Content encoding",
51
+
"enum": ["utf-8", "base64"]
52
+
},
53
+
"size": {
54
+
"type": "integer",
55
+
"description": "File size in bytes"
56
+
},
57
+
"isBinary": {
58
+
"type": "boolean",
59
+
"description": "Whether the file is binary"
60
+
},
61
+
"mimeType": {
62
+
"type": "string",
63
+
"description": "MIME type of the file"
64
+
},
65
+
"lastCommit": {
66
+
"type": "ref",
67
+
"ref": "#lastCommit"
68
+
}
69
+
}
70
+
}
71
+
},
72
+
"errors": [
73
+
{
74
+
"name": "RepoNotFound",
75
+
"description": "Repository not found or access denied"
76
+
},
77
+
{
78
+
"name": "RefNotFound",
79
+
"description": "Git reference not found"
80
+
},
81
+
{
82
+
"name": "FileNotFound",
83
+
"description": "File not found at the specified path"
84
+
},
85
+
{
86
+
"name": "InvalidRequest",
87
+
"description": "Invalid request parameters"
88
+
}
89
+
]
90
+
},
91
+
"lastCommit": {
92
+
"type": "object",
93
+
"required": ["hash", "message", "when"],
94
+
"properties": {
95
+
"hash": {
96
+
"type": "string",
97
+
"description": "Commit hash"
98
+
},
99
+
"shortHash": {
100
+
"type": "string",
101
+
"description": "Short commit hash"
102
+
},
103
+
"message": {
104
+
"type": "string",
105
+
"description": "Commit message"
106
+
},
107
+
"author": {
108
+
"type": "ref",
109
+
"ref": "#signature"
110
+
},
111
+
"when": {
112
+
"type": "string",
113
+
"format": "datetime",
114
+
"description": "Commit timestamp"
115
+
}
116
+
}
117
+
},
118
+
"signature": {
119
+
"type": "object",
120
+
"required": ["name", "email", "when"],
121
+
"properties": {
122
+
"name": {
123
+
"type": "string",
124
+
"description": "Author name"
125
+
},
126
+
"email": {
127
+
"type": "string",
128
+
"description": "Author email"
129
+
},
130
+
"when": {
131
+
"type": "string",
132
+
"format": "datetime",
133
+
"description": "Author timestamp"
134
+
}
135
+
}
136
+
}
137
+
}
138
+
}
+94
lexicons/repo/branch.json
+94
lexicons/repo/branch.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.branch",
4
+
"defs": {
5
+
"main": {
6
+
"type": "query",
7
+
"parameters": {
8
+
"type": "params",
9
+
"required": ["repo", "name"],
10
+
"properties": {
11
+
"repo": {
12
+
"type": "string",
13
+
"description": "Repository identifier in format 'did:plc:.../repoName'"
14
+
},
15
+
"name": {
16
+
"type": "string",
17
+
"description": "Branch name to get information for"
18
+
}
19
+
}
20
+
},
21
+
"output": {
22
+
"encoding": "application/json",
23
+
"schema": {
24
+
"type": "object",
25
+
"required": ["name", "hash", "when"],
26
+
"properties": {
27
+
"name": {
28
+
"type": "string",
29
+
"description": "Branch name"
30
+
},
31
+
"hash": {
32
+
"type": "string",
33
+
"description": "Latest commit hash on this branch"
34
+
},
35
+
"shortHash": {
36
+
"type": "string",
37
+
"description": "Short commit hash"
38
+
},
39
+
"when": {
40
+
"type": "string",
41
+
"format": "datetime",
42
+
"description": "Timestamp of latest commit"
43
+
},
44
+
"message": {
45
+
"type": "string",
46
+
"description": "Latest commit message"
47
+
},
48
+
"author": {
49
+
"type": "ref",
50
+
"ref": "#signature"
51
+
},
52
+
"isDefault": {
53
+
"type": "boolean",
54
+
"description": "Whether this is the default branch"
55
+
}
56
+
}
57
+
}
58
+
},
59
+
"errors": [
60
+
{
61
+
"name": "RepoNotFound",
62
+
"description": "Repository not found or access denied"
63
+
},
64
+
{
65
+
"name": "BranchNotFound",
66
+
"description": "Branch not found"
67
+
},
68
+
{
69
+
"name": "InvalidRequest",
70
+
"description": "Invalid request parameters"
71
+
}
72
+
]
73
+
},
74
+
"signature": {
75
+
"type": "object",
76
+
"required": ["name", "email", "when"],
77
+
"properties": {
78
+
"name": {
79
+
"type": "string",
80
+
"description": "Author name"
81
+
},
82
+
"email": {
83
+
"type": "string",
84
+
"description": "Author email"
85
+
},
86
+
"when": {
87
+
"type": "string",
88
+
"format": "datetime",
89
+
"description": "Author timestamp"
90
+
}
91
+
}
92
+
}
93
+
}
94
+
}
+43
lexicons/repo/branches.json
+43
lexicons/repo/branches.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.branches",
4
+
"defs": {
5
+
"main": {
6
+
"type": "query",
7
+
"parameters": {
8
+
"type": "params",
9
+
"required": ["repo"],
10
+
"properties": {
11
+
"repo": {
12
+
"type": "string",
13
+
"description": "Repository identifier in format 'did:plc:.../repoName'"
14
+
},
15
+
"limit": {
16
+
"type": "integer",
17
+
"description": "Maximum number of branches to return",
18
+
"minimum": 1,
19
+
"maximum": 100,
20
+
"default": 50
21
+
},
22
+
"cursor": {
23
+
"type": "string",
24
+
"description": "Pagination cursor"
25
+
}
26
+
}
27
+
},
28
+
"output": {
29
+
"encoding": "*/*"
30
+
},
31
+
"errors": [
32
+
{
33
+
"name": "RepoNotFound",
34
+
"description": "Repository not found or access denied"
35
+
},
36
+
{
37
+
"name": "InvalidRequest",
38
+
"description": "Invalid request parameters"
39
+
}
40
+
]
41
+
}
42
+
}
43
+
}
+49
lexicons/repo/compare.json
+49
lexicons/repo/compare.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.compare",
4
+
"defs": {
5
+
"main": {
6
+
"type": "query",
7
+
"parameters": {
8
+
"type": "params",
9
+
"required": ["repo", "rev1", "rev2"],
10
+
"properties": {
11
+
"repo": {
12
+
"type": "string",
13
+
"description": "Repository identifier in format 'did:plc:.../repoName'"
14
+
},
15
+
"rev1": {
16
+
"type": "string",
17
+
"description": "First revision (commit, branch, or tag)"
18
+
},
19
+
"rev2": {
20
+
"type": "string",
21
+
"description": "Second revision (commit, branch, or tag)"
22
+
}
23
+
}
24
+
},
25
+
"output": {
26
+
"encoding": "*/*",
27
+
"description": "Compare output in application/json"
28
+
},
29
+
"errors": [
30
+
{
31
+
"name": "RepoNotFound",
32
+
"description": "Repository not found or access denied"
33
+
},
34
+
{
35
+
"name": "RevisionNotFound",
36
+
"description": "One or both revisions not found"
37
+
},
38
+
{
39
+
"name": "InvalidRequest",
40
+
"description": "Invalid request parameters"
41
+
},
42
+
{
43
+
"name": "CompareError",
44
+
"description": "Failed to compare revisions"
45
+
}
46
+
]
47
+
}
48
+
}
49
+
}
+40
lexicons/repo/diff.json
+40
lexicons/repo/diff.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.diff",
4
+
"defs": {
5
+
"main": {
6
+
"type": "query",
7
+
"parameters": {
8
+
"type": "params",
9
+
"required": ["repo", "ref"],
10
+
"properties": {
11
+
"repo": {
12
+
"type": "string",
13
+
"description": "Repository identifier in format 'did:plc:.../repoName'"
14
+
},
15
+
"ref": {
16
+
"type": "string",
17
+
"description": "Git reference (branch, tag, or commit SHA)"
18
+
}
19
+
}
20
+
},
21
+
"output": {
22
+
"encoding": "*/*"
23
+
},
24
+
"errors": [
25
+
{
26
+
"name": "RepoNotFound",
27
+
"description": "Repository not found or access denied"
28
+
},
29
+
{
30
+
"name": "RefNotFound",
31
+
"description": "Git reference not found"
32
+
},
33
+
{
34
+
"name": "InvalidRequest",
35
+
"description": "Invalid request parameters"
36
+
}
37
+
]
38
+
}
39
+
}
40
+
}
+82
lexicons/repo/getDefaultBranch.json
+82
lexicons/repo/getDefaultBranch.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.getDefaultBranch",
4
+
"defs": {
5
+
"main": {
6
+
"type": "query",
7
+
"parameters": {
8
+
"type": "params",
9
+
"required": ["repo"],
10
+
"properties": {
11
+
"repo": {
12
+
"type": "string",
13
+
"description": "Repository identifier in format 'did:plc:.../repoName'"
14
+
}
15
+
}
16
+
},
17
+
"output": {
18
+
"encoding": "application/json",
19
+
"schema": {
20
+
"type": "object",
21
+
"required": ["name", "hash", "when"],
22
+
"properties": {
23
+
"name": {
24
+
"type": "string",
25
+
"description": "Default branch name"
26
+
},
27
+
"hash": {
28
+
"type": "string",
29
+
"description": "Latest commit hash on default branch"
30
+
},
31
+
"shortHash": {
32
+
"type": "string",
33
+
"description": "Short commit hash"
34
+
},
35
+
"when": {
36
+
"type": "string",
37
+
"format": "datetime",
38
+
"description": "Timestamp of latest commit"
39
+
},
40
+
"message": {
41
+
"type": "string",
42
+
"description": "Latest commit message"
43
+
},
44
+
"author": {
45
+
"type": "ref",
46
+
"ref": "#signature"
47
+
}
48
+
}
49
+
}
50
+
},
51
+
"errors": [
52
+
{
53
+
"name": "RepoNotFound",
54
+
"description": "Repository not found or access denied"
55
+
},
56
+
{
57
+
"name": "InvalidRequest",
58
+
"description": "Invalid request parameters"
59
+
}
60
+
]
61
+
},
62
+
"signature": {
63
+
"type": "object",
64
+
"required": ["name", "email", "when"],
65
+
"properties": {
66
+
"name": {
67
+
"type": "string",
68
+
"description": "Author name"
69
+
},
70
+
"email": {
71
+
"type": "string",
72
+
"description": "Author email"
73
+
},
74
+
"when": {
75
+
"type": "string",
76
+
"format": "datetime",
77
+
"description": "Author timestamp"
78
+
}
79
+
}
80
+
}
81
+
}
82
+
}
+99
lexicons/repo/languages.json
+99
lexicons/repo/languages.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.languages",
4
+
"defs": {
5
+
"main": {
6
+
"type": "query",
7
+
"parameters": {
8
+
"type": "params",
9
+
"required": ["repo"],
10
+
"properties": {
11
+
"repo": {
12
+
"type": "string",
13
+
"description": "Repository identifier in format 'did:plc:.../repoName'"
14
+
},
15
+
"ref": {
16
+
"type": "string",
17
+
"description": "Git reference (branch, tag, or commit SHA)",
18
+
"default": "HEAD"
19
+
}
20
+
}
21
+
},
22
+
"output": {
23
+
"encoding": "application/json",
24
+
"schema": {
25
+
"type": "object",
26
+
"required": ["ref", "languages"],
27
+
"properties": {
28
+
"ref": {
29
+
"type": "string",
30
+
"description": "The git reference used"
31
+
},
32
+
"languages": {
33
+
"type": "array",
34
+
"items": {
35
+
"type": "ref",
36
+
"ref": "#language"
37
+
}
38
+
},
39
+
"totalSize": {
40
+
"type": "integer",
41
+
"description": "Total size of all analyzed files in bytes"
42
+
},
43
+
"totalFiles": {
44
+
"type": "integer",
45
+
"description": "Total number of files analyzed"
46
+
}
47
+
}
48
+
}
49
+
},
50
+
"errors": [
51
+
{
52
+
"name": "RepoNotFound",
53
+
"description": "Repository not found or access denied"
54
+
},
55
+
{
56
+
"name": "RefNotFound",
57
+
"description": "Git reference not found"
58
+
},
59
+
{
60
+
"name": "InvalidRequest",
61
+
"description": "Invalid request parameters"
62
+
}
63
+
]
64
+
},
65
+
"language": {
66
+
"type": "object",
67
+
"required": ["name", "size", "percentage"],
68
+
"properties": {
69
+
"name": {
70
+
"type": "string",
71
+
"description": "Programming language name"
72
+
},
73
+
"size": {
74
+
"type": "integer",
75
+
"description": "Total size of files in this language (bytes)"
76
+
},
77
+
"percentage": {
78
+
"type": "integer",
79
+
"description": "Percentage of total codebase (0-100)"
80
+
},
81
+
"fileCount": {
82
+
"type": "integer",
83
+
"description": "Number of files in this language"
84
+
},
85
+
"color": {
86
+
"type": "string",
87
+
"description": "Hex color code for this language"
88
+
},
89
+
"extensions": {
90
+
"type": "array",
91
+
"items": {
92
+
"type": "string"
93
+
},
94
+
"description": "File extensions associated with this language"
95
+
}
96
+
}
97
+
}
98
+
}
99
+
}
+60
lexicons/repo/log.json
+60
lexicons/repo/log.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.log",
4
+
"defs": {
5
+
"main": {
6
+
"type": "query",
7
+
"parameters": {
8
+
"type": "params",
9
+
"required": ["repo", "ref"],
10
+
"properties": {
11
+
"repo": {
12
+
"type": "string",
13
+
"description": "Repository identifier in format 'did:plc:.../repoName'"
14
+
},
15
+
"ref": {
16
+
"type": "string",
17
+
"description": "Git reference (branch, tag, or commit SHA)"
18
+
},
19
+
"path": {
20
+
"type": "string",
21
+
"description": "Path to filter commits by",
22
+
"default": ""
23
+
},
24
+
"limit": {
25
+
"type": "integer",
26
+
"description": "Maximum number of commits to return",
27
+
"minimum": 1,
28
+
"maximum": 100,
29
+
"default": 50
30
+
},
31
+
"cursor": {
32
+
"type": "string",
33
+
"description": "Pagination cursor (commit SHA)"
34
+
}
35
+
}
36
+
},
37
+
"output": {
38
+
"encoding": "*/*"
39
+
},
40
+
"errors": [
41
+
{
42
+
"name": "RepoNotFound",
43
+
"description": "Repository not found or access denied"
44
+
},
45
+
{
46
+
"name": "RefNotFound",
47
+
"description": "Git reference not found"
48
+
},
49
+
{
50
+
"name": "PathNotFound",
51
+
"description": "Path not found in repository"
52
+
},
53
+
{
54
+
"name": "InvalidRequest",
55
+
"description": "Invalid request parameters"
56
+
}
57
+
]
58
+
}
59
+
}
60
+
}
-1
lexicons/repo/repo.json
-1
lexicons/repo/repo.json
+123
lexicons/repo/tree.json
+123
lexicons/repo/tree.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.tree",
4
+
"defs": {
5
+
"main": {
6
+
"type": "query",
7
+
"parameters": {
8
+
"type": "params",
9
+
"required": ["repo", "ref"],
10
+
"properties": {
11
+
"repo": {
12
+
"type": "string",
13
+
"description": "Repository identifier in format 'did:plc:.../repoName'"
14
+
},
15
+
"ref": {
16
+
"type": "string",
17
+
"description": "Git reference (branch, tag, or commit SHA)"
18
+
},
19
+
"path": {
20
+
"type": "string",
21
+
"description": "Path within the repository tree",
22
+
"default": ""
23
+
}
24
+
}
25
+
},
26
+
"output": {
27
+
"encoding": "application/json",
28
+
"schema": {
29
+
"type": "object",
30
+
"required": ["ref", "files"],
31
+
"properties": {
32
+
"ref": {
33
+
"type": "string",
34
+
"description": "The git reference used"
35
+
},
36
+
"parent": {
37
+
"type": "string",
38
+
"description": "The parent path in the tree"
39
+
},
40
+
"dotdot": {
41
+
"type": "string",
42
+
"description": "Parent directory path"
43
+
},
44
+
"files": {
45
+
"type": "array",
46
+
"items": {
47
+
"type": "ref",
48
+
"ref": "#treeEntry"
49
+
}
50
+
}
51
+
}
52
+
}
53
+
},
54
+
"errors": [
55
+
{
56
+
"name": "RepoNotFound",
57
+
"description": "Repository not found or access denied"
58
+
},
59
+
{
60
+
"name": "RefNotFound",
61
+
"description": "Git reference not found"
62
+
},
63
+
{
64
+
"name": "PathNotFound",
65
+
"description": "Path not found in repository tree"
66
+
},
67
+
{
68
+
"name": "InvalidRequest",
69
+
"description": "Invalid request parameters"
70
+
}
71
+
]
72
+
},
73
+
"treeEntry": {
74
+
"type": "object",
75
+
"required": ["name", "mode", "size", "is_file", "is_subtree"],
76
+
"properties": {
77
+
"name": {
78
+
"type": "string",
79
+
"description": "Relative file or directory name"
80
+
},
81
+
"mode": {
82
+
"type": "string",
83
+
"description": "File mode"
84
+
},
85
+
"size": {
86
+
"type": "integer",
87
+
"description": "File size in bytes"
88
+
},
89
+
"is_file": {
90
+
"type": "boolean",
91
+
"description": "Whether this entry is a file"
92
+
},
93
+
"is_subtree": {
94
+
"type": "boolean",
95
+
"description": "Whether this entry is a directory/subtree"
96
+
},
97
+
"last_commit": {
98
+
"type": "ref",
99
+
"ref": "#lastCommit"
100
+
}
101
+
}
102
+
},
103
+
"lastCommit": {
104
+
"type": "object",
105
+
"required": ["hash", "message", "when"],
106
+
"properties": {
107
+
"hash": {
108
+
"type": "string",
109
+
"description": "Commit hash"
110
+
},
111
+
"message": {
112
+
"type": "string",
113
+
"description": "Commit message"
114
+
},
115
+
"when": {
116
+
"type": "string",
117
+
"format": "datetime",
118
+
"description": "Commit timestamp"
119
+
}
120
+
}
121
+
}
122
+
}
123
+
}
+8
-2
nix/gomod2nix.toml
+8
-2
nix/gomod2nix.toml
···
425
425
[mod."github.com/whyrusleeping/cbor-gen"]
426
426
version = "v0.3.1"
427
427
hash = "sha256-PAd8M2Z8t6rVRBII+Rg8Bz+QaJIwbW64bfyqsv31kgc="
428
+
[mod."github.com/wyatt915/goldmark-treeblood"]
429
+
version = "v0.0.0-20250825231212-5dcbdb2f4b57"
430
+
hash = "sha256-IZEsUXTBTsNgWoD7vqRUc9aFCCHNjzk1IUmI9O+NCnM="
431
+
[mod."github.com/wyatt915/treeblood"]
432
+
version = "v0.1.15"
433
+
hash = "sha256-hb99exdkoY2Qv8WdDxhwgPXGbEYimUr6wFtPXEvcO9g="
428
434
[mod."github.com/yuin/goldmark"]
429
-
version = "v1.4.15"
430
-
hash = "sha256-MvSOT6dwf5hVYkIg4MnqMpsy5ZtWZ7amAE7Zo9HkEa0="
435
+
version = "v1.7.12"
436
+
hash = "sha256-thLYBS4woL2X5qRdo7vP+xCvjlGRDU0jXtDCUt6vvWM="
431
437
[mod."github.com/yuin/goldmark-highlighting/v2"]
432
438
version = "v2.0.0-20230729083705-37449abec8cc"
433
439
hash = "sha256-HpiwU7jIeDUAg2zOpTIiviQir8dpRPuXYh2nqFFccpg="
+16
nix/modules/spindle.nix
+16
nix/modules/spindle.nix
···
55
55
description = "DID of owner (required)";
56
56
};
57
57
58
+
maxJobCount = mkOption {
59
+
type = types.int;
60
+
default = 2;
61
+
example = 5;
62
+
description = "Maximum number of concurrent jobs to run";
63
+
};
64
+
65
+
queueSize = mkOption {
66
+
type = types.int;
67
+
default = 100;
68
+
example = 100;
69
+
description = "Maximum number of jobs queue up";
70
+
};
71
+
58
72
secrets = {
59
73
provider = mkOption {
60
74
type = types.str;
···
108
122
"SPINDLE_SERVER_JETSTREAM=${cfg.server.jetstreamEndpoint}"
109
123
"SPINDLE_SERVER_DEV=${lib.boolToString cfg.server.dev}"
110
124
"SPINDLE_SERVER_OWNER=${cfg.server.owner}"
125
+
"SPINDLE_SERVER_MAX_JOB_COUNT=${toString cfg.server.maxJobCount}"
126
+
"SPINDLE_SERVER_QUEUE_SIZE=${toString cfg.server.queueSize}"
111
127
"SPINDLE_SERVER_SECRETS_PROVIDER=${cfg.server.secrets.provider}"
112
128
"SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=${cfg.server.secrets.openbao.proxyAddr}"
113
129
"SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=${cfg.server.secrets.openbao.mount}"
+17
-12
nix/pkgs/knot-unwrapped.nix
+17
-12
nix/pkgs/knot-unwrapped.nix
···
3
3
modules,
4
4
sqlite-lib,
5
5
src,
6
-
}:
7
-
buildGoApplication {
8
-
pname = "knot";
9
-
version = "0.1.0";
10
-
inherit src modules;
6
+
}: let
7
+
version = "1.9.0-alpha";
8
+
in
9
+
buildGoApplication {
10
+
pname = "knot";
11
+
inherit src version modules;
12
+
13
+
doCheck = false;
11
14
12
-
doCheck = false;
15
+
subPackages = ["cmd/knot"];
16
+
tags = ["libsqlite3"];
13
17
14
-
subPackages = ["cmd/knot"];
15
-
tags = ["libsqlite3"];
18
+
ldflags = [
19
+
"-X tangled.sh/tangled.sh/core/knotserver/xrpc.version=${version}"
20
+
];
16
21
17
-
env.CGO_CFLAGS = "-I ${sqlite-lib}/include ";
18
-
env.CGO_LDFLAGS = "-L ${sqlite-lib}/lib";
19
-
CGO_ENABLED = 1;
20
-
}
22
+
env.CGO_CFLAGS = "-I ${sqlite-lib}/include ";
23
+
env.CGO_LDFLAGS = "-L ${sqlite-lib}/lib";
24
+
CGO_ENABLED = 1;
25
+
}
+2
nix/vm.nix
+2
nix/vm.nix
+1
-1
patchutil/combinediff.go
+1
-1
patchutil/combinediff.go
+2
spindle/config/config.go
+2
spindle/config/config.go
···
17
17
Owner string `env:"OWNER, required"`
18
18
Secrets Secrets `env:",prefix=SECRETS_"`
19
19
LogDir string `env:"LOG_DIR, default=/var/log/spindle"`
20
+
QueueSize int `env:"QUEUE_SIZE, default=100"`
21
+
MaxJobCount int `env:"MAX_JOB_COUNT, default=2"` // max number of jobs that run at a time
20
22
}
21
23
22
24
func (s Server) Did() syntax.DID {
+2
-4
spindle/server.go
+2
-4
spindle/server.go
···
100
100
return err
101
101
}
102
102
103
-
jq := queue.NewQueue(100, 5)
103
+
jq := queue.NewQueue(cfg.Server.QueueSize, cfg.Server.MaxJobCount)
104
+
logger.Info("initialized queue", "queueSize", cfg.Server.QueueSize, "numWorkers", cfg.Server.MaxJobCount)
104
105
105
106
collections := []string{
106
107
tangled.SpindleMemberNSID,
···
202
203
w.Write(motd)
203
204
})
204
205
mux.HandleFunc("/events", s.Events)
205
-
mux.HandleFunc("/owner", func(w http.ResponseWriter, r *http.Request) {
206
-
w.Write([]byte(s.cfg.Server.Owner))
207
-
})
208
206
mux.HandleFunc("/logs/{knot}/{rkey}/{name}", s.Logs)
209
207
210
208
mux.Mount("/xrpc", s.XrpcRouter())
+31
spindle/xrpc/owner.go
+31
spindle/xrpc/owner.go
···
1
+
package xrpc
2
+
3
+
import (
4
+
"encoding/json"
5
+
"net/http"
6
+
7
+
"tangled.sh/tangled.sh/core/api/tangled"
8
+
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
9
+
)
10
+
11
+
func (x *Xrpc) Owner(w http.ResponseWriter, r *http.Request) {
12
+
owner := x.Config.Server.Owner
13
+
if owner == "" {
14
+
writeError(w, xrpcerr.OwnerNotFoundError, http.StatusInternalServerError)
15
+
return
16
+
}
17
+
18
+
response := tangled.Owner_Output{
19
+
Owner: owner,
20
+
}
21
+
22
+
w.Header().Set("Content-Type", "application/json")
23
+
if err := json.NewEncoder(w).Encode(response); err != nil {
24
+
x.Logger.Error("failed to encode response", "error", err)
25
+
writeError(w, xrpcerr.NewXrpcError(
26
+
xrpcerr.WithTag("InternalServerError"),
27
+
xrpcerr.WithMessage("failed to encode response"),
28
+
), http.StatusInternalServerError)
29
+
return
30
+
}
31
+
}
+10
-3
spindle/xrpc/xrpc.go
+10
-3
spindle/xrpc/xrpc.go
···
35
35
func (x *Xrpc) Router() http.Handler {
36
36
r := chi.NewRouter()
37
37
38
-
r.With(x.ServiceAuth.VerifyServiceAuth).Post("/"+tangled.RepoAddSecretNSID, x.AddSecret)
39
-
r.With(x.ServiceAuth.VerifyServiceAuth).Post("/"+tangled.RepoRemoveSecretNSID, x.RemoveSecret)
40
-
r.With(x.ServiceAuth.VerifyServiceAuth).Get("/"+tangled.RepoListSecretsNSID, x.ListSecrets)
38
+
r.Group(func(r chi.Router) {
39
+
r.Use(x.ServiceAuth.VerifyServiceAuth)
40
+
41
+
r.Post("/"+tangled.RepoAddSecretNSID, x.AddSecret)
42
+
r.Post("/"+tangled.RepoRemoveSecretNSID, x.RemoveSecret)
43
+
r.Get("/"+tangled.RepoListSecretsNSID, x.ListSecrets)
44
+
})
45
+
46
+
// service query endpoints (no auth required)
47
+
r.Get("/"+tangled.OwnerNSID, x.Owner)
41
48
42
49
return r
43
50
}
+5
xrpc/errors/errors.go
+5
xrpc/errors/errors.go
···
51
51
WithMessage("actor DID not supplied"),
52
52
)
53
53
54
+
var OwnerNotFoundError = NewXrpcError(
55
+
WithTag("OwnerNotFound"),
56
+
WithMessage("owner not set for this service"),
57
+
)
58
+
54
59
var AuthError = func(err error) XrpcError {
55
60
return NewXrpcError(
56
61
WithTag("Auth"),