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

Compare changes

Choose any two refs to compare.

+9198 -5875
+388 -732
api/tangled/cbor_gen.go
··· 1202 1203 return nil 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 { 1386 if t == nil { 1387 _, err := w.Write(cbg.CborNull) 1388 return err ··· 1399 return err 1400 } 1401 1402 - // t.ByEmail ([]*tangled.GitRefUpdate_Meta_CommitCount_ByEmail_Elem) (slice) 1403 if t.ByEmail != nil { 1404 1405 if len("byEmail") > 1000000 { ··· 1430 return nil 1431 } 1432 1433 - func (t *GitRefUpdate_Meta_CommitCount) UnmarshalCBOR(r io.Reader) (err error) { 1434 - *t = GitRefUpdate_Meta_CommitCount{} 1435 1436 cr := cbg.NewCborReader(r) 1437 ··· 1450 } 1451 1452 if extra > cbg.MaxLength { 1453 - return fmt.Errorf("GitRefUpdate_Meta_CommitCount: map struct too large (%d)", extra) 1454 } 1455 1456 n := extra ··· 1471 } 1472 1473 switch string(nameBuf[:nameLen]) { 1474 - // t.ByEmail ([]*tangled.GitRefUpdate_Meta_CommitCount_ByEmail_Elem) (slice) 1475 case "byEmail": 1476 1477 maj, extra, err = cr.ReadHeader() ··· 1488 } 1489 1490 if extra > 0 { 1491 - t.ByEmail = make([]*GitRefUpdate_Meta_CommitCount_ByEmail_Elem, extra) 1492 } 1493 1494 for i := 0; i < int(extra); i++ { ··· 1510 if err := cr.UnreadByte(); err != nil { 1511 return err 1512 } 1513 - t.ByEmail[i] = new(GitRefUpdate_Meta_CommitCount_ByEmail_Elem) 1514 if err := t.ByEmail[i].UnmarshalCBOR(cr); err != nil { 1515 return xerrors.Errorf("unmarshaling t.ByEmail[i] pointer: %w", err) 1516 } ··· 1531 1532 return nil 1533 } 1534 - func (t *GitRefUpdate_Meta_CommitCount_ByEmail_Elem) MarshalCBOR(w io.Writer) error { 1535 if t == nil { 1536 _, err := w.Write(cbg.CborNull) 1537 return err ··· 1590 return nil 1591 } 1592 1593 - func (t *GitRefUpdate_Meta_CommitCount_ByEmail_Elem) UnmarshalCBOR(r io.Reader) (err error) { 1594 - *t = GitRefUpdate_Meta_CommitCount_ByEmail_Elem{} 1595 1596 cr := cbg.NewCborReader(r) 1597 ··· 1610 } 1611 1612 if extra > cbg.MaxLength { 1613 - return fmt.Errorf("GitRefUpdate_Meta_CommitCount_ByEmail_Elem: map struct too large (%d)", extra) 1614 } 1615 1616 n := extra ··· 1679 1680 return nil 1681 } 1682 - func (t *GitRefUpdate_Meta_LangBreakdown) MarshalCBOR(w io.Writer) error { 1683 if t == nil { 1684 _, err := w.Write(cbg.CborNull) 1685 return err ··· 1696 return err 1697 } 1698 1699 - // t.Inputs ([]*tangled.GitRefUpdate_Pair) (slice) 1700 if t.Inputs != nil { 1701 1702 if len("inputs") > 1000000 { ··· 1727 return nil 1728 } 1729 1730 - func (t *GitRefUpdate_Meta_LangBreakdown) UnmarshalCBOR(r io.Reader) (err error) { 1731 - *t = GitRefUpdate_Meta_LangBreakdown{} 1732 1733 cr := cbg.NewCborReader(r) 1734 ··· 1747 } 1748 1749 if extra > cbg.MaxLength { 1750 - return fmt.Errorf("GitRefUpdate_Meta_LangBreakdown: map struct too large (%d)", extra) 1751 } 1752 1753 n := extra ··· 1768 } 1769 1770 switch string(nameBuf[:nameLen]) { 1771 - // t.Inputs ([]*tangled.GitRefUpdate_Pair) (slice) 1772 case "inputs": 1773 1774 maj, extra, err = cr.ReadHeader() ··· 1785 } 1786 1787 if extra > 0 { 1788 - t.Inputs = make([]*GitRefUpdate_Pair, extra) 1789 } 1790 1791 for i := 0; i < int(extra); i++ { ··· 1807 if err := cr.UnreadByte(); err != nil { 1808 return err 1809 } 1810 - t.Inputs[i] = new(GitRefUpdate_Pair) 1811 if err := t.Inputs[i].UnmarshalCBOR(cr); err != nil { 1812 return xerrors.Errorf("unmarshaling t.Inputs[i] pointer: %w", err) 1813 } ··· 1828 1829 return nil 1830 } 1831 - func (t *GitRefUpdate_Pair) MarshalCBOR(w io.Writer) error { 1832 if t == nil { 1833 _, err := w.Write(cbg.CborNull) 1834 return err ··· 1888 return nil 1889 } 1890 1891 - func (t *GitRefUpdate_Pair) UnmarshalCBOR(r io.Reader) (err error) { 1892 - *t = GitRefUpdate_Pair{} 1893 1894 cr := cbg.NewCborReader(r) 1895 ··· 1908 } 1909 1910 if extra > cbg.MaxLength { 1911 - return fmt.Errorf("GitRefUpdate_Pair: map struct too large (%d)", extra) 1912 } 1913 1914 n := extra ··· 1965 } 1966 1967 t.Size = int64(extraI) 1968 } 1969 1970 default: ··· 5642 } 5643 5644 cw := cbg.NewCborWriter(w) 5645 - fieldCount := 7 5646 5647 if t.Body == nil { 5648 fieldCount-- ··· 5726 return err 5727 } 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 // t.Title (string) (string) 5753 if len("title") > 1000000 { 5754 return xerrors.Errorf("Value in field \"title\" was too long") ··· 5772 return err 5773 } 5774 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 // t.CreatedAt (string) (string) 5798 if len("createdAt") > 1000000 { 5799 return xerrors.Errorf("Value in field \"createdAt\" was too long") ··· 5903 5904 t.LexiconTypeID = string(sval) 5905 } 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 // t.Title (string) (string) 5918 case "title": 5919 ··· 5925 5926 t.Title = string(sval) 5927 } 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 // t.CreatedAt (string) (string) 5955 case "createdAt": 5956 ··· 5980 } 5981 5982 cw := cbg.NewCborWriter(w) 5983 - fieldCount := 7 5984 - 5985 - if t.CommentId == nil { 5986 - fieldCount-- 5987 - } 5988 5989 - if t.Owner == nil { 5990 - fieldCount-- 5991 - } 5992 - 5993 - if t.Repo == nil { 5994 fieldCount-- 5995 } 5996 ··· 6021 return err 6022 } 6023 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 // t.LexiconTypeID (string) (string) 6057 if len("$type") > 1000000 { 6058 return xerrors.Errorf("Value in field \"$type\" was too long") ··· 6095 return err 6096 } 6097 6098 - // t.Owner (string) (string) 6099 - if t.Owner != nil { 6100 6101 - if len("owner") > 1000000 { 6102 - return xerrors.Errorf("Value in field \"owner\" was too long") 6103 } 6104 6105 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("owner"))); err != nil { 6106 return err 6107 } 6108 - if _, err := cw.WriteString(string("owner")); err != nil { 6109 return err 6110 } 6111 6112 - if t.Owner == nil { 6113 if _, err := cw.Write(cbg.CborNull); err != nil { 6114 return err 6115 } 6116 } else { 6117 - if len(*t.Owner) > 1000000 { 6118 - return xerrors.Errorf("Value in field t.Owner was too long") 6119 } 6120 6121 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Owner))); err != nil { 6122 return err 6123 } 6124 - if _, err := cw.WriteString(string(*t.Owner)); err != nil { 6125 return err 6126 } 6127 } 6128 } 6129 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 // t.CreatedAt (string) (string) 6163 if len("createdAt") > 1000000 { 6164 return xerrors.Errorf("Value in field \"createdAt\" was too long") ··· 6236 6237 t.Body = string(sval) 6238 } 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 // t.LexiconTypeID (string) (string) 6261 case "$type": 6262 ··· 6279 6280 t.Issue = string(sval) 6281 } 6282 - // t.Owner (string) (string) 6283 - case "owner": 6284 6285 { 6286 b, err := cr.ReadByte() ··· 6297 return err 6298 } 6299 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) 6337 } 6338 } 6339 // t.CreatedAt (string) (string) ··· 6529 } 6530 6531 cw := cbg.NewCborWriter(w) 6532 - fieldCount := 9 6533 6534 if t.Body == nil { 6535 fieldCount-- ··· 6640 return err 6641 } 6642 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 // t.Source (tangled.RepoPull_Source) (struct) 6666 if t.Source != nil { 6667 ··· 6681 } 6682 } 6683 6684 - // t.CreatedAt (string) (string) 6685 - if len("createdAt") > 1000000 { 6686 - return xerrors.Errorf("Value in field \"createdAt\" was too long") 6687 } 6688 6689 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 6690 return err 6691 } 6692 - if _, err := cw.WriteString(string("createdAt")); err != nil { 6693 return err 6694 } 6695 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 { 6704 return err 6705 } 6706 6707 - // t.TargetRepo (string) (string) 6708 - if len("targetRepo") > 1000000 { 6709 - return xerrors.Errorf("Value in field \"targetRepo\" was too long") 6710 } 6711 6712 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("targetRepo"))); err != nil { 6713 return err 6714 } 6715 - if _, err := cw.WriteString(string("targetRepo")); err != nil { 6716 return err 6717 } 6718 6719 - if len(t.TargetRepo) > 1000000 { 6720 - return xerrors.Errorf("Value in field t.TargetRepo was too long") 6721 } 6722 6723 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.TargetRepo))); err != nil { 6724 return err 6725 } 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 { 6750 return err 6751 } 6752 return nil ··· 6777 6778 n := extra 6779 6780 - nameBuf := make([]byte, 12) 6781 for i := uint64(0); i < n; i++ { 6782 nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 6783 if err != nil { ··· 6847 6848 t.Title = string(sval) 6849 } 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 // t.Source (tangled.RepoPull_Source) (struct) 6877 case "source": 6878 ··· 6893 } 6894 6895 } 6896 - // t.CreatedAt (string) (string) 6897 - case "createdAt": 6898 6899 { 6900 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 6901 if err != nil { 6902 return err 6903 } 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 6914 } 6915 6916 - t.TargetRepo = string(sval) 6917 } 6918 - // t.TargetBranch (string) (string) 6919 - case "targetBranch": 6920 6921 { 6922 sval, err := cbg.ReadStringWithMax(cr, 1000000) ··· 6924 return err 6925 } 6926 6927 - t.TargetBranch = string(sval) 6928 } 6929 6930 default: ··· 6944 } 6945 6946 cw := cbg.NewCborWriter(w) 6947 - fieldCount := 7 6948 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 { 6962 return err 6963 } 6964 ··· 7008 return err 7009 } 7010 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 // t.LexiconTypeID (string) (string) 7044 if len("$type") > 1000000 { 7045 return xerrors.Errorf("Value in field \"$type\" was too long") ··· 7059 return err 7060 } 7061 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 // t.CreatedAt (string) (string) 7127 if len("createdAt") > 1000000 { 7128 return xerrors.Errorf("Value in field \"createdAt\" was too long") ··· 7211 7212 t.Pull = string(sval) 7213 } 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 // t.LexiconTypeID (string) (string) 7236 case "$type": 7237 ··· 7243 7244 t.LexiconTypeID = string(sval) 7245 } 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 // t.CreatedAt (string) (string) 7304 case "createdAt": 7305 ··· 7666 } 7667 7668 t.Status = string(sval) 7669 } 7670 7671 default:
··· 1202 1203 return nil 1204 } 1205 + func (t *GitRefUpdate_CommitCountBreakdown) MarshalCBOR(w io.Writer) error { 1206 if t == nil { 1207 _, err := w.Write(cbg.CborNull) 1208 return err ··· 1219 return err 1220 } 1221 1222 + // t.ByEmail ([]*tangled.GitRefUpdate_IndividualEmailCommitCount) (slice) 1223 if t.ByEmail != nil { 1224 1225 if len("byEmail") > 1000000 { ··· 1250 return nil 1251 } 1252 1253 + func (t *GitRefUpdate_CommitCountBreakdown) UnmarshalCBOR(r io.Reader) (err error) { 1254 + *t = GitRefUpdate_CommitCountBreakdown{} 1255 1256 cr := cbg.NewCborReader(r) 1257 ··· 1270 } 1271 1272 if extra > cbg.MaxLength { 1273 + return fmt.Errorf("GitRefUpdate_CommitCountBreakdown: map struct too large (%d)", extra) 1274 } 1275 1276 n := extra ··· 1291 } 1292 1293 switch string(nameBuf[:nameLen]) { 1294 + // t.ByEmail ([]*tangled.GitRefUpdate_IndividualEmailCommitCount) (slice) 1295 case "byEmail": 1296 1297 maj, extra, err = cr.ReadHeader() ··· 1308 } 1309 1310 if extra > 0 { 1311 + t.ByEmail = make([]*GitRefUpdate_IndividualEmailCommitCount, extra) 1312 } 1313 1314 for i := 0; i < int(extra); i++ { ··· 1330 if err := cr.UnreadByte(); err != nil { 1331 return err 1332 } 1333 + t.ByEmail[i] = new(GitRefUpdate_IndividualEmailCommitCount) 1334 if err := t.ByEmail[i].UnmarshalCBOR(cr); err != nil { 1335 return xerrors.Errorf("unmarshaling t.ByEmail[i] pointer: %w", err) 1336 } ··· 1351 1352 return nil 1353 } 1354 + func (t *GitRefUpdate_IndividualEmailCommitCount) MarshalCBOR(w io.Writer) error { 1355 if t == nil { 1356 _, err := w.Write(cbg.CborNull) 1357 return err ··· 1410 return nil 1411 } 1412 1413 + func (t *GitRefUpdate_IndividualEmailCommitCount) UnmarshalCBOR(r io.Reader) (err error) { 1414 + *t = GitRefUpdate_IndividualEmailCommitCount{} 1415 1416 cr := cbg.NewCborReader(r) 1417 ··· 1430 } 1431 1432 if extra > cbg.MaxLength { 1433 + return fmt.Errorf("GitRefUpdate_IndividualEmailCommitCount: map struct too large (%d)", extra) 1434 } 1435 1436 n := extra ··· 1499 1500 return nil 1501 } 1502 + func (t *GitRefUpdate_LangBreakdown) MarshalCBOR(w io.Writer) error { 1503 if t == nil { 1504 _, err := w.Write(cbg.CborNull) 1505 return err ··· 1516 return err 1517 } 1518 1519 + // t.Inputs ([]*tangled.GitRefUpdate_IndividualLanguageSize) (slice) 1520 if t.Inputs != nil { 1521 1522 if len("inputs") > 1000000 { ··· 1547 return nil 1548 } 1549 1550 + func (t *GitRefUpdate_LangBreakdown) UnmarshalCBOR(r io.Reader) (err error) { 1551 + *t = GitRefUpdate_LangBreakdown{} 1552 1553 cr := cbg.NewCborReader(r) 1554 ··· 1567 } 1568 1569 if extra > cbg.MaxLength { 1570 + return fmt.Errorf("GitRefUpdate_LangBreakdown: map struct too large (%d)", extra) 1571 } 1572 1573 n := extra ··· 1588 } 1589 1590 switch string(nameBuf[:nameLen]) { 1591 + // t.Inputs ([]*tangled.GitRefUpdate_IndividualLanguageSize) (slice) 1592 case "inputs": 1593 1594 maj, extra, err = cr.ReadHeader() ··· 1605 } 1606 1607 if extra > 0 { 1608 + t.Inputs = make([]*GitRefUpdate_IndividualLanguageSize, extra) 1609 } 1610 1611 for i := 0; i < int(extra); i++ { ··· 1627 if err := cr.UnreadByte(); err != nil { 1628 return err 1629 } 1630 + t.Inputs[i] = new(GitRefUpdate_IndividualLanguageSize) 1631 if err := t.Inputs[i].UnmarshalCBOR(cr); err != nil { 1632 return xerrors.Errorf("unmarshaling t.Inputs[i] pointer: %w", err) 1633 } ··· 1648 1649 return nil 1650 } 1651 + func (t *GitRefUpdate_IndividualLanguageSize) MarshalCBOR(w io.Writer) error { 1652 if t == nil { 1653 _, err := w.Write(cbg.CborNull) 1654 return err ··· 1708 return nil 1709 } 1710 1711 + func (t *GitRefUpdate_IndividualLanguageSize) UnmarshalCBOR(r io.Reader) (err error) { 1712 + *t = GitRefUpdate_IndividualLanguageSize{} 1713 1714 cr := cbg.NewCborReader(r) 1715 ··· 1728 } 1729 1730 if extra > cbg.MaxLength { 1731 + return fmt.Errorf("GitRefUpdate_IndividualLanguageSize: map struct too large (%d)", extra) 1732 } 1733 1734 n := extra ··· 1785 } 1786 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 } 1969 1970 default: ··· 5642 } 5643 5644 cw := cbg.NewCborWriter(w) 5645 + fieldCount := 5 5646 5647 if t.Body == nil { 5648 fieldCount-- ··· 5726 return err 5727 } 5728 5729 // t.Title (string) (string) 5730 if len("title") > 1000000 { 5731 return xerrors.Errorf("Value in field \"title\" was too long") ··· 5749 return err 5750 } 5751 5752 // t.CreatedAt (string) (string) 5753 if len("createdAt") > 1000000 { 5754 return xerrors.Errorf("Value in field \"createdAt\" was too long") ··· 5858 5859 t.LexiconTypeID = string(sval) 5860 } 5861 // t.Title (string) (string) 5862 case "title": 5863 ··· 5869 5870 t.Title = string(sval) 5871 } 5872 // t.CreatedAt (string) (string) 5873 case "createdAt": 5874 ··· 5898 } 5899 5900 cw := cbg.NewCborWriter(w) 5901 + fieldCount := 5 5902 5903 + if t.ReplyTo == nil { 5904 fieldCount-- 5905 } 5906 ··· 5931 return err 5932 } 5933 5934 // t.LexiconTypeID (string) (string) 5935 if len("$type") > 1000000 { 5936 return xerrors.Errorf("Value in field \"$type\" was too long") ··· 5973 return err 5974 } 5975 5976 + // t.ReplyTo (string) (string) 5977 + if t.ReplyTo != nil { 5978 5979 + if len("replyTo") > 1000000 { 5980 + return xerrors.Errorf("Value in field \"replyTo\" was too long") 5981 } 5982 5983 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("replyTo"))); err != nil { 5984 return err 5985 } 5986 + if _, err := cw.WriteString(string("replyTo")); err != nil { 5987 return err 5988 } 5989 5990 + if t.ReplyTo == nil { 5991 if _, err := cw.Write(cbg.CborNull); err != nil { 5992 return err 5993 } 5994 } else { 5995 + if len(*t.ReplyTo) > 1000000 { 5996 + return xerrors.Errorf("Value in field t.ReplyTo was too long") 5997 } 5998 5999 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.ReplyTo))); err != nil { 6000 return err 6001 } 6002 + if _, err := cw.WriteString(string(*t.ReplyTo)); err != nil { 6003 return err 6004 } 6005 } 6006 } 6007 6008 // t.CreatedAt (string) (string) 6009 if len("createdAt") > 1000000 { 6010 return xerrors.Errorf("Value in field \"createdAt\" was too long") ··· 6082 6083 t.Body = string(sval) 6084 } 6085 // t.LexiconTypeID (string) (string) 6086 case "$type": 6087 ··· 6104 6105 t.Issue = string(sval) 6106 } 6107 + // t.ReplyTo (string) (string) 6108 + case "replyTo": 6109 6110 { 6111 b, err := cr.ReadByte() ··· 6122 return err 6123 } 6124 6125 + t.ReplyTo = (*string)(&sval) 6126 } 6127 } 6128 // t.CreatedAt (string) (string) ··· 6318 } 6319 6320 cw := cbg.NewCborWriter(w) 6321 + fieldCount := 7 6322 6323 if t.Body == nil { 6324 fieldCount-- ··· 6429 return err 6430 } 6431 6432 // t.Source (tangled.RepoPull_Source) (struct) 6433 if t.Source != nil { 6434 ··· 6448 } 6449 } 6450 6451 + // t.Target (tangled.RepoPull_Target) (struct) 6452 + if len("target") > 1000000 { 6453 + return xerrors.Errorf("Value in field \"target\" was too long") 6454 } 6455 6456 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("target"))); err != nil { 6457 return err 6458 } 6459 + if _, err := cw.WriteString(string("target")); err != nil { 6460 return err 6461 } 6462 6463 + if err := t.Target.MarshalCBOR(cw); err != nil { 6464 return err 6465 } 6466 6467 + // t.CreatedAt (string) (string) 6468 + if len("createdAt") > 1000000 { 6469 + return xerrors.Errorf("Value in field \"createdAt\" was too long") 6470 } 6471 6472 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 6473 return err 6474 } 6475 + if _, err := cw.WriteString(string("createdAt")); err != nil { 6476 return err 6477 } 6478 6479 + if len(t.CreatedAt) > 1000000 { 6480 + return xerrors.Errorf("Value in field t.CreatedAt was too long") 6481 } 6482 6483 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { 6484 return err 6485 } 6486 + if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 6487 return err 6488 } 6489 return nil ··· 6514 6515 n := extra 6516 6517 + nameBuf := make([]byte, 9) 6518 for i := uint64(0); i < n; i++ { 6519 nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 6520 if err != nil { ··· 6584 6585 t.Title = string(sval) 6586 } 6587 // t.Source (tangled.RepoPull_Source) (struct) 6588 case "source": 6589 ··· 6604 } 6605 6606 } 6607 + // t.Target (tangled.RepoPull_Target) (struct) 6608 + case "target": 6609 6610 { 6611 + 6612 + b, err := cr.ReadByte() 6613 if err != nil { 6614 return err 6615 } 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 + } 6624 } 6625 6626 } 6627 + // t.CreatedAt (string) (string) 6628 + case "createdAt": 6629 6630 { 6631 sval, err := cbg.ReadStringWithMax(cr, 1000000) ··· 6633 return err 6634 } 6635 6636 + t.CreatedAt = string(sval) 6637 } 6638 6639 default: ··· 6653 } 6654 6655 cw := cbg.NewCborWriter(w) 6656 6657 + if _, err := cw.Write([]byte{164}); err != nil { 6658 return err 6659 } 6660 ··· 6704 return err 6705 } 6706 6707 // t.LexiconTypeID (string) (string) 6708 if len("$type") > 1000000 { 6709 return xerrors.Errorf("Value in field \"$type\" was too long") ··· 6723 return err 6724 } 6725 6726 // t.CreatedAt (string) (string) 6727 if len("createdAt") > 1000000 { 6728 return xerrors.Errorf("Value in field \"createdAt\" was too long") ··· 6811 6812 t.Pull = string(sval) 6813 } 6814 // t.LexiconTypeID (string) (string) 6815 case "$type": 6816 ··· 6822 6823 t.LexiconTypeID = string(sval) 6824 } 6825 // t.CreatedAt (string) (string) 6826 case "createdAt": 6827 ··· 7188 } 7189 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) 7325 } 7326 7327 default:
+19 -15
api/tangled/gitrefUpdate.go
··· 33 RepoName string `json:"repoName" cborgen:"repoName"` 34 } 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"` 40 } 41 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 { 47 Count int64 `json:"count" cborgen:"count"` 48 Email string `json:"email" cborgen:"email"` 49 } 50 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 { 57 Lang string `json:"lang" cborgen:"lang"` 58 Size int64 `json:"size" cborgen:"size"` 59 }
··· 33 RepoName string `json:"repoName" cborgen:"repoName"` 34 } 35 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"` 39 } 40 41 + // GitRefUpdate_IndividualEmailCommitCount is a "individualEmailCommitCount" in the sh.tangled.git.refUpdate schema. 42 + type GitRefUpdate_IndividualEmailCommitCount struct { 43 Count int64 `json:"count" cborgen:"count"` 44 Email string `json:"email" cborgen:"email"` 45 } 46 47 + // GitRefUpdate_IndividualLanguageSize is a "individualLanguageSize" in the sh.tangled.git.refUpdate schema. 48 + type GitRefUpdate_IndividualLanguageSize struct { 49 Lang string `json:"lang" cborgen:"lang"` 50 Size int64 `json:"size" cborgen:"size"` 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
··· 19 type RepoIssueComment struct { 20 LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue.comment" cborgen:"$type,const=sh.tangled.repo.issue.comment"` 21 Body string `json:"body" cborgen:"body"` 22 - CommentId *int64 `json:"commentId,omitempty" cborgen:"commentId,omitempty"` 23 CreatedAt string `json:"createdAt" cborgen:"createdAt"` 24 Issue string `json:"issue" cborgen:"issue"` 25 - Owner *string `json:"owner,omitempty" cborgen:"owner,omitempty"` 26 - Repo *string `json:"repo,omitempty" cborgen:"repo,omitempty"` 27 }
··· 19 type RepoIssueComment struct { 20 LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue.comment" cborgen:"$type,const=sh.tangled.repo.issue.comment"` 21 Body string `json:"body" cborgen:"body"` 22 CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 Issue string `json:"issue" cborgen:"issue"` 24 + ReplyTo *string `json:"replyTo,omitempty" cborgen:"replyTo,omitempty"` 25 }
+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
···
··· 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
··· 17 } // 18 // RECORDTYPE: RepoPullComment 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"` 27 }
··· 17 } // 18 // RECORDTYPE: RepoPullComment 19 type RepoPullComment struct { 20 + LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull.comment" cborgen:"$type,const=sh.tangled.repo.pull.comment"` 21 + Body string `json:"body" cborgen:"body"` 22 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 + Pull string `json:"pull" cborgen:"pull"` 24 }
+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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
··· 20 LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue" cborgen:"$type,const=sh.tangled.repo.issue"` 21 Body *string `json:"body,omitempty" cborgen:"body,omitempty"` 22 CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 - IssueId int64 `json:"issueId" cborgen:"issueId"` 24 - Owner string `json:"owner" cborgen:"owner"` 25 Repo string `json:"repo" cborgen:"repo"` 26 Title string `json:"title" cborgen:"title"` 27 }
··· 20 LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue" cborgen:"$type,const=sh.tangled.repo.issue"` 21 Body *string `json:"body,omitempty" cborgen:"body,omitempty"` 22 CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 Repo string `json:"repo" cborgen:"repo"` 24 Title string `json:"title" cborgen:"title"` 25 }
+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
···
··· 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
··· 21 Body *string `json:"body,omitempty" cborgen:"body,omitempty"` 22 CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 Patch string `json:"patch" cborgen:"patch"` 24 - PullId int64 `json:"pullId" cborgen:"pullId"` 25 Source *RepoPull_Source `json:"source,omitempty" cborgen:"source,omitempty"` 26 - TargetBranch string `json:"targetBranch" cborgen:"targetBranch"` 27 - TargetRepo string `json:"targetRepo" cborgen:"targetRepo"` 28 Title string `json:"title" cborgen:"title"` 29 } 30 ··· 34 Repo *string `json:"repo,omitempty" cborgen:"repo,omitempty"` 35 Sha string `json:"sha" cborgen:"sha"` 36 }
··· 21 Body *string `json:"body,omitempty" cborgen:"body,omitempty"` 22 CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 Patch string `json:"patch" cborgen:"patch"` 24 Source *RepoPull_Source `json:"source,omitempty" cborgen:"source,omitempty"` 25 + Target *RepoPull_Target `json:"target" cborgen:"target"` 26 Title string `json:"title" cborgen:"title"` 27 } 28 ··· 32 Repo *string `json:"repo,omitempty" cborgen:"repo,omitempty"` 33 Sha string `json:"sha" cborgen:"sha"` 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 + }
+39
api/tangled/repotags.go
···
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.tags 6 + 7 + import ( 8 + "bytes" 9 + "context" 10 + 11 + "github.com/bluesky-social/indigo/lex/util" 12 + ) 13 + 14 + const ( 15 + RepoTagsNSID = "sh.tangled.repo.tags" 16 + ) 17 + 18 + // RepoTags calls the XRPC method "sh.tangled.repo.tags". 19 + // 20 + // cursor: Pagination cursor 21 + // limit: Maximum number of tags to return 22 + // repo: Repository identifier in format 'did:plc:.../repoName' 23 + func RepoTags(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.tags", params, nil, buf); err != nil { 35 + return nil, err 36 + } 37 + 38 + return buf.Bytes(), nil 39 + }
+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
···
··· 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
··· 21 AppPassword string `env:"APP_PASSWORD"` 22 23 // uhhhh this is because knot1 is under icy's did 24 - TmpAltAppPassword string `env:"ALT_APP_PASSWORD, required"` 25 } 26 27 type OAuthConfig struct {
··· 21 AppPassword string `env:"APP_PASSWORD"` 22 23 // uhhhh this is because knot1 is under icy's did 24 + TmpAltAppPassword string `env:"ALT_APP_PASSWORD"` 25 } 26 27 type OAuthConfig struct {
+169
appview/db/db.go
··· 703 return err 704 }) 705 706 return &DB{db}, nil 707 } 708 ··· 747 } 748 749 return nil 750 } 751 752 type filter struct {
··· 703 return err 704 }) 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 + 871 return &DB{db}, nil 872 } 873 ··· 912 } 913 914 return nil 915 + } 916 + 917 + func (d *DB) Close() error { 918 + return d.DB.Close() 919 } 920 921 type filter struct {
+4 -4
appview/db/follow.go
··· 56 } 57 58 type FollowStats struct { 59 - Followers int 60 - Following int 61 } 62 63 func GetFollowerFollowingCount(e Execer, did string) (FollowStats, error) { 64 - followers, following := 0, 0 65 err := e.QueryRow( 66 `SELECT 67 COUNT(CASE WHEN subject_did = ? THEN 1 END) AS followers, ··· 122 123 for rows.Next() { 124 var did string 125 - var followers, following int 126 if err := rows.Scan(&did, &followers, &following); err != nil { 127 return nil, err 128 }
··· 56 } 57 58 type FollowStats struct { 59 + Followers int64 60 + Following int64 61 } 62 63 func GetFollowerFollowingCount(e Execer, did string) (FollowStats, error) { 64 + var followers, following int64 65 err := e.QueryRow( 66 `SELECT 67 COUNT(CASE WHEN subject_did = ? THEN 1 END) AS followers, ··· 122 123 for rows.Next() { 124 var did string 125 + var followers, following int64 126 if err := rows.Scan(&did, &followers, &following); err != nil { 127 return nil, err 128 }
+430 -368
appview/db/issues.go
··· 3 import ( 4 "database/sql" 5 "fmt" 6 "strings" 7 "time" 8 ··· 12 ) 13 14 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 24 25 // optionally, populate this when querying for reverse mappings 26 // like comment counts, parent repo etc. 27 - Metadata *IssueMetadata 28 } 29 30 - type IssueMetadata struct { 31 - CommentCount int 32 - Repo *Repo 33 - // labels, assignee etc. 34 } 35 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 46 } 47 48 - func (i *Issue) AtUri() syntax.ATURI { 49 - return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.OwnerDid, tangled.RepoIssueNSID, i.Rkey)) 50 } 51 52 - func NewIssue(tx *sql.Tx, issue *Issue) error { 53 - defer tx.Rollback() 54 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 61 } 62 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 72 } 73 74 - issue.IssueId = nextId 75 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 82 } 83 84 - lastID, err := res.LastInsertId() 85 if err != nil { 86 - return err 87 } 88 - issue.ID = lastID 89 90 - if err := tx.Commit(); err != nil { 91 - return err 92 } 93 94 - return nil 95 } 96 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 101 } 102 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 107 } 108 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 114 } 115 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) 154 if err != nil { 155 return nil, err 156 } 157 - defer rows.Close() 158 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 - } 167 168 - createdTime, err := time.Parse(time.RFC3339, createdAt) 169 - if err != nil { 170 - return nil, err 171 } 172 - issue.Created = createdTime 173 - issue.Metadata = &metadata 174 175 - issues = append(issues, issue) 176 } 177 178 - if err := rows.Err(); err != nil { 179 - return nil, err 180 } 181 182 - return issues, nil 183 } 184 185 - func GetIssuesWithLimit(e Execer, limit int, filters ...filter) ([]Issue, error) { 186 - issues := make([]Issue, 0, limit) 187 188 var conditions []string 189 var args []any 190 for _, filter := range filters { 191 conditions = append(conditions, filter.Condition()) 192 args = append(args, filter.Arg()...) ··· 196 if conditions != nil { 197 whereClause = " where " + strings.Join(conditions, " and ") 198 } 199 - limitClause := "" 200 - if limit != 0 { 201 - limitClause = fmt.Sprintf(" limit %d ", limit) 202 - } 203 204 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 216 %s 217 - order by 218 - i.created desc 219 - %s`, 220 - whereClause, limitClause) 221 222 rows, err := e.Query(query, args...) 223 if err != nil { 224 - return nil, err 225 } 226 defer rows.Close() 227 228 for rows.Next() { 229 var issue Issue 230 - var issueCreatedAt string 231 err := rows.Scan( 232 - &issue.ID, 233 - &issue.OwnerDid, 234 &issue.RepoAt, 235 &issue.IssueId, 236 - &issueCreatedAt, 237 &issue.Title, 238 &issue.Body, 239 &issue.Open, 240 ) 241 if err != nil { 242 - return nil, err 243 } 244 245 - issueCreatedTime, err := time.Parse(time.RFC3339, issueCreatedAt) 246 - if err != nil { 247 - return nil, err 248 } 249 - issue.Created = issueCreatedTime 250 251 - issues = append(issues, issue) 252 - } 253 - 254 - if err := rows.Err(); err != nil { 255 - return nil, err 256 - } 257 258 - return issues, nil 259 - } 260 261 - func GetIssues(e Execer, filters ...filter) ([]Issue, error) { 262 - return GetIssuesWithLimit(e, 0, filters...) 263 - } 264 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 269 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) 295 if err != nil { 296 - return nil, err 297 } 298 - defer rows.Close() 299 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 - } 323 324 - issueCreatedTime, err := time.Parse(time.RFC3339, issueCreatedAt) 325 - if err != nil { 326 - return nil, err 327 } 328 - issue.Created = issueCreatedTime 329 330 - repoCreatedTime, err := time.Parse(time.RFC3339, repoCreatedAt) 331 - if err != nil { 332 - return nil, err 333 - } 334 - repo.Created = repoCreatedTime 335 336 - issue.Metadata = &IssueMetadata{ 337 - Repo: &repo, 338 } 339 340 - issues = append(issues, issue) 341 } 342 343 - if err := rows.Err(); err != nil { 344 - return nil, err 345 - } 346 347 return issues, nil 348 } 349 350 func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, error) { ··· 353 354 var issue Issue 355 var createdAt string 356 - err := row.Scan(&issue.ID, &issue.OwnerDid, &issue.Rkey, &createdAt, &issue.Title, &issue.Body, &issue.Open) 357 if err != nil { 358 return nil, err 359 } ··· 367 return &issue, nil 368 } 369 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) 377 if err != nil { 378 - return nil, nil, err 379 } 380 381 - createdTime, err := time.Parse(time.RFC3339, createdAt) 382 if err != nil { 383 - return nil, nil, err 384 } 385 - issue.Created = createdTime 386 387 - comments, err := GetComments(e, repoAt, issueId) 388 - if err != nil { 389 - return nil, nil, err 390 } 391 392 - return &issue, comments, nil 393 - } 394 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 - ) 406 return err 407 } 408 409 - func GetComments(e Execer, repoAt syntax.ATURI, issueId int) ([]Comment, error) { 410 - var comments []Comment 411 412 - rows, err := e.Query(` 413 select 414 - owner_did, 415 - issue_id, 416 - comment_id, 417 rkey, 418 body, 419 created, 420 edited, 421 deleted 422 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 - } 434 if err != nil { 435 return nil, err 436 } 437 - defer rows.Close() 438 439 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) 444 if err != nil { 445 return nil, err 446 } 447 448 - createdAtTime, err := time.Parse(time.RFC3339, createdAt) 449 - if err != nil { 450 - return nil, err 451 } 452 - comment.Created = &createdAtTime 453 454 - if deletedAt.Valid { 455 - deletedTime, err := time.Parse(time.RFC3339, deletedAt.String) 456 - if err != nil { 457 - return nil, err 458 } 459 - comment.Deleted = &deletedTime 460 } 461 462 - if editedAt.Valid { 463 - editedTime, err := time.Parse(time.RFC3339, editedAt.String) 464 - if err != nil { 465 - return nil, err 466 } 467 - comment.Edited = &editedTime 468 } 469 470 - if rkey.Valid { 471 - comment.Rkey = rkey.String 472 } 473 474 comments = append(comments, comment) 475 } 476 477 - if err := rows.Err(); err != nil { 478 return nil, err 479 } 480 481 return comments, nil 482 } 483 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 499 } 500 501 - createdTime, err := time.Parse(time.RFC3339, createdAt) 502 - if err != nil { 503 - return nil, err 504 } 505 - comment.Created = &createdTime 506 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 - } 514 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 521 } 522 523 - if rkey.Valid { 524 - comment.Rkey = rkey.String 525 } 526 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) 542 return err 543 } 544 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 - } 555 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 - } 560 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) 563 return err 564 } 565
··· 3 import ( 4 "database/sql" 5 "fmt" 6 + "maps" 7 + "slices" 8 + "sort" 9 "strings" 10 "time" 11 ··· 15 ) 16 17 type Issue struct { 18 + Id int64 19 + Did string 20 + Rkey string 21 + RepoAt syntax.ATURI 22 + IssueId int 23 + Created time.Time 24 + Edited *time.Time 25 + Deleted *time.Time 26 + Title string 27 + Body string 28 + Open bool 29 30 // optionally, populate this when querying for reverse mappings 31 // like comment counts, parent repo etc. 32 + Comments []IssueComment 33 + Repo *Repo 34 } 35 36 + func (i *Issue) AtUri() syntax.ATURI { 37 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueNSID, i.Rkey)) 38 } 39 40 + func (i *Issue) AsRecord() tangled.RepoIssue { 41 + return tangled.RepoIssue{ 42 + Repo: i.RepoAt.String(), 43 + Title: i.Title, 44 + Body: &i.Body, 45 + CreatedAt: i.Created.Format(time.RFC3339), 46 + } 47 } 48 49 + func (i *Issue) State() string { 50 + if i.Open { 51 + return "open" 52 + } 53 + return "closed" 54 + } 55 + 56 + type CommentListItem struct { 57 + Self *IssueComment 58 + Replies []*IssueComment 59 } 60 61 + func (i *Issue) CommentList() []CommentListItem { 62 + // Create a map to quickly find comments by their aturi 63 + toplevel := make(map[string]*CommentListItem) 64 + var replies []*IssueComment 65 66 + // collect top level comments into the map 67 + for _, comment := range i.Comments { 68 + if comment.IsTopLevel() { 69 + toplevel[comment.AtUri().String()] = &CommentListItem{ 70 + Self: &comment, 71 + } 72 + } else { 73 + replies = append(replies, &comment) 74 + } 75 } 76 77 + for _, r := range replies { 78 + parentAt := *r.ReplyTo 79 + if parent, exists := toplevel[parentAt]; exists { 80 + parent.Replies = append(parent.Replies, r) 81 + } 82 } 83 84 + var listing []CommentListItem 85 + for _, v := range toplevel { 86 + listing = append(listing, *v) 87 + } 88 89 + // sort everything 90 + sortFunc := func(a, b *IssueComment) bool { 91 + return a.Created.Before(b.Created) 92 + } 93 + sort.Slice(listing, func(i, j int) bool { 94 + return sortFunc(listing[i].Self, listing[j].Self) 95 + }) 96 + for _, r := range listing { 97 + sort.Slice(r.Replies, func(i, j int) bool { 98 + return sortFunc(r.Replies[i], r.Replies[j]) 99 + }) 100 } 101 102 + return listing 103 + } 104 + 105 + func IssueFromRecord(did, rkey string, record tangled.RepoIssue) Issue { 106 + created, err := time.Parse(time.RFC3339, record.CreatedAt) 107 if err != nil { 108 + created = time.Now() 109 } 110 111 + body := "" 112 + if record.Body != nil { 113 + body = *record.Body 114 } 115 116 + return Issue{ 117 + RepoAt: syntax.ATURI(record.Repo), 118 + Did: did, 119 + Rkey: rkey, 120 + Created: created, 121 + Title: record.Title, 122 + Body: body, 123 + Open: true, // new issues are open by default 124 + } 125 } 126 127 + type IssueComment struct { 128 + Id int64 129 + Did string 130 + Rkey string 131 + IssueAt string 132 + ReplyTo *string 133 + Body string 134 + Created time.Time 135 + Edited *time.Time 136 + Deleted *time.Time 137 } 138 139 + func (i *IssueComment) AtUri() syntax.ATURI { 140 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueCommentNSID, i.Rkey)) 141 } 142 143 + func (i *IssueComment) AsRecord() tangled.RepoIssueComment { 144 + return tangled.RepoIssueComment{ 145 + Body: i.Body, 146 + Issue: i.IssueAt, 147 + CreatedAt: i.Created.Format(time.RFC3339), 148 + ReplyTo: i.ReplyTo, 149 } 150 + } 151 152 + func (i *IssueComment) IsTopLevel() bool { 153 + return i.ReplyTo == nil 154 + } 155 + 156 + func IssueCommentFromRecord(e Execer, did, rkey string, record tangled.RepoIssueComment) (*IssueComment, error) { 157 + created, err := time.Parse(time.RFC3339, record.CreatedAt) 158 if err != nil { 159 + created = time.Now() 160 + } 161 + 162 + ownerDid := did 163 + 164 + if _, err = syntax.ParseATURI(record.Issue); err != nil { 165 return nil, err 166 } 167 168 + comment := IssueComment{ 169 + Did: ownerDid, 170 + Rkey: rkey, 171 + Body: record.Body, 172 + IssueAt: record.Issue, 173 + ReplyTo: record.ReplyTo, 174 + Created: created, 175 + } 176 + 177 + return &comment, nil 178 + } 179 180 + func PutIssue(tx *sql.Tx, issue *Issue) error { 181 + // ensure sequence exists 182 + _, err := tx.Exec(` 183 + insert or ignore into repo_issue_seqs (repo_at, next_issue_id) 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 207 } 208 209 + issue.Id = existingIssue.Id 210 + issue.IssueId = existingIssue.IssueId 211 + return updateIssue(tx, issue) 212 } 213 + } 214 215 + func createNewIssue(tx *sql.Tx, issue *Issue) error { 216 + // get next issue_id 217 + var newIssueId int 218 + err := tx.QueryRow(` 219 + update repo_issue_seqs 220 + set next_issue_id = next_issue_id + 1 221 + where repo_at = ? 222 + returning next_issue_id - 1 223 + `, issue.RepoAt).Scan(&newIssueId) 224 + if err != nil { 225 + return err 226 } 227 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 246 } 247 248 + func GetIssuesPaginated(e Execer, page pagination.Page, filters ...filter) ([]Issue, error) { 249 + issueMap := make(map[string]*Issue) // at-uri -> issue 250 251 var conditions []string 252 var args []any 253 + 254 for _, filter := range filters { 255 conditions = append(conditions, filter.Condition()) 256 args = append(args, filter.Arg()...) ··· 260 if conditions != nil { 261 whereClause = " where " + strings.Join(conditions, " and ") 262 } 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() 270 271 query := fmt.Sprintf( 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 291 %s 292 + `, 293 + whereClause, 294 + pagination, 295 + ) 296 297 rows, err := e.Query(query, args...) 298 if err != nil { 299 + return nil, fmt.Errorf("failed to query issues table: %w", err) 300 } 301 defer rows.Close() 302 303 for rows.Next() { 304 var issue Issue 305 + var createdAt string 306 + var editedAt, deletedAt sql.Null[string] 307 + var rowNum int64 308 err := rows.Scan( 309 + &issue.Id, 310 + &issue.Did, 311 + &issue.Rkey, 312 &issue.RepoAt, 313 &issue.IssueId, 314 &issue.Title, 315 &issue.Body, 316 &issue.Open, 317 + &createdAt, 318 + &editedAt, 319 + &deletedAt, 320 + &rowNum, 321 ) 322 if err != nil { 323 + return nil, fmt.Errorf("failed to scan issue: %w", err) 324 } 325 326 + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 327 + issue.Created = t 328 } 329 330 + if editedAt.Valid { 331 + if t, err := time.Parse(time.RFC3339, editedAt.V); err == nil { 332 + issue.Edited = &t 333 + } 334 + } 335 336 + if deletedAt.Valid { 337 + if t, err := time.Parse(time.RFC3339, deletedAt.V); err == nil { 338 + issue.Deleted = &t 339 + } 340 + } 341 342 + atUri := issue.AtUri().String() 343 + issueMap[atUri] = &issue 344 + } 345 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 + } 351 352 + repos, err := GetRepos(e, 0, FilterIn("at_uri", repoAts)) 353 if err != nil { 354 + return nil, fmt.Errorf("failed to build repo mappings: %w", err) 355 } 356 357 + repoMap := make(map[string]*Repo) 358 + for i := range repos { 359 + repoMap[string(repos[i].RepoAt())] = &repos[i] 360 + } 361 362 + for issueAt, 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) 369 } 370 + } 371 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 + } 378 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]) 383 } 384 + } 385 386 + var issues []Issue 387 + for _, i := range issueMap { 388 + issues = append(issues, *i) 389 } 390 391 + sort.Slice(issues, func(i, j int) bool { 392 + return issues[i].Created.After(issues[j].Created) 393 + }) 394 395 return issues, nil 396 + } 397 + 398 + func GetIssues(e Execer, filters ...filter) ([]Issue, error) { 399 + return GetIssuesPaginated(e, pagination.FirstPage(), filters...) 400 } 401 402 func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, error) { ··· 405 406 var issue Issue 407 var createdAt string 408 + err := row.Scan(&issue.Id, &issue.Did, &issue.Rkey, &createdAt, &issue.Title, &issue.Body, &issue.Open) 409 if err != nil { 410 return nil, err 411 } ··· 419 return &issue, nil 420 } 421 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 + ) 453 if err != nil { 454 + return 0, err 455 } 456 457 + id, err := result.LastInsertId() 458 if err != nil { 459 + return 0, err 460 } 461 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()...) 471 } 472 473 + whereClause := "" 474 + if conditions != nil { 475 + whereClause = " where " + strings.Join(conditions, " and ") 476 + } 477 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...) 481 return err 482 } 483 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 + } 493 494 + whereClause := "" 495 + if conditions != nil { 496 + whereClause = " where " + strings.Join(conditions, " and ") 497 + } 498 + 499 + query := fmt.Sprintf(` 500 select 501 + id, 502 + did, 503 rkey, 504 + issue_at, 505 + reply_to, 506 body, 507 created, 508 edited, 509 deleted 510 from 511 + issue_comments 512 + %s 513 + `, whereClause) 514 + 515 + rows, err := e.Query(query, args...) 516 if err != nil { 517 return nil, err 518 } 519 520 for rows.Next() { 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 + ) 535 if err != nil { 536 return nil, err 537 } 538 539 + // this is a remnant from old times, newer comments always have rkey 540 + if rkey.Valid { 541 + comment.Rkey = rkey.V 542 } 543 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 551 } 552 } 553 554 + if deleted.Valid { 555 + if t, err := time.Parse(time.RFC3339, deleted.V); err == nil { 556 + comment.Deleted = &t 557 } 558 } 559 560 + if replyTo.Valid { 561 + comment.ReplyTo = &replyTo.V 562 } 563 564 comments = append(comments, comment) 565 } 566 567 + if err = rows.Err(); err != nil { 568 return nil, err 569 } 570 571 return comments, nil 572 } 573 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()...) 580 } 581 582 + whereClause := "" 583 + if conditions != nil { 584 + whereClause = " where " + strings.Join(conditions, " and ") 585 } 586 587 + query := fmt.Sprintf(`delete from issues %s`, whereClause) 588 + _, err := e.Exec(query, args...) 589 + return err 590 + } 591 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()...) 598 } 599 600 + whereClause := "" 601 + if conditions != nil { 602 + whereClause = " where " + strings.Join(conditions, " and ") 603 } 604 605 + query := fmt.Sprintf(`update issues set open = 0 %s`, whereClause) 606 + _, err := e.Exec(query, args...) 607 return err 608 } 609 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 + } 617 618 + whereClause := "" 619 + if conditions != nil { 620 + whereClause = " where " + strings.Join(conditions, " and ") 621 + } 622 623 + query := fmt.Sprintf(`update issues set open = 1 %s`, whereClause) 624 + _, err := e.Exec(query, args...) 625 return err 626 } 627
+23 -5
appview/db/profile.go
··· 22 ByMonth []ByMonth 23 } 24 25 type ByMonth struct { 26 RepoEvents []RepoEvent 27 IssueEvents IssueEvents ··· 118 *items = append(*items, &pull) 119 } 120 121 - issues, err := GetIssuesByOwnerDid(e, forDid, timeframe) 122 if err != nil { 123 return nil, fmt.Errorf("error getting issues by owner did: %w", err) 124 } ··· 137 *items = append(*items, &issue) 138 } 139 140 - repos, err := GetAllReposByDid(e, forDid) 141 if err != nil { 142 return nil, fmt.Errorf("error getting all repos by did: %w", err) 143 } ··· 535 query = `select count(id) from pulls where owner_did = ? and state = ?` 536 args = append(args, did, PullOpen) 537 case VanityStatOpenIssueCount: 538 - query = `select count(id) from issues where owner_did = ? and open = 1` 539 args = append(args, did) 540 case VanityStatClosedIssueCount: 541 - query = `select count(id) from issues where owner_did = ? and open = 0` 542 args = append(args, did) 543 case VanityStatRepositoryCount: 544 query = `select count(id) from repos where did = ?` ··· 572 } 573 574 // ensure all pinned repos are either own repos or collaborating repos 575 - repos, err := GetAllReposByDid(e, profile.Did) 576 if err != nil { 577 log.Printf("getting repos for %s: %s", profile.Did, err) 578 }
··· 22 ByMonth []ByMonth 23 } 24 25 + func (p *ProfileTimeline) IsEmpty() bool { 26 + if p == nil { 27 + return true 28 + } 29 + 30 + for _, m := range p.ByMonth { 31 + if !m.IsEmpty() { 32 + return false 33 + } 34 + } 35 + 36 + return true 37 + } 38 + 39 type ByMonth struct { 40 RepoEvents []RepoEvent 41 IssueEvents IssueEvents ··· 132 *items = append(*items, &pull) 133 } 134 135 + issues, err := GetIssues( 136 + e, 137 + FilterEq("did", forDid), 138 + FilterGte("created", time.Now().AddDate(0, -TimeframeMonths, 0)), 139 + ) 140 if err != nil { 141 return nil, fmt.Errorf("error getting issues by owner did: %w", err) 142 } ··· 155 *items = append(*items, &issue) 156 } 157 158 + repos, err := GetRepos(e, 0, FilterEq("did", forDid)) 159 if err != nil { 160 return nil, fmt.Errorf("error getting all repos by did: %w", err) 161 } ··· 553 query = `select count(id) from pulls where owner_did = ? and state = ?` 554 args = append(args, did, PullOpen) 555 case VanityStatOpenIssueCount: 556 + query = `select count(id) from issues where did = ? and open = 1` 557 args = append(args, did) 558 case VanityStatClosedIssueCount: 559 + query = `select count(id) from issues where did = ? and open = 0` 560 args = append(args, did) 561 case VanityStatRepositoryCount: 562 query = `select count(id) from repos where did = ?` ··· 590 } 591 592 // ensure all pinned repos are either own repos or collaborating repos 593 + repos, err := GetRepos(e, 0, FilterEq("did", profile.Did)) 594 if err != nil { 595 log.Printf("getting repos for %s: %s", profile.Did, err) 596 }
+9 -8
appview/db/pulls.go
··· 91 } 92 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, 102 } 103 return record 104 }
··· 91 } 92 93 record := tangled.RepoPull{ 94 + Title: p.Title, 95 + Body: &p.Body, 96 + CreatedAt: p.Created.Format(time.RFC3339), 97 + Target: &tangled.RepoPull_Target{ 98 + Repo: p.RepoAt.String(), 99 + Branch: p.TargetBranch, 100 + }, 101 + Patch: p.LatestPatch(), 102 + Source: source, 103 } 104 return record 105 }
+4 -4
appview/db/punchcard.go
··· 29 Punches []Punch 30 } 31 32 - func MakePunchcard(e Execer, filters ...filter) (Punchcard, error) { 33 - punchcard := Punchcard{} 34 now := time.Now() 35 startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC) 36 endOfYear := time.Date(now.Year(), 12, 31, 0, 0, 0, 0, time.UTC) ··· 63 64 rows, err := e.Query(query, args...) 65 if err != nil { 66 - return punchcard, err 67 } 68 defer rows.Close() 69 ··· 72 var date string 73 var count sql.NullInt64 74 if err := rows.Scan(&date, &count); err != nil { 75 - return punchcard, err 76 } 77 78 punch.Date, err = time.Parse(time.DateOnly, date)
··· 29 Punches []Punch 30 } 31 32 + func MakePunchcard(e Execer, filters ...filter) (*Punchcard, error) { 33 + punchcard := &Punchcard{} 34 now := time.Now() 35 startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC) 36 endOfYear := time.Date(now.Year(), 12, 31, 0, 0, 0, 0, time.UTC) ··· 63 64 rows, err := e.Query(query, args...) 65 if err != nil { 66 + return nil, err 67 } 68 defer rows.Close() 69 ··· 72 var date string 73 var count sql.NullInt64 74 if err := rows.Scan(&date, &count); err != nil { 75 + return nil, err 76 } 77 78 punch.Date, err = time.Parse(time.DateOnly, date)
+17 -17
appview/db/registration.go
··· 10 // Registration represents a knot registration. Knot would've been a better 11 // name but we're stuck with this for historical reasons. 12 type Registration struct { 13 - Id int64 14 - Domain string 15 - ByDid string 16 - Created *time.Time 17 - Registered *time.Time 18 - ReadOnly bool 19 } 20 21 func (r *Registration) Status() Status { 22 - if r.ReadOnly { 23 - return ReadOnly 24 } else if r.Registered != nil { 25 return Registered 26 } else { ··· 32 return r.Status() == Registered 33 } 34 35 - func (r *Registration) IsReadOnly() bool { 36 - return r.Status() == ReadOnly 37 } 38 39 func (r *Registration) IsPending() bool { ··· 45 const ( 46 Registered Status = iota 47 Pending 48 - ReadOnly 49 ) 50 51 func GetRegistrations(e Execer, filters ...filter) ([]Registration, error) { ··· 64 } 65 66 query := fmt.Sprintf(` 67 - select id, domain, did, created, registered, read_only 68 from registrations 69 %s 70 order by created ··· 80 for rows.Next() { 81 var createdAt string 82 var registeredAt sql.Null[string] 83 - var readOnly int 84 var reg Registration 85 86 - err = rows.Scan(&reg.Id, &reg.Domain, &reg.ByDid, &createdAt, &registeredAt, &readOnly) 87 if err != nil { 88 return nil, err 89 } ··· 98 } 99 } 100 101 - if readOnly != 0 { 102 - reg.ReadOnly = true 103 } 104 105 registrations = append(registrations, reg) ··· 116 args = append(args, filter.Arg()...) 117 } 118 119 - query := "update registrations set registered = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), read_only = 0" 120 if len(conditions) > 0 { 121 query += " where " + strings.Join(conditions, " and ") 122 }
··· 10 // Registration represents a knot registration. Knot would've been a better 11 // name but we're stuck with this for historical reasons. 12 type Registration struct { 13 + Id int64 14 + Domain string 15 + ByDid string 16 + Created *time.Time 17 + Registered *time.Time 18 + NeedsUpgrade bool 19 } 20 21 func (r *Registration) Status() Status { 22 + if r.NeedsUpgrade { 23 + return NeedsUpgrade 24 } else if r.Registered != nil { 25 return Registered 26 } else { ··· 32 return r.Status() == Registered 33 } 34 35 + func (r *Registration) IsNeedsUpgrade() bool { 36 + return r.Status() == NeedsUpgrade 37 } 38 39 func (r *Registration) IsPending() bool { ··· 45 const ( 46 Registered Status = iota 47 Pending 48 + NeedsUpgrade 49 ) 50 51 func GetRegistrations(e Execer, filters ...filter) ([]Registration, error) { ··· 64 } 65 66 query := fmt.Sprintf(` 67 + select id, domain, did, created, registered, needs_upgrade 68 from registrations 69 %s 70 order by created ··· 80 for rows.Next() { 81 var createdAt string 82 var registeredAt sql.Null[string] 83 + var needsUpgrade int 84 var reg Registration 85 86 + err = rows.Scan(&reg.Id, &reg.Domain, &reg.ByDid, &createdAt, &registeredAt, &needsUpgrade) 87 if err != nil { 88 return nil, err 89 } ··· 98 } 99 } 100 101 + if needsUpgrade != 0 { 102 + reg.NeedsUpgrade = true 103 } 104 105 registrations = append(registrations, reg) ··· 116 args = append(args, filter.Arg()...) 117 } 118 119 + query := "update registrations set registered = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), needs_upgrade = 0" 120 if len(conditions) > 0 { 121 query += " where " + strings.Join(conditions, " and ") 122 }
+27 -130
appview/db/repos.go
··· 2 3 import ( 4 "database/sql" 5 "fmt" 6 "log" 7 "slices" ··· 36 func (r Repo) DidSlashRepo() string { 37 p, _ := securejoin.SecureJoin(r.Did, r.Name) 38 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 } 74 75 func GetRepos(e Execer, limit int, filters ...filter) ([]Repo, error) { ··· 310 311 slices.SortFunc(repos, func(a, b Repo) int { 312 if a.Created.After(b.Created) { 313 - return 1 314 } 315 - return -1 316 }) 317 318 return repos, nil 319 } 320 321 - func GetAllReposByDid(e Execer, did string) ([]Repo, error) { 322 - var repos []Repo 323 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 346 } 347 - defer rows.Close() 348 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 - } 360 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) 379 } 380 381 - if err := rows.Err(); err != nil { 382 - return nil, err 383 - } 384 - 385 - return repos, nil 386 } 387 388 func GetRepo(e Execer, did, name string) (*Repo, error) { ··· 466 var repos []Repo 467 468 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, 474 ) 475 if err != nil { 476 return nil, err ··· 567 IssueCount IssueCount 568 PullCount PullCount 569 } 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 - }
··· 2 3 import ( 4 "database/sql" 5 + "errors" 6 "fmt" 7 "log" 8 "slices" ··· 37 func (r Repo) DidSlashRepo() string { 38 p, _ := securejoin.SecureJoin(r.Did, r.Name) 39 return p 40 } 41 42 func GetRepos(e Execer, limit int, filters ...filter) ([]Repo, error) { ··· 277 278 slices.SortFunc(repos, func(a, b Repo) int { 279 if a.Created.After(b.Created) { 280 + return -1 281 } 282 + return 1 283 }) 284 285 return repos, nil 286 } 287 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 + } 295 296 + whereClause := "" 297 + if conditions != nil { 298 + whereClause = " where " + strings.Join(conditions, " and ") 299 } 300 301 + repoQuery := fmt.Sprintf(`select count(1) from repos %s`, whereClause) 302 + var count int64 303 + err := e.QueryRow(repoQuery, args...).Scan(&count) 304 305 + if !errors.Is(err, sql.ErrNoRows) && err != nil { 306 + return 0, err 307 } 308 309 + return count, nil 310 } 311 312 func GetRepo(e Execer, did, name string) (*Repo, error) { ··· 390 var repos []Repo 391 392 rows, err := e.Query( 393 + `select distinct r.did, r.name, r.knot, r.rkey, r.description, r.created, r.source 394 + from repos r 395 + left join collaborators c on r.at_uri = c.repo_at 396 + where (r.did = ? or c.subject_did = ?) 397 + and r.source is not null 398 + and r.source != '' 399 + order by r.created desc`, 400 + did, did, 401 ) 402 if err != nil { 403 return nil, err ··· 494 IssueCount IssueCount 495 PullCount PullCount 496 }
+14 -7
appview/db/spindle.go
··· 10 ) 11 12 type Spindle struct { 13 - Id int 14 - Owner syntax.DID 15 - Instance string 16 - Verified *time.Time 17 - Created time.Time 18 } 19 20 type SpindleMember struct { ··· 42 } 43 44 query := fmt.Sprintf( 45 - `select id, owner, instance, verified, created 46 from spindles 47 %s 48 order by created ··· 61 var spindle Spindle 62 var createdAt string 63 var verified sql.NullString 64 65 if err := rows.Scan( 66 &spindle.Id, ··· 68 &spindle.Instance, 69 &verified, 70 &createdAt, 71 ); err != nil { 72 return nil, err 73 } ··· 86 spindle.Verified = &t 87 } 88 89 spindles = append(spindles, spindle) 90 } 91 ··· 115 whereClause = " where " + strings.Join(conditions, " and ") 116 } 117 118 - query := fmt.Sprintf(`update spindles set verified = strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', 'now') %s`, whereClause) 119 120 res, err := e.Exec(query, args...) 121 if err != nil {
··· 10 ) 11 12 type Spindle struct { 13 + Id int 14 + Owner syntax.DID 15 + Instance string 16 + Verified *time.Time 17 + Created time.Time 18 + NeedsUpgrade bool 19 } 20 21 type SpindleMember struct { ··· 43 } 44 45 query := fmt.Sprintf( 46 + `select id, owner, instance, verified, created, needs_upgrade 47 from spindles 48 %s 49 order by created ··· 62 var spindle Spindle 63 var createdAt string 64 var verified sql.NullString 65 + var needsUpgrade int 66 67 if err := rows.Scan( 68 &spindle.Id, ··· 70 &spindle.Instance, 71 &verified, 72 &createdAt, 73 + &needsUpgrade, 74 ); err != nil { 75 return nil, err 76 } ··· 89 spindle.Verified = &t 90 } 91 92 + if needsUpgrade != 0 { 93 + spindle.NeedsUpgrade = true 94 + } 95 + 96 spindles = append(spindles, spindle) 97 } 98 ··· 122 whereClause = " where " + strings.Join(conditions, " and ") 123 } 124 125 + query := fmt.Sprintf(`update spindles set verified = strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', 'now'), needs_upgrade = 0 %s`, whereClause) 126 127 res, err := e.Exec(query, args...) 128 if err != nil {
+26
appview/db/star.go
··· 1 package db 2 3 import ( 4 "fmt" 5 "log" 6 "strings" ··· 181 } 182 183 return stars, nil 184 } 185 186 func GetAllStars(e Execer, limit int) ([]Star, error) {
··· 1 package db 2 3 import ( 4 + "database/sql" 5 + "errors" 6 "fmt" 7 "log" 8 "strings" ··· 183 } 184 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 210 } 211 212 func GetAllStars(e Execer, limit int) ([]Star, error) {
+24
appview/db/strings.go
··· 206 return all, nil 207 } 208 209 func DeleteString(e Execer, filters ...filter) error { 210 var conditions []string 211 var args []any
··· 206 return all, nil 207 } 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 + 233 func DeleteString(e Execer, filters ...filter) error { 234 var conditions []string 235 var args []any
+12 -14
appview/db/timeline.go
··· 20 *FollowStats 21 } 22 23 - const Limit = 50 24 - 25 // TODO: this gathers heterogenous events from different sources and aggregates 26 // 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) { 28 var events []TimelineEvent 29 30 - repos, err := getTimelineRepos(e) 31 if err != nil { 32 return nil, err 33 } 34 35 - stars, err := getTimelineStars(e) 36 if err != nil { 37 return nil, err 38 } 39 40 - follows, err := getTimelineFollows(e) 41 if err != nil { 42 return nil, err 43 } ··· 51 }) 52 53 // Limit the slice to 100 events 54 - if len(events) > Limit { 55 - events = events[:Limit] 56 } 57 58 return events, nil 59 } 60 61 - func getTimelineRepos(e Execer) ([]TimelineEvent, error) { 62 - repos, err := GetRepos(e, Limit) 63 if err != nil { 64 return nil, err 65 } ··· 104 return events, nil 105 } 106 107 - func getTimelineStars(e Execer) ([]TimelineEvent, error) { 108 - stars, err := GetStars(e, Limit) 109 if err != nil { 110 return nil, err 111 } ··· 131 return events, nil 132 } 133 134 - func getTimelineFollows(e Execer) ([]TimelineEvent, error) { 135 - follows, err := GetFollows(e, Limit) 136 if err != nil { 137 return nil, err 138 }
··· 20 *FollowStats 21 } 22 23 // TODO: this gathers heterogenous events from different sources and aggregates 24 // them in code; if we did this entirely in sql, we could order and limit and paginate easily 25 + func MakeTimeline(e Execer, limit int) ([]TimelineEvent, error) { 26 var events []TimelineEvent 27 28 + repos, err := getTimelineRepos(e, limit) 29 if err != nil { 30 return nil, err 31 } 32 33 + stars, err := getTimelineStars(e, limit) 34 if err != nil { 35 return nil, err 36 } 37 38 + follows, err := getTimelineFollows(e, limit) 39 if err != nil { 40 return nil, err 41 } ··· 49 }) 50 51 // Limit the slice to 100 events 52 + if len(events) > limit { 53 + events = events[:limit] 54 } 55 56 return events, nil 57 } 58 59 + func getTimelineRepos(e Execer, limit int) ([]TimelineEvent, error) { 60 + repos, err := GetRepos(e, limit) 61 if err != nil { 62 return nil, err 63 } ··· 102 return events, nil 103 } 104 105 + func getTimelineStars(e Execer, limit int) ([]TimelineEvent, error) { 106 + stars, err := GetStars(e, limit) 107 if err != nil { 108 return nil, err 109 } ··· 129 return events, nil 130 } 131 132 + func getTimelineFollows(e Execer, limit int) ([]TimelineEvent, error) { 133 + follows, err := GetFollows(e, limit) 134 if err != nil { 135 return nil, err 136 }
+133 -6
appview/ingester.go
··· 5 "encoding/json" 6 "fmt" 7 "log/slog" 8 "time" 9 10 "github.com/bluesky-social/indigo/atproto/syntax" ··· 15 "tangled.sh/tangled.sh/core/appview/config" 16 "tangled.sh/tangled.sh/core/appview/db" 17 "tangled.sh/tangled.sh/core/appview/serververify" 18 "tangled.sh/tangled.sh/core/idresolver" 19 "tangled.sh/tangled.sh/core/rbac" 20 ) ··· 25 IdResolver *idresolver.Resolver 26 Config *config.Config 27 Logger *slog.Logger 28 } 29 30 type processFunc func(ctx context.Context, e *models.Event) error ··· 61 case tangled.ActorProfileNSID: 62 err = i.ingestProfile(e) 63 case tangled.SpindleMemberNSID: 64 - err = i.ingestSpindleMember(e) 65 case tangled.SpindleNSID: 66 - err = i.ingestSpindle(e) 67 case tangled.KnotMemberNSID: 68 err = i.ingestKnotMember(e) 69 case tangled.KnotNSID: 70 err = i.ingestKnot(e) 71 case tangled.StringNSID: 72 err = i.ingestString(e) 73 } 74 l = i.Logger.With("nsid", e.Commit.Collection) 75 } ··· 340 return nil 341 } 342 343 - func (i *Ingester) ingestSpindleMember(e *models.Event) error { 344 did := e.Did 345 var err error 346 ··· 363 return fmt.Errorf("failed to enforce permissions: %w", err) 364 } 365 366 - memberId, err := i.IdResolver.ResolveIdent(context.Background(), record.Subject) 367 if err != nil { 368 return err 369 } ··· 446 return nil 447 } 448 449 - func (i *Ingester) ingestSpindle(e *models.Event) error { 450 did := e.Did 451 var err error 452 ··· 479 return err 480 } 481 482 - err = serververify.RunVerification(context.Background(), instance, did, i.Config.Core.Dev) 483 if err != nil { 484 l.Error("failed to add spindle to db", "err", err, "instance", instance) 485 return err ··· 769 770 return nil 771 }
··· 5 "encoding/json" 6 "fmt" 7 "log/slog" 8 + 9 "time" 10 11 "github.com/bluesky-social/indigo/atproto/syntax" ··· 16 "tangled.sh/tangled.sh/core/appview/config" 17 "tangled.sh/tangled.sh/core/appview/db" 18 "tangled.sh/tangled.sh/core/appview/serververify" 19 + "tangled.sh/tangled.sh/core/appview/validator" 20 "tangled.sh/tangled.sh/core/idresolver" 21 "tangled.sh/tangled.sh/core/rbac" 22 ) ··· 27 IdResolver *idresolver.Resolver 28 Config *config.Config 29 Logger *slog.Logger 30 + Validator *validator.Validator 31 } 32 33 type processFunc func(ctx context.Context, e *models.Event) error ··· 64 case tangled.ActorProfileNSID: 65 err = i.ingestProfile(e) 66 case tangled.SpindleMemberNSID: 67 + err = i.ingestSpindleMember(ctx, e) 68 case tangled.SpindleNSID: 69 + err = i.ingestSpindle(ctx, e) 70 case tangled.KnotMemberNSID: 71 err = i.ingestKnotMember(e) 72 case tangled.KnotNSID: 73 err = i.ingestKnot(e) 74 case tangled.StringNSID: 75 err = i.ingestString(e) 76 + case tangled.RepoIssueNSID: 77 + err = i.ingestIssue(ctx, e) 78 + case tangled.RepoIssueCommentNSID: 79 + err = i.ingestIssueComment(e) 80 } 81 l = i.Logger.With("nsid", e.Commit.Collection) 82 } ··· 347 return nil 348 } 349 350 + func (i *Ingester) ingestSpindleMember(ctx context.Context, e *models.Event) error { 351 did := e.Did 352 var err error 353 ··· 370 return fmt.Errorf("failed to enforce permissions: %w", err) 371 } 372 373 + memberId, err := i.IdResolver.ResolveIdent(ctx, record.Subject) 374 if err != nil { 375 return err 376 } ··· 453 return nil 454 } 455 456 + func (i *Ingester) ingestSpindle(ctx context.Context, e *models.Event) error { 457 did := e.Did 458 var err error 459 ··· 486 return err 487 } 488 489 + err = serververify.RunVerification(ctx, instance, did, i.Config.Core.Dev) 490 if err != nil { 491 l.Error("failed to add spindle to db", "err", err, "instance", instance) 492 return err ··· 776 777 return nil 778 } 779 + func (i *Ingester) ingestIssue(ctx context.Context, e *models.Event) error { 780 + did := e.Did 781 + rkey := e.Commit.RKey 782 + 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
··· 1 package issues 2 3 import ( 4 "fmt" 5 "log" 6 - mathrand "math/rand/v2" 7 "net/http" 8 "slices" 9 - "strconv" 10 - "strings" 11 "time" 12 13 comatproto "github.com/bluesky-social/indigo/api/atproto" 14 - "github.com/bluesky-social/indigo/atproto/data" 15 lexutil "github.com/bluesky-social/indigo/lex/util" 16 "github.com/go-chi/chi/v5" 17 ··· 21 "tangled.sh/tangled.sh/core/appview/notify" 22 "tangled.sh/tangled.sh/core/appview/oauth" 23 "tangled.sh/tangled.sh/core/appview/pages" 24 - "tangled.sh/tangled.sh/core/appview/pages/markup" 25 "tangled.sh/tangled.sh/core/appview/pagination" 26 "tangled.sh/tangled.sh/core/appview/reporesolver" 27 "tangled.sh/tangled.sh/core/idresolver" 28 "tangled.sh/tangled.sh/core/tid" 29 ) 30 ··· 36 db *db.DB 37 config *config.Config 38 notifier notify.Notifier 39 } 40 41 func New( ··· 46 db *db.DB, 47 config *config.Config, 48 notifier notify.Notifier, 49 ) *Issues { 50 return &Issues{ 51 oauth: oauth, ··· 55 db: db, 56 config: config, 57 notifier: notifier, 58 } 59 } 60 61 func (rp *Issues) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { 62 user := rp.oauth.GetUser(r) 63 f, err := rp.repoResolver.Resolve(r) 64 if err != nil { ··· 66 return 67 } 68 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.") 81 return 82 } 83 84 reactionCountMap, err := db.GetReactionCountMap(rp.db, issue.AtUri()) 85 if err != nil { 86 - log.Println("failed to get issue reactions") 87 - rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 88 } 89 90 userReactions := map[db.ReactionKind]bool{} ··· 92 userReactions = db.GetReactionStatusMap(rp.db, user.Did, issue.AtUri()) 93 } 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 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 - 108 OrderedReactionKinds: db.OrderedReactionKinds, 109 Reactions: reactionCountMap, 110 UserReacted: userReactions, 111 }) 112 - 113 } 114 115 - func (rp *Issues) CloseIssue(w http.ResponseWriter, r *http.Request) { 116 user := rp.oauth.GetUser(r) 117 f, err := rp.repoResolver.Resolve(r) 118 if err != nil { ··· 120 return 121 } 122 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) 128 return 129 } 130 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 - } 137 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 146 147 - // TODO: make this more granular 148 - if isIssueOwner || isCollaborator { 149 - 150 - closed := tangled.RepoIssueStateClosed 151 152 client, err := rp.oauth.AuthorizedClient(r) 153 if err != nil { 154 - log.Println("failed to get authorized client", err) 155 return 156 } 157 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 158 - Collection: tangled.RepoIssueStateNSID, 159 Repo: user.Did, 160 - Rkey: tid.TID(), 161 Record: &lexutil.LexiconTypeDecoder{ 162 - Val: &tangled.RepoIssueState{ 163 - Issue: issue.AtUri().String(), 164 - State: closed, 165 - }, 166 }, 167 }) 168 169 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.") 172 return 173 } 174 175 - err = db.CloseIssue(rp.db, f.RepoAt(), issueIdInt) 176 if err != nil { 177 log.Println("failed to close issue", err) 178 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 179 return 180 } 181 182 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt)) 183 return 184 } else { 185 log.Println("user is not permitted to close issue") ··· 189 } 190 191 func (rp *Issues) ReopenIssue(w http.ResponseWriter, r *http.Request) { 192 user := rp.oauth.GetUser(r) 193 f, err := rp.repoResolver.Resolve(r) 194 if err != nil { ··· 196 return 197 } 198 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.") 211 return 212 } 213 ··· 218 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 219 return user.Did == collab.Did 220 }) 221 - isIssueOwner := user.Did == issue.OwnerDid 222 223 if isCollaborator || isIssueOwner { 224 - err := db.ReopenIssue(rp.db, f.RepoAt(), issueIdInt) 225 if err != nil { 226 log.Println("failed to reopen issue", err) 227 rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") 228 return 229 } 230 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt)) 231 return 232 } else { 233 log.Println("user is not the owner of the repo") ··· 237 } 238 239 func (rp *Issues) NewIssueComment(w http.ResponseWriter, r *http.Request) { 240 user := rp.oauth.GetUser(r) 241 f, err := rp.repoResolver.Resolve(r) 242 if err != nil { 243 - log.Println("failed to get repo and knot", err) 244 return 245 } 246 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) 252 return 253 } 254 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 - } 262 263 - commentId := mathrand.IntN(1000000) 264 - rkey := tid.TID() 265 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 - } 279 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 - } 289 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 296 } 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 - } 317 318 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issueIdInt, commentId)) 319 return 320 } 321 } 322 323 func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) { 324 user := rp.oauth.GetUser(r) 325 f, err := rp.repoResolver.Resolve(r) 326 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) 336 return 337 } 338 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) 344 return 345 } 346 347 - issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt) 348 if err != nil { 349 - log.Println("failed to get issue", err) 350 - rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 351 return 352 } 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) 357 return 358 } 359 360 - rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 361 LoggedInUser: user, 362 RepoInfo: f.RepoInfo(user), 363 Issue: issue, 364 - Comment: comment, 365 }) 366 } 367 368 func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) { 369 user := rp.oauth.GetUser(r) 370 f, err := rp.repoResolver.Resolve(r) 371 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) 381 return 382 } 383 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) 389 return 390 } 391 392 - issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt) 393 if err != nil { 394 - log.Println("failed to get issue", err) 395 - rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 396 return 397 } 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) 402 return 403 } 404 405 - if comment.OwnerDid != user.Did { 406 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 407 return 408 } ··· 413 LoggedInUser: user, 414 RepoInfo: f.RepoInfo(user), 415 Issue: issue, 416 - Comment: comment, 417 }) 418 case http.MethodPost: 419 // extract form value ··· 424 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 425 return 426 } 427 - rkey := comment.Rkey 428 429 - // optimistic update 430 - edited := time.Now() 431 - err = db.EditComment(rp.db, comment.RepoAt, comment.Issue, comment.CommentId, newBody) 432 if err != nil { 433 log.Println("failed to perferom update-description query", err) 434 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") ··· 436 } 437 438 // rkey is optional, it was introduced later 439 - if comment.Rkey != "" { 440 // update the record on pds 441 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, rkey) 442 if err != nil { 443 - // failed to get record 444 - log.Println(err, rkey) 445 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") 446 return 447 } 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 456 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 457 Collection: tangled.RepoIssueCommentNSID, 458 Repo: user.Did, 459 - Rkey: rkey, 460 SwapRecord: ex.Cid, 461 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 - }, 470 }, 471 }) 472 if err != nil { 473 - log.Println(err) 474 } 475 } 476 477 - // optimistic update for htmx 478 - comment.Body = newBody 479 - comment.Edited = &edited 480 - 481 // return new comment body with htmx 482 - rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 483 LoggedInUser: user, 484 RepoInfo: f.RepoInfo(user), 485 Issue: issue, 486 - Comment: comment, 487 }) 488 return 489 490 } 491 492 } 493 494 - func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { 495 user := rp.oauth.GetUser(r) 496 f, err := rp.repoResolver.Resolve(r) 497 if err != nil { 498 - log.Println("failed to get repo and knot", err) 499 return 500 } 501 502 - issueId := chi.URLParam(r, "issue") 503 - issueIdInt, err := strconv.Atoi(issueId) 504 if err != nil { 505 - http.Error(w, "bad issue id", http.StatusBadRequest) 506 - log.Println("failed to parse issue id", err) 507 return 508 } 509 510 - issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt) 511 if err != nil { 512 - log.Println("failed to get issue", err) 513 - rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 514 return 515 } 516 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) 522 return 523 } 524 525 - comment, err := db.GetComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt) 526 if err != nil { 527 - http.Error(w, "bad comment id", http.StatusBadRequest) 528 return 529 } 530 531 - if comment.OwnerDid != user.Did { 532 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 533 return 534 } ··· 540 541 // optimistic deletion 542 deleted := time.Now() 543 - err = db.DeleteComment(rp.db, f.RepoAt(), issueIdInt, commentIdInt) 544 if err != nil { 545 - log.Println("failed to delete comment") 546 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment") 547 return 548 } ··· 556 return 557 } 558 _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 559 - Collection: tangled.GraphFollowNSID, 560 Repo: user.Did, 561 Rkey: comment.Rkey, 562 }) ··· 570 comment.Deleted = &deleted 571 572 // htmx fragment of comment after deletion 573 - rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 574 LoggedInUser: user, 575 RepoInfo: f.RepoInfo(user), 576 Issue: issue, 577 - Comment: comment, 578 }) 579 } 580 ··· 604 return 605 } 606 607 - issues, err := db.GetIssuesPaginated(rp.db, f.RepoAt(), isOpen, page) 608 if err != nil { 609 log.Println("failed to get issues", err) 610 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") ··· 621 } 622 623 func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) { 624 user := rp.oauth.GetUser(r) 625 626 f, err := rp.repoResolver.Resolve(r) 627 if err != nil { 628 - log.Println("failed to get repo and knot", err) 629 return 630 } 631 ··· 636 RepoInfo: f.RepoInfo(user), 637 }) 638 case http.MethodPost: 639 - title := r.FormValue("title") 640 - body := r.FormValue("body") 641 642 - if title == "" || body == "" { 643 - rp.pages.Notice(w, "issues", "Title and body are required") 644 return 645 } 646 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") 650 return 651 } 652 - if sb := strings.TrimSpace(sanitizer.SanitizeDefault(body)); sb == "" { 653 - rp.pages.Notice(w, "issues", "Body is empty after HTML sanitization") 654 return 655 } 656 657 tx, err := rp.db.BeginTx(r.Context(), nil) 658 if err != nil { 659 rp.pages.Notice(w, "issues", "Failed to create issue, try again later") 660 return 661 } 662 663 - issue := &db.Issue{ 664 - RepoAt: f.RepoAt(), 665 - Rkey: tid.TID(), 666 - Title: title, 667 - Body: body, 668 - OwnerDid: user.Did, 669 } 670 - err = db.NewIssue(tx, issue) 671 if err != nil { 672 log.Println("failed to create issue", err) 673 rp.pages.Notice(w, "issues", "Failed to create issue.") 674 return 675 } 676 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 { 699 log.Println("failed to create issue", err) 700 rp.pages.Notice(w, "issues", "Failed to create issue.") 701 return 702 } 703 704 rp.notifier.NewIssue(r.Context(), issue) 705 - 706 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 707 return 708 } 709 }
··· 1 package issues 2 3 import ( 4 + "context" 5 + "database/sql" 6 + "errors" 7 "fmt" 8 "log" 9 + "log/slog" 10 "net/http" 11 "slices" 12 "time" 13 14 comatproto "github.com/bluesky-social/indigo/api/atproto" 15 + "github.com/bluesky-social/indigo/atproto/syntax" 16 lexutil "github.com/bluesky-social/indigo/lex/util" 17 "github.com/go-chi/chi/v5" 18 ··· 22 "tangled.sh/tangled.sh/core/appview/notify" 23 "tangled.sh/tangled.sh/core/appview/oauth" 24 "tangled.sh/tangled.sh/core/appview/pages" 25 "tangled.sh/tangled.sh/core/appview/pagination" 26 "tangled.sh/tangled.sh/core/appview/reporesolver" 27 + "tangled.sh/tangled.sh/core/appview/validator" 28 + "tangled.sh/tangled.sh/core/appview/xrpcclient" 29 "tangled.sh/tangled.sh/core/idresolver" 30 + tlog "tangled.sh/tangled.sh/core/log" 31 "tangled.sh/tangled.sh/core/tid" 32 ) 33 ··· 39 db *db.DB 40 config *config.Config 41 notifier notify.Notifier 42 + logger *slog.Logger 43 + validator *validator.Validator 44 } 45 46 func New( ··· 51 db *db.DB, 52 config *config.Config, 53 notifier notify.Notifier, 54 + validator *validator.Validator, 55 ) *Issues { 56 return &Issues{ 57 oauth: oauth, ··· 61 db: db, 62 config: config, 63 notifier: notifier, 64 + logger: tlog.New("issues"), 65 + validator: validator, 66 } 67 } 68 69 func (rp *Issues) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { 70 + l := rp.logger.With("handler", "RepoSingleIssue") 71 user := rp.oauth.GetUser(r) 72 f, err := rp.repoResolver.Resolve(r) 73 if err != nil { ··· 75 return 76 } 77 78 + issue, ok := r.Context().Value("issue").(*db.Issue) 79 + if !ok { 80 + l.Error("failed to get issue") 81 + rp.pages.Error404(w) 82 return 83 } 84 85 reactionCountMap, err := db.GetReactionCountMap(rp.db, issue.AtUri()) 86 if err != nil { 87 + l.Error("failed to get issue reactions", "err", err) 88 } 89 90 userReactions := map[db.ReactionKind]bool{} ··· 92 userReactions = db.GetReactionStatusMap(rp.db, user.Did, issue.AtUri()) 93 } 94 95 rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{ 96 + LoggedInUser: user, 97 + RepoInfo: f.RepoInfo(user), 98 + Issue: issue, 99 + CommentList: issue.CommentList(), 100 OrderedReactionKinds: db.OrderedReactionKinds, 101 Reactions: reactionCountMap, 102 UserReacted: userReactions, 103 }) 104 } 105 106 + func (rp *Issues) EditIssue(w http.ResponseWriter, r *http.Request) { 107 + l := rp.logger.With("handler", "EditIssue") 108 user := rp.oauth.GetUser(r) 109 f, err := rp.repoResolver.Resolve(r) 110 if err != nil { ··· 112 return 113 } 114 115 + issue, ok := r.Context().Value("issue").(*db.Issue) 116 + if !ok { 117 + l.Error("failed to get issue") 118 + rp.pages.Error404(w) 119 return 120 } 121 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") 134 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 + } 140 141 + newRecord := newIssue.AsRecord() 142 143 + // edit an atproto record 144 client, err := rp.oauth.AuthorizedClient(r) 145 if err != nil { 146 + l.Error("failed to get authorized client", "err", err) 147 + rp.pages.Notice(w, noticeId, "Failed to edit issue.") 148 return 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 + 158 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 159 + Collection: tangled.RepoIssueNSID, 160 Repo: user.Did, 161 + Rkey: newIssue.Rkey, 162 + SwapRecord: ex.Cid, 163 Record: &lexutil.LexiconTypeDecoder{ 164 + Val: &newRecord, 165 }, 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 + } 172 173 + // modify on DB -- TODO: transact this cleverly 174 + tx, err := rp.db.Begin() 175 if err != nil { 176 + l.Error("failed to edit issue on DB", "err", err) 177 + rp.pages.Notice(w, noticeId, "Failed to edit issue.") 178 return 179 } 180 + defer tx.Rollback() 181 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 + ) 280 if err != nil { 281 log.Println("failed to close issue", err) 282 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 283 return 284 } 285 286 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 287 return 288 } else { 289 log.Println("user is not permitted to close issue") ··· 293 } 294 295 func (rp *Issues) ReopenIssue(w http.ResponseWriter, r *http.Request) { 296 + l := rp.logger.With("handler", "ReopenIssue") 297 user := rp.oauth.GetUser(r) 298 f, err := rp.repoResolver.Resolve(r) 299 if err != nil { ··· 301 return 302 } 303 304 + issue, ok := r.Context().Value("issue").(*db.Issue) 305 + if !ok { 306 + l.Error("failed to get issue") 307 + rp.pages.Error404(w) 308 return 309 } 310 ··· 315 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 316 return user.Did == collab.Did 317 }) 318 + isIssueOwner := user.Did == issue.Did 319 320 if isCollaborator || isIssueOwner { 321 + err := db.ReopenIssues( 322 + rp.db, 323 + db.FilterEq("id", issue.Id), 324 + ) 325 if err != nil { 326 log.Println("failed to reopen issue", err) 327 rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") 328 return 329 } 330 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 331 return 332 } else { 333 log.Println("user is not the owner of the repo") ··· 337 } 338 339 func (rp *Issues) NewIssueComment(w http.ResponseWriter, r *http.Request) { 340 + l := rp.logger.With("handler", "NewIssueComment") 341 user := rp.oauth.GetUser(r) 342 f, err := rp.repoResolver.Resolve(r) 343 if err != nil { 344 + l.Error("failed to get repo and knot", "err", err) 345 return 346 } 347 348 + issue, ok := r.Context().Value("issue").(*db.Issue) 349 + if !ok { 350 + l.Error("failed to get issue") 351 + rp.pages.Error404(w) 352 return 353 } 354 355 + body := r.FormValue("body") 356 + if body == "" { 357 + rp.pages.Notice(w, "issue", "Body is required") 358 + return 359 + } 360 361 + replyToUri := r.FormValue("reply-to") 362 + var replyTo *string 363 + if replyToUri != "" { 364 + replyTo = &replyToUri 365 + } 366 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() 381 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 + } 388 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) 407 } 408 + }() 409 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.") 414 return 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)) 420 } 421 422 func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) { 423 + l := rp.logger.With("handler", "IssueComment") 424 user := rp.oauth.GetUser(r) 425 f, err := rp.repoResolver.Resolve(r) 426 if err != nil { 427 + l.Error("failed to get repo and knot", "err", err) 428 return 429 } 430 431 + issue, ok := r.Context().Value("issue").(*db.Issue) 432 + if !ok { 433 + l.Error("failed to get issue") 434 + rp.pages.Error404(w) 435 return 436 } 437 438 + commentId := chi.URLParam(r, "commentId") 439 + comments, err := db.GetIssueComments( 440 + rp.db, 441 + db.FilterEq("id", commentId), 442 + ) 443 if err != nil { 444 + l.Error("failed to fetch comment", "id", commentId) 445 + http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 446 return 447 } 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) 451 return 452 } 453 + comment := comments[0] 454 455 + rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 456 LoggedInUser: user, 457 RepoInfo: f.RepoInfo(user), 458 Issue: issue, 459 + Comment: &comment, 460 }) 461 } 462 463 func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) { 464 + l := rp.logger.With("handler", "EditIssueComment") 465 user := rp.oauth.GetUser(r) 466 f, err := rp.repoResolver.Resolve(r) 467 if err != nil { 468 + l.Error("failed to get repo and knot", "err", err) 469 return 470 } 471 472 + issue, ok := r.Context().Value("issue").(*db.Issue) 473 + if !ok { 474 + l.Error("failed to get issue") 475 + rp.pages.Error404(w) 476 return 477 } 478 479 + commentId := chi.URLParam(r, "commentId") 480 + comments, err := db.GetIssueComments( 481 + rp.db, 482 + db.FilterEq("id", commentId), 483 + ) 484 if err != nil { 485 + l.Error("failed to fetch comment", "id", commentId) 486 + http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 487 return 488 } 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) 492 return 493 } 494 + comment := comments[0] 495 496 + if comment.Did != user.Did { 497 + l.Error("unauthorized comment edit", "expectedDid", comment.Did, "gotDid", user.Did) 498 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 499 return 500 } ··· 505 LoggedInUser: user, 506 RepoInfo: f.RepoInfo(user), 507 Issue: issue, 508 + Comment: &comment, 509 }) 510 case http.MethodPost: 511 // extract form value ··· 516 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 517 return 518 } 519 + 520 + now := time.Now() 521 + newComment := comment 522 + newComment.Body = newBody 523 + newComment.Edited = &now 524 + record := newComment.AsRecord() 525 526 + _, err = db.AddIssueComment(rp.db, newComment) 527 if err != nil { 528 log.Println("failed to perferom update-description query", err) 529 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") ··· 531 } 532 533 // rkey is optional, it was introduced later 534 + if newComment.Rkey != "" { 535 // update the record on pds 536 + ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey) 537 if err != nil { 538 + log.Println("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey) 539 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") 540 return 541 } 542 543 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 544 Collection: tangled.RepoIssueCommentNSID, 545 Repo: user.Did, 546 + Rkey: newComment.Rkey, 547 SwapRecord: ex.Cid, 548 Record: &lexutil.LexiconTypeDecoder{ 549 + Val: &record, 550 }, 551 }) 552 if err != nil { 553 + l.Error("failed to update record on PDS", "err", err) 554 } 555 } 556 557 // return new comment body with htmx 558 + rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 559 LoggedInUser: user, 560 RepoInfo: f.RepoInfo(user), 561 Issue: issue, 562 + Comment: &newComment, 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) 573 return 574 + } 575 576 + issue, ok := r.Context().Value("issue").(*db.Issue) 577 + if !ok { 578 + l.Error("failed to get issue") 579 + rp.pages.Error404(w) 580 + return 581 } 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 + }) 606 } 607 608 + func (rp *Issues) ReplyIssueComment(w http.ResponseWriter, r *http.Request) { 609 + l := rp.logger.With("handler", "ReplyIssueComment") 610 user := rp.oauth.GetUser(r) 611 f, err := rp.repoResolver.Resolve(r) 612 if err != nil { 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) 621 return 622 } 623 624 + commentId := chi.URLParam(r, "commentId") 625 + comments, err := db.GetIssueComments( 626 + rp.db, 627 + db.FilterEq("id", commentId), 628 + ) 629 if err != nil { 630 + l.Error("failed to fetch comment", "id", commentId) 631 + http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 632 return 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] 640 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) 653 if err != nil { 654 + l.Error("failed to get repo and knot", "err", err) 655 return 656 } 657 658 + issue, ok := r.Context().Value("issue").(*db.Issue) 659 + if !ok { 660 + l.Error("failed to get issue") 661 + rp.pages.Error404(w) 662 return 663 } 664 665 + commentId := chi.URLParam(r, "commentId") 666 + comments, err := db.GetIssueComments( 667 + rp.db, 668 + db.FilterEq("id", commentId), 669 + ) 670 if err != nil { 671 + l.Error("failed to fetch comment", "id", commentId) 672 + http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 673 return 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] 681 682 + if comment.Did != user.Did { 683 + l.Error("unauthorized action", "expectedDid", comment.Did, "gotDid", user.Did) 684 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 685 return 686 } ··· 692 693 // optimistic deletion 694 deleted := time.Now() 695 + err = db.DeleteIssueComments(rp.db, db.FilterEq("id", comment.Id)) 696 if err != nil { 697 + l.Error("failed to delete comment", "err", err) 698 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment") 699 return 700 } ··· 708 return 709 } 710 _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 711 + Collection: tangled.RepoIssueCommentNSID, 712 Repo: user.Did, 713 Rkey: comment.Rkey, 714 }) ··· 722 comment.Deleted = &deleted 723 724 // htmx fragment of comment after deletion 725 + rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 726 LoggedInUser: user, 727 RepoInfo: f.RepoInfo(user), 728 Issue: issue, 729 + Comment: &comment, 730 }) 731 } 732 ··· 756 return 757 } 758 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 + ) 769 if err != nil { 770 log.Println("failed to get issues", err) 771 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") ··· 782 } 783 784 func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) { 785 + l := rp.logger.With("handler", "NewIssue") 786 user := rp.oauth.GetUser(r) 787 788 f, err := rp.repoResolver.Resolve(r) 789 if err != nil { 790 + l.Error("failed to get repo and knot", "err", err) 791 return 792 } 793 ··· 798 RepoInfo: f.RepoInfo(user), 799 }) 800 case http.MethodPost: 801 + issue := &db.Issue{ 802 + RepoAt: f.RepoAt(), 803 + Rkey: tid.TID(), 804 + Title: r.FormValue("title"), 805 + Body: r.FormValue("body"), 806 + Did: user.Did, 807 + Created: time.Now(), 808 + } 809 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)) 813 return 814 } 815 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.") 823 return 824 } 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.") 836 return 837 } 838 + atUri := resp.Uri 839 840 tx, err := rp.db.BeginTx(r.Context(), nil) 841 if err != nil { 842 rp.pages.Notice(w, "issues", "Failed to create issue, try again later") 843 return 844 } 845 + rollback := func() { 846 + err1 := tx.Rollback() 847 + err2 := rollbackRecord(context.Background(), atUri, client) 848 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 + } 856 } 857 + defer rollback() 858 + 859 + err = db.PutIssue(tx, issue) 860 if err != nil { 861 log.Println("failed to create issue", err) 862 rp.pages.Notice(w, "issues", "Failed to create issue.") 863 return 864 } 865 866 + if err = tx.Commit(); err != nil { 867 log.Println("failed to create issue", err) 868 rp.pages.Notice(w, "issues", "Failed to create issue.") 869 return 870 } 871 872 + // everything is successful, do not rollback the atproto record 873 + atUri = "" 874 rp.notifier.NewIssue(r.Context(), issue) 875 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 876 return 877 } 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
··· 12 13 r.Route("/", func(r chi.Router) { 14 r.With(middleware.Paginate).Get("/", i.RepoIssues) 15 - r.Get("/{issue}", i.RepoSingleIssue) 16 17 r.Group(func(r chi.Router) { 18 r.Use(middleware.AuthMiddleware(i.oauth)) 19 r.Get("/new", i.NewIssue) 20 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 }) 31 }) 32
··· 12 13 r.Route("/", func(r chi.Router) { 14 r.With(middleware.Paginate).Get("/", i.RepoIssues) 15 + 16 + r.Route("/{issue}", func(r chi.Router) { 17 + r.Use(mw.ResolveIssue()) 18 + r.Get("/", i.RepoSingleIssue) 19 + 20 + // authenticated routes 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 + }) 39 40 r.Group(func(r chi.Router) { 41 r.Use(middleware.AuthMiddleware(i.oauth)) 42 r.Get("/new", i.NewIssue) 43 r.Post("/new", i.NewIssue) 44 }) 45 }) 46
+5 -34
appview/knots/knots.go
··· 3 import ( 4 "errors" 5 "fmt" 6 - "log" 7 "log/slog" 8 "net/http" 9 "slices" ··· 17 "tangled.sh/tangled.sh/core/appview/oauth" 18 "tangled.sh/tangled.sh/core/appview/pages" 19 "tangled.sh/tangled.sh/core/appview/serververify" 20 "tangled.sh/tangled.sh/core/eventconsumer" 21 "tangled.sh/tangled.sh/core/idresolver" 22 "tangled.sh/tangled.sh/core/rbac" ··· 49 r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/retry", k.retry) 50 r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/add", k.addMember) 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 55 return r 56 } ··· 399 if err != nil { 400 l.Error("verification failed", "err", err) 401 402 - if errors.Is(err, serververify.FetchError) { 403 - k.Pages.Notice(w, noticeId, "Failed to verify knot, unable to fetch owner.") 404 return 405 } 406 ··· 420 return 421 } 422 423 - // if this knot was previously read-only, then emit a record too 424 // 425 // this is part of migrating from the old knot system to the new one 426 - if registration.ReadOnly { 427 // re-announce by registering under same rkey 428 client, err := k.OAuth.AuthorizedClient(r) 429 if err != nil { ··· 484 return 485 } 486 updatedRegistration := registrations[0] 487 - 488 - log.Println(updatedRegistration) 489 490 w.Header().Set("HX-Reswap", "outerHTML") 491 k.Pages.KnotListing(w, pages.KnotListingParams{ ··· 678 // ok 679 k.Pages.HxRefresh(w) 680 } 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 - }
··· 3 import ( 4 "errors" 5 "fmt" 6 "log/slog" 7 "net/http" 8 "slices" ··· 16 "tangled.sh/tangled.sh/core/appview/oauth" 17 "tangled.sh/tangled.sh/core/appview/pages" 18 "tangled.sh/tangled.sh/core/appview/serververify" 19 + "tangled.sh/tangled.sh/core/appview/xrpcclient" 20 "tangled.sh/tangled.sh/core/eventconsumer" 21 "tangled.sh/tangled.sh/core/idresolver" 22 "tangled.sh/tangled.sh/core/rbac" ··· 49 r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/retry", k.retry) 50 r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/add", k.addMember) 51 r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/remove", k.removeMember) 52 53 return r 54 } ··· 397 if err != nil { 398 l.Error("verification failed", "err", err) 399 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!") 402 return 403 } 404 ··· 418 return 419 } 420 421 + // if this knot requires upgrade, then emit a record too 422 // 423 // this is part of migrating from the old knot system to the new one 424 + if registration.NeedsUpgrade { 425 // re-announce by registering under same rkey 426 client, err := k.OAuth.AuthorizedClient(r) 427 if err != nil { ··· 482 return 483 } 484 updatedRegistration := registrations[0] 485 486 w.Header().Set("HX-Reswap", "outerHTML") 487 k.Pages.KnotListing(w, pages.KnotListingParams{ ··· 674 // ok 675 k.Pages.HxRefresh(w) 676 }
+40
appview/middleware/middleware.go
··· 275 } 276 } 277 278 // this should serve the go-import meta tag even if the path is technically 279 // a 404 like tangled.sh/oppi.li/go-git/v5 280 func (mw Middleware) GoImport() middlewareFunc {
··· 275 } 276 } 277 278 + // middleware that is tacked on top of /{user}/{repo}/issues/{issue} 279 + func (mw Middleware) ResolveIssue() middlewareFunc { 280 + return func(next http.Handler) http.Handler { 281 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 282 + f, err := mw.repoResolver.Resolve(r) 283 + if err != nil { 284 + log.Println("failed to fully resolve repo", err) 285 + mw.pages.ErrorKnot404(w) 286 + return 287 + } 288 + 289 + issueIdStr := chi.URLParam(r, "issue") 290 + issueId, err := strconv.Atoi(issueIdStr) 291 + if err != nil { 292 + log.Println("failed to fully resolve issue ID", err) 293 + mw.pages.ErrorKnot404(w) 294 + return 295 + } 296 + 297 + issues, err := db.GetIssues( 298 + mw.db, 299 + db.FilterEq("repo_at", f.RepoAt()), 300 + db.FilterEq("issue_id", issueId), 301 + ) 302 + if err != nil { 303 + log.Println("failed to get issues", "err", err) 304 + return 305 + } 306 + if len(issues) != 1 { 307 + log.Println("got incorrect number of issues", "len(issuse)", len(issues)) 308 + return 309 + } 310 + issue := issues[0] 311 + 312 + ctx := context.WithValue(r.Context(), "issue", &issue) 313 + next.ServeHTTP(w, r.WithContext(ctx)) 314 + }) 315 + } 316 + } 317 + 318 // this should serve the go-import meta tag even if the path is technically 319 // a 404 like tangled.sh/oppi.li/go-git/v5 320 func (mw Middleware) GoImport() middlewareFunc {
+15 -13
appview/oauth/handler/handler.go
··· 354 } 355 356 var ( 357 - tangledHandle = "tangled.sh" 358 - tangledDid = "did:plc:wshs7t2adsemcrrd4snkeqli" 359 - 360 - icyHandle = "icyphox.sh" 361 - icyDid = "did:plc:hwevmowznbiukdf6uk5dwrrq" 362 363 defaultSpindle = "spindle.tangled.sh" 364 defaultKnot = "knot1.tangled.sh" ··· 383 } 384 385 log.Printf("adding %s to default spindle", did) 386 - session, err := o.createAppPasswordSession(o.config.Core.AppPassword, tangledHandle, tangledDid) 387 if err != nil { 388 log.Printf("failed to create session: %s", err) 389 return ··· 396 CreatedAt: time.Now().Format(time.RFC3339), 397 } 398 399 - if err := session.putRecord(record); err != nil { 400 log.Printf("failed to add member to default spindle: %s", err) 401 return 402 } ··· 420 } 421 422 log.Printf("adding %s to default knot", did) 423 - session, err := o.createAppPasswordSession(o.config.Core.TmpAltAppPassword, icyHandle, icyDid) 424 if err != nil { 425 log.Printf("failed to create session: %s", err) 426 return ··· 433 CreatedAt: time.Now().Format(time.RFC3339), 434 } 435 436 - if err := session.putRecord(record); err != nil { 437 log.Printf("failed to add member to default knot: %s", err) 438 return 439 } 440 ··· 448 Did string 449 } 450 451 - func (o *OAuthHandler) createAppPasswordSession(appPassword, handle, did string) (*session, error) { 452 if appPassword == "" { 453 return nil, fmt.Errorf("no app password configured, skipping member addition") 454 } ··· 464 } 465 466 sessionPayload := map[string]string{ 467 - "identifier": handle, 468 "password": appPassword, 469 } 470 sessionBytes, err := json.Marshal(sessionPayload) ··· 501 return &session, nil 502 } 503 504 - func (s *session) putRecord(record any) error { 505 recordBytes, err := json.Marshal(record) 506 if err != nil { 507 return fmt.Errorf("failed to marshal knot member record: %w", err) ··· 509 510 payload := map[string]any{ 511 "repo": s.Did, 512 - "collection": tangled.KnotMemberNSID, 513 "rkey": tid.TID(), 514 "record": json.RawMessage(recordBytes), 515 }
··· 354 } 355 356 var ( 357 + tangledDid = "did:plc:wshs7t2adsemcrrd4snkeqli" 358 + icyDid = "did:plc:hwevmowznbiukdf6uk5dwrrq" 359 360 defaultSpindle = "spindle.tangled.sh" 361 defaultKnot = "knot1.tangled.sh" ··· 380 } 381 382 log.Printf("adding %s to default spindle", did) 383 + session, err := o.createAppPasswordSession(o.config.Core.AppPassword, tangledDid) 384 if err != nil { 385 log.Printf("failed to create session: %s", err) 386 return ··· 393 CreatedAt: time.Now().Format(time.RFC3339), 394 } 395 396 + if err := session.putRecord(record, tangled.SpindleMemberNSID); err != nil { 397 log.Printf("failed to add member to default spindle: %s", err) 398 return 399 } ··· 417 } 418 419 log.Printf("adding %s to default knot", did) 420 + session, err := o.createAppPasswordSession(o.config.Core.TmpAltAppPassword, icyDid) 421 if err != nil { 422 log.Printf("failed to create session: %s", err) 423 return ··· 430 CreatedAt: time.Now().Format(time.RFC3339), 431 } 432 433 + if err := session.putRecord(record, tangled.KnotMemberNSID); err != nil { 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) 440 return 441 } 442 ··· 450 Did string 451 } 452 453 + func (o *OAuthHandler) createAppPasswordSession(appPassword, did string) (*session, error) { 454 if appPassword == "" { 455 return nil, fmt.Errorf("no app password configured, skipping member addition") 456 } ··· 466 } 467 468 sessionPayload := map[string]string{ 469 + "identifier": did, 470 "password": appPassword, 471 } 472 sessionBytes, err := json.Marshal(sessionPayload) ··· 503 return &session, nil 504 } 505 506 + func (s *session) putRecord(record any, collection string) error { 507 recordBytes, err := json.Marshal(record) 508 if err != nil { 509 return fmt.Errorf("failed to marshal knot member record: %w", err) ··· 511 512 payload := map[string]any{ 513 "repo": s.Did, 514 + "collection": collection, 515 "rkey": tid.TID(), 516 "record": json.RawMessage(recordBytes), 517 }
+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
··· 29 "split": func(s string) []string { 30 return strings.Split(s, "\n") 31 }, 32 "resolve": func(s string) string { 33 identity, err := p.resolver.ResolveIdent(context.Background(), s) 34
··· 29 "split": func(s string) []string { 30 return strings.Split(s, "\n") 31 }, 32 + "contains": func(s string, target string) bool { 33 + return strings.Contains(s, target) 34 + }, 35 "resolve": func(s string) string { 36 identity, err := p.resolver.ResolveIdent(context.Background(), s) 37
+12
appview/pages/markup/format.go
··· 13 FormatMarkdown: []string{".md", ".markdown", ".mdown", ".mkdn", ".mkd"}, 14 } 15 16 func GetFormat(filename string) Format { 17 for format, extensions := range FileTypes { 18 for _, extension := range extensions {
··· 13 FormatMarkdown: []string{".md", ".markdown", ".mdown", ".mkdn", ".mkd"}, 14 } 15 16 + // ReadmeFilenames contains the list of common README filenames to search for, 17 + // in order of preference. Only includes well-supported formats. 18 + var ReadmeFilenames = []string{ 19 + "README.md", "readme.md", 20 + "README", 21 + "readme", 22 + "README.markdown", 23 + "readme.markdown", 24 + "README.txt", 25 + "readme.txt", 26 + } 27 + 28 func GetFormat(filename string) Format { 29 for format, extensions := range FileTypes { 30 for _, extension := range extensions {
+12 -8
appview/pages/markup/markdown.go
··· 11 12 chromahtml "github.com/alecthomas/chroma/v2/formatters/html" 13 "github.com/alecthomas/chroma/v2/styles" 14 "github.com/yuin/goldmark" 15 highlighting "github.com/yuin/goldmark-highlighting/v2" 16 "github.com/yuin/goldmark/ast" ··· 21 "github.com/yuin/goldmark/util" 22 htmlparse "golang.org/x/net/html" 23 24 "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 25 ) 26 ··· 59 extension.NewFootnote( 60 extension.WithFootnoteIDPrefix([]byte("footnote")), 61 ), 62 ), 63 goldmark.WithParserOptions( 64 parser.WithAutoHeadingID(), ··· 229 230 actualPath := rctx.actualPath(dst) 231 232 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 } 242 newPath := parsedURL.String() 243 return newPath
··· 11 12 chromahtml "github.com/alecthomas/chroma/v2/formatters/html" 13 "github.com/alecthomas/chroma/v2/styles" 14 + treeblood "github.com/wyatt915/goldmark-treeblood" 15 "github.com/yuin/goldmark" 16 highlighting "github.com/yuin/goldmark-highlighting/v2" 17 "github.com/yuin/goldmark/ast" ··· 22 "github.com/yuin/goldmark/util" 23 htmlparse "golang.org/x/net/html" 24 25 + "tangled.sh/tangled.sh/core/api/tangled" 26 "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 27 ) 28 ··· 61 extension.NewFootnote( 62 extension.WithFootnoteIDPrefix([]byte("footnote")), 63 ), 64 + treeblood.MathML(), 65 ), 66 goldmark.WithParserOptions( 67 parser.WithAutoHeadingID(), ··· 232 233 actualPath := rctx.actualPath(dst) 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 + 240 parsedURL := &url.URL{ 241 + Scheme: scheme, 242 + Host: rctx.Knot, 243 + Path: path.Join("/xrpc", tangled.RepoBlobNSID), 244 + RawQuery: query, 245 } 246 newPath := parsedURL.String() 247 return newPath
+17
appview/pages/markup/sanitizer.go
··· 97 "margin-bottom", 98 ) 99 100 return policy 101 } 102
··· 97 "margin-bottom", 98 ) 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 + 117 return policy 118 } 119
+270 -190
appview/pages/pages.go
··· 9 "html/template" 10 "io" 11 "io/fs" 12 - "log" 13 "net/http" 14 "os" 15 "path/filepath" ··· 42 var Files embed.FS 43 44 type Pages struct { 45 - mu sync.RWMutex 46 - t map[string]*template.Template 47 48 avatar config.AvatarConfig 49 resolver *idresolver.Resolver 50 dev bool 51 - embedFS embed.FS 52 templateDir string // Path to templates on disk for dev mode 53 rctx *markup.RenderContext 54 } 55 56 func NewPages(config *config.Config, res *idresolver.Resolver) *Pages { ··· 64 65 p := &Pages{ 66 mu: sync.RWMutex{}, 67 - t: make(map[string]*template.Template), 68 dev: config.Core.Dev, 69 avatar: config.Avatar, 70 - embedFS: Files, 71 rctx: rctx, 72 resolver: res, 73 templateDir: "appview/pages", 74 } 75 76 - // Initial load of all templates 77 - p.loadAllTemplates() 78 79 return p 80 } 81 82 - func (p *Pages) loadAllTemplates() { 83 - templates := make(map[string]*template.Template) 84 - var fragmentPaths []string 85 86 - // Use embedded FS for initial loading 87 - // First, collect all fragment paths 88 err := fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error { 89 if err != nil { 90 return err ··· 98 if !strings.Contains(path, "fragments/") { 99 return nil 100 } 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 fragmentPaths = append(fragmentPaths, path) 111 - log.Printf("loaded fragment: %s", name) 112 return nil 113 }) 114 if err != nil { 115 - log.Fatalf("walking template dir for fragments: %v", err) 116 } 117 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 - }) 154 if err != nil { 155 - log.Fatalf("walking template dir: %v", err) 156 } 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 168 } 169 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 - }) 190 if err != nil { 191 - return fmt.Errorf("walking disk template dir for fragments: %w", err) 192 } 193 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 - } 199 200 - // Create a new template 201 - tmpl := template.New(name).Funcs(p.funcMap()) 202 203 - // Parse layouts 204 - layoutGlob := filepath.Join(p.templateDir, "templates", "layouts", "*.html") 205 - layouts, err := filepath.Glob(layoutGlob) 206 if err != nil { 207 - return fmt.Errorf("finding layout templates: %w", err) 208 } 209 210 - // Create paths for parsing 211 - allFiles := append(layouts, fragmentPaths...) 212 - allFiles = append(allFiles, templatePath) 213 214 - // Parse all templates 215 - tmpl, err = tmpl.ParseFiles(allFiles...) 216 - if err != nil { 217 - return fmt.Errorf("parsing template files: %w", err) 218 } 219 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 226 } 227 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 - } 235 } 236 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) 242 } 243 244 - if base == "" { 245 - return tmpl.Execute(w, params) 246 - } else { 247 - return tmpl.ExecuteTemplate(w, base, params) 248 - } 249 } 250 251 func (p *Pages) execute(name string, w io.Writer, params any) error { 252 - return p.executeOrReload(name, w, "layouts/base", params) 253 - } 254 255 - func (p *Pages) executePlain(name string, w io.Writer, params any) error { 256 - return p.executeOrReload(name, w, "", params) 257 } 258 259 func (p *Pages) executeRepo(name string, w io.Writer, params any) error { 260 - return p.executeOrReload(name, w, "layouts/repobase", params) 261 } 262 263 func (p *Pages) Favicon(w io.Writer) error { ··· 282 283 type TermsOfServiceParams struct { 284 LoggedInUser *oauth.User 285 } 286 287 func (p *Pages) TermsOfService(w io.Writer, params TermsOfServiceParams) error { 288 return p.execute("legal/terms", w, params) 289 } 290 291 type PrivacyPolicyParams struct { 292 LoggedInUser *oauth.User 293 } 294 295 func (p *Pages) PrivacyPolicy(w io.Writer, params PrivacyPolicyParams) error { 296 return p.execute("legal/privacy", w, params) 297 } 298 ··· 338 return p.execute("user/settings/emails", w, params) 339 } 340 341 - type KnotBannerParams struct { 342 Registrations []db.Registration 343 } 344 345 - func (p *Pages) KnotBanner(w io.Writer, params KnotBannerParams) error { 346 - return p.executePlain("knots/fragments/banner", w, params) 347 } 348 349 type KnotsParams struct { ··· 422 return p.execute("repo/fork", w, params) 423 } 424 425 - type ProfileHomePageParams struct { 426 LoggedInUser *oauth.User 427 Repos []db.Repo 428 CollaboratingRepos []db.Repo 429 ProfileTimeline *db.ProfileTimeline 430 - Card ProfileCard 431 - Punchcard db.Punchcard 432 } 433 434 - type ProfileCard struct { 435 - UserDid string 436 - UserHandle string 437 - FollowStatus db.FollowStatus 438 - FollowersCount int 439 - FollowingCount int 440 441 - Profile *db.Profile 442 } 443 444 - func (p *Pages) ProfileHomePage(w io.Writer, params ProfileHomePageParams) error { 445 - return p.execute("user/profile", w, params) 446 } 447 448 - type ReposPageParams struct { 449 LoggedInUser *oauth.User 450 Repos []db.Repo 451 - Card ProfileCard 452 } 453 454 - func (p *Pages) ReposPage(w io.Writer, params ReposPageParams) error { 455 - return p.execute("user/repos", w, params) 456 } 457 458 type FollowCard struct { 459 UserDid string 460 FollowStatus db.FollowStatus 461 - FollowersCount int 462 - FollowingCount int 463 Profile *db.Profile 464 } 465 466 - type FollowersPageParams struct { 467 LoggedInUser *oauth.User 468 Followers []FollowCard 469 - Card ProfileCard 470 } 471 472 - func (p *Pages) FollowersPage(w io.Writer, params FollowersPageParams) error { 473 - return p.execute("user/followers", w, params) 474 } 475 476 - type FollowingPageParams struct { 477 LoggedInUser *oauth.User 478 Following []FollowCard 479 - Card ProfileCard 480 } 481 482 - func (p *Pages) FollowingPage(w io.Writer, params FollowingPageParams) error { 483 - return p.execute("user/following", w, params) 484 } 485 486 type FollowFragmentParams struct { ··· 553 VerifiedCommits commitverify.VerifiedCommits 554 Languages []types.RepoLanguageDetails 555 Pipelines map[string]db.Pipeline 556 types.RepoIndexResponse 557 } 558 ··· 562 return p.executeRepo("repo/empty", w, params) 563 } 564 565 p.rctx.RepoInfo = params.RepoInfo 566 p.rctx.RepoInfo.Ref = params.Ref 567 p.rctx.RendererType = markup.RendererTypeRepoMarkdown ··· 649 650 func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error { 651 params.Active = "overview" 652 - return p.execute("repo/tree", w, params) 653 } 654 655 type RepoBranchesParams struct { ··· 700 ShowRendered bool 701 RenderToggle bool 702 RenderedContents template.HTML 703 - types.RepoBlobResponse 704 } 705 706 func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error { ··· 835 RepoInfo repoinfo.RepoInfo 836 Active string 837 Issue *db.Issue 838 - Comments []db.Comment 839 IssueOwnerHandle string 840 841 OrderedReactionKinds []db.ReactionKind 842 Reactions map[db.ReactionKind]int 843 UserReacted map[db.ReactionKind]bool 844 845 - State string 846 } 847 848 type ThreadReactionFragmentParams struct { ··· 856 return p.executePlain("repo/fragments/reaction", w, params) 857 } 858 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 type RepoNewIssueParams struct { 870 LoggedInUser *oauth.User 871 RepoInfo repoinfo.RepoInfo 872 Active string 873 } 874 875 func (p *Pages) RepoNewIssue(w io.Writer, params RepoNewIssueParams) error { 876 params.Active = "issues" 877 return p.executeRepo("repo/issues/new", w, params) 878 } 879 ··· 881 LoggedInUser *oauth.User 882 RepoInfo repoinfo.RepoInfo 883 Issue *db.Issue 884 - Comment *db.Comment 885 } 886 887 func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error { 888 return p.executePlain("repo/issues/fragments/editIssueComment", w, params) 889 } 890 891 - type SingleIssueCommentParams struct { 892 LoggedInUser *oauth.User 893 RepoInfo repoinfo.RepoInfo 894 Issue *db.Issue 895 - Comment *db.Comment 896 } 897 898 - func (p *Pages) SingleIssueCommentFragment(w io.Writer, params SingleIssueCommentParams) error { 899 - return p.executePlain("repo/issues/fragments/issueComment", w, params) 900 } 901 902 type RepoNewPullParams struct { ··· 1262 return p.execute("strings/string", w, params) 1263 } 1264 1265 func (p *Pages) Static() http.Handler { 1266 if p.dev { 1267 return http.StripPrefix("/static/", http.FileServer(http.Dir("appview/pages/static"))) ··· 1269 1270 sub, err := fs.Sub(Files, "static") 1271 if err != nil { 1272 - log.Fatalf("no static dir found? that's crazy: %v", err) 1273 } 1274 // Custom handler to apply Cache-Control headers for font files 1275 return Cache(http.StripPrefix("/static/", http.FileServer(http.FS(sub)))) ··· 1292 func CssContentHash() string { 1293 cssFile, err := Files.Open("static/tw.css") 1294 if err != nil { 1295 - log.Printf("Error opening CSS file: %v", err) 1296 return "" 1297 } 1298 defer cssFile.Close() 1299 1300 hasher := sha256.New() 1301 if _, err := io.Copy(hasher, cssFile); err != nil { 1302 - log.Printf("Error hashing CSS file: %v", err) 1303 return "" 1304 } 1305
··· 9 "html/template" 10 "io" 11 "io/fs" 12 + "log/slog" 13 "net/http" 14 "os" 15 "path/filepath" ··· 42 var Files embed.FS 43 44 type Pages struct { 45 + mu sync.RWMutex 46 + cache *TmplCache[string, *template.Template] 47 48 avatar config.AvatarConfig 49 resolver *idresolver.Resolver 50 dev bool 51 + embedFS fs.FS 52 templateDir string // Path to templates on disk for dev mode 53 rctx *markup.RenderContext 54 + logger *slog.Logger 55 } 56 57 func NewPages(config *config.Config, res *idresolver.Resolver) *Pages { ··· 65 66 p := &Pages{ 67 mu: sync.RWMutex{}, 68 + cache: NewTmplCache[string, *template.Template](), 69 dev: config.Core.Dev, 70 avatar: config.Avatar, 71 rctx: rctx, 72 resolver: res, 73 templateDir: "appview/pages", 74 + logger: slog.Default().With("component", "pages"), 75 } 76 77 + if p.dev { 78 + p.embedFS = os.DirFS(p.templateDir) 79 + } else { 80 + p.embedFS = Files 81 + } 82 83 return p 84 } 85 86 + func (p *Pages) pathToName(s string) string { 87 + return strings.TrimSuffix(strings.TrimPrefix(s, "templates/"), ".html") 88 + } 89 90 + // reverse of pathToName 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 97 err := fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error { 98 if err != nil { 99 return err ··· 107 if !strings.Contains(path, "fragments/") { 108 return nil 109 } 110 fragmentPaths = append(fragmentPaths, path) 111 return nil 112 }) 113 if err != nil { 114 + return nil, err 115 } 116 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() 123 if err != nil { 124 + return nil, err 125 } 126 + for _, s := range stack { 127 + paths = append(paths, p.nameToPath(s)) 128 } 129 130 + funcs := p.funcMap() 131 + top := stack[len(stack)-1] 132 + parsed, err := template.New(top). 133 + Funcs(funcs). 134 + ParseFS(p.embedFS, paths...) 135 if err != nil { 136 + return nil, err 137 } 138 139 + return parsed, nil 140 + } 141 142 + func (p *Pages) parse(stack ...string) (*template.Template, error) { 143 + key := strings.Join(stack, "|") 144 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...) 151 if err != nil { 152 + return nil, err 153 } 154 155 + p.cache.Set(key, result) 156 + return result, nil 157 + } 158 159 + func (p *Pages) parseBase(top string) (*template.Template, error) { 160 + stack := []string{ 161 + "layouts/base", 162 + top, 163 } 164 + return p.parse(stack...) 165 + } 166 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...) 174 } 175 176 + func (p *Pages) parseProfileBase(top string) (*template.Template, error) { 177 + stack := []string{ 178 + "layouts/base", 179 + "layouts/profilebase", 180 + top, 181 } 182 + return p.parse(stack...) 183 + } 184 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 189 } 190 191 + return tpl.Execute(w, params) 192 } 193 194 func (p *Pages) execute(name string, w io.Writer, params any) error { 195 + tpl, err := p.parseBase(name) 196 + if err != nil { 197 + return err 198 + } 199 200 + return tpl.ExecuteTemplate(w, "layouts/base", params) 201 } 202 203 func (p *Pages) executeRepo(name string, w io.Writer, params any) error { 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) 219 } 220 221 func (p *Pages) Favicon(w io.Writer) error { ··· 240 241 type TermsOfServiceParams struct { 242 LoggedInUser *oauth.User 243 + Content template.HTML 244 } 245 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 + 259 return p.execute("legal/terms", w, params) 260 } 261 262 type PrivacyPolicyParams struct { 263 LoggedInUser *oauth.User 264 + Content template.HTML 265 } 266 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 + 280 return p.execute("legal/privacy", w, params) 281 } 282 ··· 322 return p.execute("user/settings/emails", w, params) 323 } 324 325 + type UpgradeBannerParams struct { 326 Registrations []db.Registration 327 + Spindles []db.Spindle 328 } 329 330 + func (p *Pages) UpgradeBanner(w io.Writer, params UpgradeBannerParams) error { 331 + return p.executePlain("banner", w, params) 332 } 333 334 type KnotsParams struct { ··· 407 return p.execute("repo/fork", w, params) 408 } 409 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 { 440 LoggedInUser *oauth.User 441 Repos []db.Repo 442 CollaboratingRepos []db.Repo 443 ProfileTimeline *db.ProfileTimeline 444 + Card *ProfileCard 445 + Active string 446 } 447 448 + func (p *Pages) ProfileOverview(w io.Writer, params ProfileOverviewParams) error { 449 + params.Active = "overview" 450 + return p.executeProfile("user/overview", w, params) 451 + } 452 453 + type ProfileReposParams struct { 454 + LoggedInUser *oauth.User 455 + Repos []db.Repo 456 + Card *ProfileCard 457 + Active string 458 } 459 460 + func (p *Pages) ProfileRepos(w io.Writer, params ProfileReposParams) error { 461 + params.Active = "repos" 462 + return p.executeProfile("user/repos", w, params) 463 } 464 465 + type ProfileStarredParams struct { 466 LoggedInUser *oauth.User 467 Repos []db.Repo 468 + Card *ProfileCard 469 + Active string 470 } 471 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) 487 } 488 489 type FollowCard struct { 490 UserDid string 491 FollowStatus db.FollowStatus 492 + FollowersCount int64 493 + FollowingCount int64 494 Profile *db.Profile 495 } 496 497 + type ProfileFollowersParams struct { 498 LoggedInUser *oauth.User 499 Followers []FollowCard 500 + Card *ProfileCard 501 + Active string 502 } 503 504 + func (p *Pages) ProfileFollowers(w io.Writer, params ProfileFollowersParams) error { 505 + params.Active = "overview" 506 + return p.executeProfile("user/followers", w, params) 507 } 508 509 + type ProfileFollowingParams struct { 510 LoggedInUser *oauth.User 511 Following []FollowCard 512 + Card *ProfileCard 513 + Active string 514 } 515 516 + func (p *Pages) ProfileFollowing(w io.Writer, params ProfileFollowingParams) error { 517 + params.Active = "overview" 518 + return p.executeProfile("user/following", w, params) 519 } 520 521 type FollowFragmentParams struct { ··· 588 VerifiedCommits commitverify.VerifiedCommits 589 Languages []types.RepoLanguageDetails 590 Pipelines map[string]db.Pipeline 591 + NeedsKnotUpgrade bool 592 types.RepoIndexResponse 593 } 594 ··· 598 return p.executeRepo("repo/empty", w, params) 599 } 600 601 + if params.NeedsKnotUpgrade { 602 + return p.executeRepo("repo/needsUpgrade", w, params) 603 + } 604 + 605 p.rctx.RepoInfo = params.RepoInfo 606 p.rctx.RepoInfo.Ref = params.Ref 607 p.rctx.RendererType = markup.RendererTypeRepoMarkdown ··· 689 690 func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error { 691 params.Active = "overview" 692 + return p.executeRepo("repo/tree", w, params) 693 } 694 695 type RepoBranchesParams struct { ··· 740 ShowRendered bool 741 RenderToggle bool 742 RenderedContents template.HTML 743 + *tangled.RepoBlob_Output 744 + // Computed fields for template compatibility 745 + Contents string 746 + Lines int 747 + SizeHint uint64 748 + IsBinary bool 749 } 750 751 func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error { ··· 880 RepoInfo repoinfo.RepoInfo 881 Active string 882 Issue *db.Issue 883 + CommentList []db.CommentListItem 884 IssueOwnerHandle string 885 886 OrderedReactionKinds []db.ReactionKind 887 Reactions map[db.ReactionKind]int 888 UserReacted map[db.ReactionKind]bool 889 + } 890 891 + func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error { 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) 906 } 907 908 type ThreadReactionFragmentParams struct { ··· 916 return p.executePlain("repo/fragments/reaction", w, params) 917 } 918 919 type RepoNewIssueParams struct { 920 LoggedInUser *oauth.User 921 RepoInfo repoinfo.RepoInfo 922 + Issue *db.Issue // existing issue if any -- passed when editing 923 Active string 924 + Action string 925 } 926 927 func (p *Pages) RepoNewIssue(w io.Writer, params RepoNewIssueParams) error { 928 params.Active = "issues" 929 + params.Action = "create" 930 return p.executeRepo("repo/issues/new", w, params) 931 } 932 ··· 934 LoggedInUser *oauth.User 935 RepoInfo repoinfo.RepoInfo 936 Issue *db.Issue 937 + Comment *db.IssueComment 938 } 939 940 func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error { 941 return p.executePlain("repo/issues/fragments/editIssueComment", w, params) 942 } 943 944 + type ReplyIssueCommentPlaceholderParams struct { 945 LoggedInUser *oauth.User 946 RepoInfo repoinfo.RepoInfo 947 Issue *db.Issue 948 + Comment *db.IssueComment 949 } 950 951 + func (p *Pages) ReplyIssueCommentPlaceholderFragment(w io.Writer, params ReplyIssueCommentPlaceholderParams) error { 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) 975 } 976 977 type RepoNewPullParams struct { ··· 1337 return p.execute("strings/string", w, params) 1338 } 1339 1340 + func (p *Pages) Home(w io.Writer, params TimelineParams) error { 1341 + return p.execute("timeline/home", w, params) 1342 + } 1343 + 1344 func (p *Pages) Static() http.Handler { 1345 if p.dev { 1346 return http.StripPrefix("/static/", http.FileServer(http.Dir("appview/pages/static"))) ··· 1348 1349 sub, err := fs.Sub(Files, "static") 1350 if err != nil { 1351 + p.logger.Error("no static dir found? that's crazy", "err", err) 1352 + panic(err) 1353 } 1354 // Custom handler to apply Cache-Control headers for font files 1355 return Cache(http.StripPrefix("/static/", http.FileServer(http.FS(sub)))) ··· 1372 func CssContentHash() string { 1373 cssFile, err := Files.Open("static/tw.css") 1374 if err != nil { 1375 + slog.Debug("Error opening CSS file", "err", err) 1376 return "" 1377 } 1378 defer cssFile.Close() 1379 1380 hasher := sha256.New() 1381 if _, err := io.Copy(hasher, cssFile); err != nil { 1382 + slog.Debug("Error hashing CSS file", "err", err) 1383 return "" 1384 } 1385
+2 -7
appview/pages/repoinfo/repoinfo.go
··· 78 func (r RepoInfo) TabMetadata() map[string]any { 79 meta := make(map[string]any) 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 - } 88 89 // more stuff? 90
··· 78 func (r RepoInfo) TabMetadata() map[string]any { 79 meta := make(map[string]any) 80 81 + meta["pulls"] = r.Stats.PullCount.Open 82 + meta["issues"] = r.Stats.IssueCount.Open 83 84 // more stuff? 85
+38
appview/pages/templates/banner.html
···
··· 1 + {{ define "banner" }} 2 + <div class="flex items-center justify-center mx-auto w-full bg-red-100 dark:bg-red-900 border border-red-200 dark:border-red-800 rounded-b drop-shadow-sm text-red-800 dark:text-red-200"> 3 + <details class="group p-2"> 4 + <summary class="list-none cursor-pointer"> 5 + <div class="flex gap-4 items-center"> 6 + <span class="group-open:hidden inline">{{ i "triangle-alert" "w-4 h-4" }}</span> 7 + <span class="hidden group-open:inline">{{ i "x" "w-4 h-4" }}</span> 8 + 9 + <span class="group-open:hidden inline">Some services that you administer require an update. Click to show more.</span> 10 + <span class="hidden group-open:inline">Some services that you administer will have to be updated to be compatible with Tangled.</span> 11 + </div> 12 + </summary> 13 + 14 + {{ if .Registrations }} 15 + <ul class="list-disc mx-12 my-2"> 16 + {{range .Registrations}} 17 + <li>Knot: {{ .Domain }}</li> 18 + {{ end }} 19 + </ul> 20 + {{ end }} 21 + 22 + {{ if .Spindles }} 23 + <ul class="list-disc mx-12 my-2"> 24 + {{range .Spindles}} 25 + <li>Spindle: {{ .Instance }}</li> 26 + {{ end }} 27 + </ul> 28 + {{ end }} 29 + 30 + <div class="mx-6"> 31 + These services may not be fully accessible until upgraded. 32 + <a class="underline text-red-800 dark:text-red-200" 33 + href="https://tangled.sh/@tangled.sh/core/tree/master/docs/migrations.md"> 34 + Click to read the upgrade guide</a>. 35 + </div> 36 + </details> 37 + </div> 38 + {{ end }}
+1 -1
appview/pages/templates/errors/404.html
··· 17 The page you're looking for doesn't exist. It may have been moved, deleted, or you have the wrong URL. 18 </p> 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"> 21 {{ i "arrow-left" "w-4 h-4" }} 22 go back 23 </a>
··· 17 The page you're looking for doesn't exist. It may have been moved, deleted, or you have the wrong URL. 18 </p> 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 no-underline hover:no-underline gap-2"> 21 {{ i "arrow-left" "w-4 h-4" }} 22 go back 23 </a>
+4 -4
appview/pages/templates/errors/500.html
··· 8 {{ i "alert-triangle" "w-8 h-8 text-red-500 dark:text-red-400" }} 9 </div> 10 </div> 11 - 12 <div class="space-y-4"> 13 <h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white"> 14 500 &mdash; internal server error ··· 24 <p class="mt-1">Our team has been automatically notified about this error.</p> 25 </div> 26 <div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6"> 27 - <button onclick="location.reload()" class="btn-create px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline hover:text-white"> 28 {{ i "refresh-cw" "w-4 h-4" }} 29 try again 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"> 32 {{ i "home" "w-4 h-4" }} 33 back to home 34 </a> ··· 36 </div> 37 </div> 38 </div> 39 - {{ end }}
··· 8 {{ i "alert-triangle" "w-8 h-8 text-red-500 dark:text-red-400" }} 9 </div> 10 </div> 11 + 12 <div class="space-y-4"> 13 <h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white"> 14 500 &mdash; internal server error ··· 24 <p class="mt-1">Our team has been automatically notified about this error.</p> 25 </div> 26 <div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6"> 27 + <button onclick="location.reload()" class="btn-create gap-2"> 28 {{ i "refresh-cw" "w-4 h-4" }} 29 try again 30 </button> 31 + <a href="/" class="btn no-underline hover:no-underline gap-2"> 32 {{ i "home" "w-4 h-4" }} 33 back to home 34 </a> ··· 36 </div> 37 </div> 38 </div> 39 + {{ end }}
+2 -2
appview/pages/templates/errors/503.html
··· 17 We were unable to reach the knot hosting this repository. The service may be temporarily unavailable. 18 </p> 19 <div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6"> 20 - <button onclick="location.reload()" class="btn-create px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline hover:text-white"> 21 {{ i "refresh-cw" "w-4 h-4" }} 22 try again 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"> 25 {{ i "arrow-left" "w-4 h-4" }} 26 back to timeline 27 </a>
··· 17 We were unable to reach the knot hosting this repository. The service may be temporarily unavailable. 18 </p> 19 <div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6"> 20 + <button onclick="location.reload()" class="btn-create gap-2"> 21 {{ i "refresh-cw" "w-4 h-4" }} 22 try again 23 </button> 24 + <a href="/" class="btn gap-2 no-underline hover:no-underline"> 25 {{ i "arrow-left" "w-4 h-4" }} 26 back to timeline 27 </a>
+1 -1
appview/pages/templates/errors/knot404.html
··· 17 The repository you were looking for could not be found. The knot serving the repository may be unavailable. 18 </p> 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"> 21 {{ i "arrow-left" "w-4 h-4" }} 22 back to timeline 23 </a>
··· 17 The repository you were looking for could not be found. The knot serving the repository may be unavailable. 18 </p> 19 <div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6"> 20 + <a href="/" class="btn flex items-center gap-2 no-underline hover:no-underline"> 21 {{ i "arrow-left" "w-4 h-4" }} 22 back to timeline 23 </a>
+8
appview/pages/templates/fragments/logotype.html
···
··· 1 + {{ define "fragments/logotype" }} 2 + <span class="flex items-center gap-2"> 3 + <span class="font-bold italic">tangled</span> 4 + <span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1"> 5 + alpha 6 + </span> 7 + <span> 8 + {{ end }}
-9
appview/pages/templates/knots/fragments/banner.html
··· 1 - {{ define "knots/fragments/banner" }} 2 - <div class="w-full px-6 py-2 -z-15 bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 border border-yellow-200 dark:border-yellow-800 rounded-b drop-shadow-sm"> 3 - A knot ({{range $i, $r := .Registrations}}{{if ne $i 0}}, {{end}}{{ $r.Domain }}{{ end }}) 4 - that you administer is presently read-only. Consider upgrading this knot to 5 - continue creating repositories on it. 6 - <a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/migrations/knot-1.7.0.md">Click to read the upgrade guide</a>. 7 - </div> 8 - {{ end }} 9 -
···
+2 -2
appview/pages/templates/knots/fragments/knotListing.html
··· 36 </span> 37 {{ template "knots/fragments/addMemberModal" . }} 38 {{ block "knotDeleteButton" . }} {{ end }} 39 - {{ else if .IsReadOnly }} 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 42 </span> 43 {{ block "knotRetryButton" . }} {{ end }} 44 {{ block "knotDeleteButton" . }} {{ end }}
··· 36 </span> 37 {{ template "knots/fragments/addMemberModal" . }} 38 {{ block "knotDeleteButton" . }} {{ end }} 39 + {{ else if .IsNeedsUpgrade }} 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" }} needs upgrade 42 </span> 43 {{ block "knotRetryButton" . }} {{ end }} 44 {{ block "knotDeleteButton" . }} {{ end }}
+12 -10
appview/pages/templates/knots/index.html
··· 1 {{ define "title" }}knots{{ end }} 2 3 {{ define "content" }} 4 - <div class="px-6 py-4"> 5 <h1 class="text-xl font-bold dark:text-white">Knots</h1> 6 </div> 7 8 <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> ··· 15 {{ end }} 16 17 {{ 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> 26 </p> 27 - </section> 28 {{ end }} 29 30 {{ define "list" }}
··· 1 {{ define "title" }}knots{{ end }} 2 3 {{ define "content" }} 4 + <div class="px-6 py-4 flex items-center justify-between gap-4 align-bottom"> 5 <h1 class="text-xl font-bold dark:text-white">Knots</h1> 6 + <span class="flex items-center gap-1"> 7 + {{ i "book" "w-3 h-3" }} 8 + <a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/knot-hosting.md">docs</a> 9 + </span> 10 </div> 11 12 <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> ··· 19 {{ end }} 20 21 {{ define "about" }} 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 </p> 27 + 28 + 29 + </section> 30 {{ end }} 31 32 {{ define "list" }}
+27 -12
appview/pages/templates/layouts/base.html
··· 3 <html lang="en" class="dark:bg-gray-900"> 4 <head> 5 <meta charset="UTF-8" /> 6 - <meta 7 - name="viewport" 8 - content="width=device-width, initial-scale=1.0" 9 - /> 10 <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> 13 <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 14 <title>{{ block "title" . }}{{ end }} ยท tangled</title> 15 {{ block "extrameta" . }}{{ end }} 16 </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"> 18 {{ 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" . }} 21 </header> 22 {{ end }} 23 24 {{ block "mainLayout" . }} 25 - <div class="px-1 col-span-1 md:col-start-3 md:col-span-8 flex flex-col gap-4"> 26 {{ block "contentLayout" . }} 27 <main class="col-span-1 md:col-span-8"> 28 {{ block "content" . }}{{ end }} ··· 38 {{ end }} 39 40 {{ block "footerLayout" . }} 41 - <footer class="px-1 col-span-1 md:col-start-3 md:col-span-8 mt-12"> 42 - {{ template "layouts/footer" . }} 43 </footer> 44 {{ end }} 45 </body>
··· 3 <html lang="en" class="dark:bg-gray-900"> 4 <head> 5 <meta charset="UTF-8" /> 6 + <meta name="viewport" content="width=device-width, initial-scale=1.0"/> 7 + <meta name="description" content="Social coding, but for real this time!"/> 8 <meta name="htmx-config" content='{"includeIndicatorStyles": false}'> 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 + 20 <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 21 <title>{{ block "title" . }}{{ end }} ยท tangled</title> 22 {{ block "extrameta" . }}{{ end }} 23 </head> 24 + <body class="min-h-screen grid grid-cols-1 grid-rows-[min-content_auto_min-content] md:grid-cols-10 lg:grid-cols-12 gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200"> 25 {{ block "topbarLayout" . }} 26 + <header class="px-1 col-span-1 md:col-start-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" . }} 36 </header> 37 {{ end }} 38 39 {{ block "mainLayout" . }} 40 + <div class="px-1 col-span-1 md:col-start-2 md:col-span-8 lg:col-start-3 flex flex-col gap-4"> 41 {{ block "contentLayout" . }} 42 <main class="col-span-1 md:col-span-8"> 43 {{ block "content" . }}{{ end }} ··· 53 {{ end }} 54 55 {{ block "footerLayout" . }} 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" . }} 58 </footer> 59 {{ end }} 60 </body>
-48
appview/pages/templates/layouts/footer.html
··· 1 - {{ define "layouts/footer" }} 2 - <div class="w-full p-4 md:p-8 bg-white dark:bg-gray-800 rounded-t drop-shadow-sm"> 3 - <div class="container mx-auto max-w-7xl px-4"> 4 - <div class="flex flex-col lg:flex-row justify-between items-start text-gray-600 dark:text-gray-400 text-sm gap-8"> 5 - <div class="mb-4 md:mb-0"> 6 - <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic"> 7 - tangled<sub>alpha</sub> 8 - </a> 9 - </div> 10 - 11 - {{ $headerStyle := "text-gray-900 dark:text-gray-200 font-bold text-xs uppercase tracking-wide mb-1" }} 12 - {{ $linkStyle := "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center" }} 13 - {{ $iconStyle := "w-4 h-4 flex-shrink-0" }} 14 - <div class="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-4 sm:gap-6 md:gap-2 gap-6 flex-1"> 15 - <div class="flex flex-col gap-1"> 16 - <div class="{{ $headerStyle }}">legal</div> 17 - <a href="/terms" class="{{ $linkStyle }}">{{ i "file-text" $iconStyle }} terms of service</a> 18 - <a href="/privacy" class="{{ $linkStyle }}">{{ i "shield" $iconStyle }} privacy policy</a> 19 - </div> 20 - 21 - <div class="flex flex-col gap-1"> 22 - <div class="{{ $headerStyle }}">resources</div> 23 - <a href="https://blog.tangled.sh" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a> 24 - <a href="https://tangled.sh/@tangled.sh/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 25 - <a href="https://tangled.sh/@tangled.sh/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a> 26 - </div> 27 - 28 - <div class="flex flex-col gap-1"> 29 - <div class="{{ $headerStyle }}">social</div> 30 - <a href="https://chat.tangled.sh" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "message-circle" $iconStyle }} discord</a> 31 - <a href="https://web.libera.chat/#tangled" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "hash" $iconStyle }} irc</a> 32 - <a href="https://bsky.app/profile/tangled.sh" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ template "user/fragments/bluesky" $iconStyle }} bluesky</a> 33 - </div> 34 - 35 - <div class="flex flex-col gap-1"> 36 - <div class="{{ $headerStyle }}">contact</div> 37 - <a href="mailto:team@tangled.sh" class="{{ $linkStyle }}">{{ i "mail" "w-4 h-4 flex-shrink-0" }} team@tangled.sh</a> 38 - <a href="mailto:security@tangled.sh" class="{{ $linkStyle }}">{{ i "shield-check" "w-4 h-4 flex-shrink-0" }} security@tangled.sh</a> 39 - </div> 40 - </div> 41 - 42 - <div class="text-center lg:text-right flex-shrink-0"> 43 - <div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div> 44 - </div> 45 - </div> 46 - </div> 47 - </div> 48 - {{ end }}
···
+48
appview/pages/templates/layouts/fragments/footer.html
···
··· 1 + {{ define "layouts/fragments/footer" }} 2 + <div class="w-full p-4 md:p-8 bg-white dark:bg-gray-800 rounded-t drop-shadow-sm"> 3 + <div class="container mx-auto max-w-7xl px-4"> 4 + <div class="flex flex-col lg:flex-row justify-between items-start text-gray-600 dark:text-gray-400 text-sm gap-8"> 5 + <div class="mb-4 md:mb-0"> 6 + <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic"> 7 + tangled<sub>alpha</sub> 8 + </a> 9 + </div> 10 + 11 + {{ $headerStyle := "text-gray-900 dark:text-gray-200 font-bold text-xs uppercase tracking-wide mb-1" }} 12 + {{ $linkStyle := "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center" }} 13 + {{ $iconStyle := "w-4 h-4 flex-shrink-0" }} 14 + <div class="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-4 sm:gap-6 md:gap-2 gap-6 flex-1"> 15 + <div class="flex flex-col gap-1"> 16 + <div class="{{ $headerStyle }}">legal</div> 17 + <a href="/terms" class="{{ $linkStyle }}">{{ i "file-text" $iconStyle }} terms of service</a> 18 + <a href="/privacy" class="{{ $linkStyle }}">{{ i "shield" $iconStyle }} privacy policy</a> 19 + </div> 20 + 21 + <div class="flex flex-col gap-1"> 22 + <div class="{{ $headerStyle }}">resources</div> 23 + <a href="https://blog.tangled.sh" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a> 24 + <a href="https://tangled.sh/@tangled.sh/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 25 + <a href="https://tangled.sh/@tangled.sh/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a> 26 + </div> 27 + 28 + <div class="flex flex-col gap-1"> 29 + <div class="{{ $headerStyle }}">social</div> 30 + <a href="https://chat.tangled.sh" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "message-circle" $iconStyle }} discord</a> 31 + <a href="https://web.libera.chat/#tangled" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "hash" $iconStyle }} irc</a> 32 + <a href="https://bsky.app/profile/tangled.sh" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ template "user/fragments/bluesky" $iconStyle }} bluesky</a> 33 + </div> 34 + 35 + <div class="flex flex-col gap-1"> 36 + <div class="{{ $headerStyle }}">contact</div> 37 + <a href="mailto:team@tangled.sh" class="{{ $linkStyle }}">{{ i "mail" "w-4 h-4 flex-shrink-0" }} team@tangled.sh</a> 38 + <a href="mailto:security@tangled.sh" class="{{ $linkStyle }}">{{ i "shield-check" "w-4 h-4 flex-shrink-0" }} security@tangled.sh</a> 39 + </div> 40 + </div> 41 + 42 + <div class="text-center lg:text-right flex-shrink-0"> 43 + <div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div> 44 + </div> 45 + </div> 46 + </div> 47 + </div> 48 + {{ end }}
+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
···
··· 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
··· 42 </section> 43 44 <section 45 - class="w-full flex flex-col drop-shadow-sm" 46 > 47 <nav class="w-full pl-4 overflow-auto"> 48 <div class="flex z-60"> ··· 71 <span class="flex items-center justify-center"> 72 {{ i $icon "w-4 h-4 mr-2" }} 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> 76 {{ end }} 77 </span> 78 </div> ··· 81 </div> 82 </nav> 83 <section 84 - class="bg-white dark:bg-gray-800 p-6 rounded relative w-full dark:text-white" 85 > 86 {{ block "repoContent" . }}{{ end }} 87 </section> 88 {{ block "repoAfter" . }}{{ end }} 89 </section> 90 {{ end }} 91 - 92 - {{ define "layouts/repobase" }} 93 - {{ template "layouts/base" . }} 94 - {{ end }}
··· 42 </section> 43 44 <section 45 + class="w-full flex flex-col" 46 > 47 <nav class="w-full pl-4 overflow-auto"> 48 <div class="flex z-60"> ··· 71 <span class="flex items-center justify-center"> 72 {{ i $icon "w-4 h-4 mr-2" }} 73 {{ $key }} 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 {{ end }} 77 </span> 78 </div> ··· 81 </div> 82 </nav> 83 <section 84 + class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white" 85 > 86 {{ block "repoContent" . }}{{ end }} 87 </section> 88 {{ block "repoAfter" . }}{{ end }} 89 </section> 90 {{ end }}
-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
··· 1 - {{ define "title" }} privacy policy {{ end }} 2 {{ define "content" }} 3 <div class="max-w-4xl mx-auto px-4 py-8"> 4 <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8"> 5 <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> 130 </div> 131 </div> 132 </div> 133 - {{ end }}
··· 1 + {{ define "title" }}privacy policy{{ end }} 2 + 3 {{ define "content" }} 4 <div class="max-w-4xl mx-auto px-4 py-8"> 5 <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8"> 6 <div class="prose prose-gray dark:prose-invert max-w-none"> 7 + {{ .Content }} 8 </div> 9 </div> 10 </div> 11 + {{ end }}
+2 -62
appview/pages/templates/legal/terms.html
··· 4 <div class="max-w-4xl mx-auto px-4 py-8"> 5 <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8"> 6 <div class="prose prose-gray dark:prose-invert max-w-none"> 7 - <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> 68 </div> 69 </div> 70 </div> 71 - {{ end }}
··· 4 <div class="max-w-4xl mx-auto px-4 py-8"> 5 <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8"> 6 <div class="prose prose-gray dark:prose-invert max-w-none"> 7 + {{ .Content }} 8 </div> 9 </div> 10 </div> 11 + {{ end }}
+2 -2
appview/pages/templates/repo/commit.html
··· 81 82 {{ define "topbarLayout" }} 83 <header class="px-1 col-span-full" style="z-index: 20;"> 84 - {{ template "layouts/topbar" . }} 85 </header> 86 {{ end }} 87 ··· 106 107 {{ define "footerLayout" }} 108 <footer class="px-1 col-span-full mt-12"> 109 - {{ template "layouts/footer" . }} 110 </footer> 111 {{ end }} 112
··· 81 82 {{ define "topbarLayout" }} 83 <header class="px-1 col-span-full" style="z-index: 20;"> 84 + {{ template "layouts/fragments/topbar" . }} 85 </header> 86 {{ end }} 87 ··· 106 107 {{ define "footerLayout" }} 108 <footer class="px-1 col-span-full mt-12"> 109 + {{ template "layouts/fragments/footer" . }} 110 </footer> 111 {{ end }} 112
+2 -2
appview/pages/templates/repo/compare/compare.html
··· 12 13 {{ define "topbarLayout" }} 14 <header class="px-1 col-span-full" style="z-index: 20;"> 15 - {{ template "layouts/topbar" . }} 16 </header> 17 {{ end }} 18 ··· 37 38 {{ define "footerLayout" }} 39 <footer class="px-1 col-span-full mt-12"> 40 - {{ template "layouts/footer" . }} 41 </footer> 42 {{ end }} 43
··· 12 13 {{ define "topbarLayout" }} 14 <header class="px-1 col-span-full" style="z-index: 20;"> 15 + {{ template "layouts/fragments/topbar" . }} 16 </header> 17 {{ end }} 18 ··· 37 38 {{ define "footerLayout" }} 39 <footer class="px-1 col-span-full mt-12"> 40 + {{ template "layouts/fragments/footer" . }} 41 </footer> 42 {{ end }} 43
+1 -1
appview/pages/templates/repo/fork.html
··· 19 class="mr-2" 20 id="domain-{{ . }}" 21 /> 22 - <span class="dark:text-white">{{ . }}</span> 23 </div> 24 {{ else }} 25 <p class="dark:text-white">No knots available.</p>
··· 19 class="mr-2" 20 id="domain-{{ . }}" 21 /> 22 + <label for="domain-{{ . }}" class="dark:text-white">{{ . }}</label> 23 </div> 24 {{ else }} 25 <p class="dark:text-white">No knots available.</p>
+35 -83
appview/pages/templates/repo/fragments/diff.html
··· 11 {{ $last := sub (len $diff) 1 }} 12 13 <div class="flex flex-col gap-4"> 14 {{ range $idx, $hunk := $diff }} 15 {{ 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> 74 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 -}} 94 {{ else }} 95 - {{- template "repo/fragments/unifiedDiff" . -}} 96 {{ end }} 97 - {{- end -}} 98 </div> 99 100 - </details> 101 - 102 </div> 103 - </div> 104 - </section> 105 {{ end }} 106 {{ end }} 107 </div> 108 {{ end }}
··· 11 {{ $last := sub (len $diff) 1 }} 12 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 }} 19 {{ range $idx, $hunk := $diff }} 20 {{ with $hunk }} 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 }} 28 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 }} 34 {{ else }} 35 + {{ .Name.New }} 36 {{ end }} 37 + </div> 38 </div> 39 + </div> 40 + </summary> 41 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 -}} 54 </div> 55 + </details> 56 {{ end }} 57 + {{ end }} 58 {{ end }} 59 </div> 60 {{ end }}
+4
appview/pages/templates/repo/fragments/duration.html
···
··· 1 + {{ define "repo/fragments/duration" }} 2 + <time datetime="{{ . | iso8601DurationFmt }}" title="{{ . | longDurationFmt }}">{{ . | durationFmt }}</time> 3 + {{ end }} 4 +
+44 -69
appview/pages/templates/repo/fragments/interdiff.html
··· 10 <div class="flex flex-col gap-4"> 11 {{ range $idx, $hunk := $diff }} 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 - 58 </div> 59 - </summary> 60 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 -}} 81 </div> 82 83 - </details> 84 85 </div> 86 - </div> 87 - </section> 88 {{ end }} 89 {{ end }} 90 </div>
··· 10 <div class="flex flex-col gap-4"> 11 {{ range $idx, $hunk := $diff }} 12 {{ with $hunk }} 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 }} 32 </div> 33 34 + <div class="flex gap-2 items-center overflow-x-auto" style="direction: rtl;">{{ .Name }}</div> 35 </div> 36 37 + </div> 38 + </summary> 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 -}} 60 </div> 61 + 62 + </details> 63 {{ end }} 64 {{ end }} 65 </div>
+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
···
··· 1 + {{ define "repo/fragments/shortTime" }} 2 + {{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (. | shortRelTimeFmt)) }} 3 + {{ end }} 4 +
+4
appview/pages/templates/repo/fragments/shortTimeAgo.html
···
··· 1 + {{ define "repo/fragments/shortTimeAgo" }} 2 + {{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (print (. | shortRelTimeFmt) " ago")) }} 3 + {{ end }} 4 +
-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 {{ define "repo/fragments/time" }} 6 {{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (. | relTimeFmt)) }} 7 {{ 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 }}
··· 1 {{ define "repo/fragments/time" }} 2 {{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (. | relTimeFmt)) }} 3 {{ end }}
+5
appview/pages/templates/repo/fragments/timeWrapper.html
···
··· 1 + {{ define "repo/fragments/timeWrapper" }} 2 + <time datetime="{{ .Time | iso8601DateTimeFmt }}" title="{{ .Time | longTimeFmt }}">{{ .Content }}</time> 3 + {{ end }} 4 + 5 +
+25 -8
appview/pages/templates/repo/index.html
··· 35 {{ end }} 36 37 {{ define "repoLanguages" }} 38 - <div class="flex gap-[1px] -m-6 mb-6 overflow-hidden rounded-t"> 39 {{ 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> 45 {{ end }} 46 - </div> 47 {{ end }} 48 - 49 50 {{ define "branchSelector" }} 51 <div class="flex gap-2 items-center justify-between w-full">
··· 35 {{ end }} 36 37 {{ define "repoLanguages" }} 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"> 48 {{ range $value := .Languages }} 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> 62 {{ end }} 63 + </div> 64 + </details> 65 {{ end }} 66 67 {{ define "branchSelector" }} 68 <div class="flex gap-2 items-center justify-between w-full">
+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
··· 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> 7 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> 41 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> 48 </div> 49 - {{ end }} 50 {{ end }} 51
··· 1 {{ define "repo/issues/fragments/editIssueComment" }} 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> 9 10 + {{ template "editActions" $ }} 11 + </div> 12 + {{ end }} 13 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" . }} 18 </div> 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> 32 {{ end }} 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
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
··· 9 {{ end }} 10 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> 18 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 }} 25 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> 41 42 - {{ if .Issue.Body }} 43 - <article id="body" class="mt-8 prose dark:prose-invert"> 44 - {{ .Issue.Body | markdown }} 45 - </article> 46 - {{ end }} 47 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 {{ end }} 63 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> 77 78 - {{ block "newComment" . }} {{ end }} 79 80 {{ end }} 81 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 - } 194 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 - } 213 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 }}
··· 9 {{ end }} 10 11 {{ define "repoContent" }} 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 }} 21 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 }} 30 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> 54 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 }} 61 62 + {{ define "issueActions" }} 63 + {{ template "editIssue" . }} 64 + {{ template "deleteIssue" . }} 65 {{ end }} 66 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 }} 76 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 }} 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> 102 {{ end }} 103 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 + }} 114 115 + {{ template "repo/issues/fragments/newComment" . }} 116 + <div> 117 + {{ end }} 118
+42 -44
appview/pages/templates/repo/issues/issues.html
··· 37 {{ end }} 38 39 {{ define "repoAfter" }} 40 - <div class="flex flex-col gap-2 mt-2"> 41 - {{ range .Issues }} 42 - <div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700"> 43 - <div class="pb-2"> 44 - <a 45 - href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" 46 - class="no-underline hover:underline" 47 - > 48 - {{ .Title | description }} 49 - <span class="text-gray-500">#{{ .IssueId }}</span> 50 - </a> 51 - </div> 52 - <p class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 53 - {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 54 - {{ $icon := "ban" }} 55 - {{ $state := "closed" }} 56 - {{ if .Open }} 57 - {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 58 - {{ $icon = "circle-dot" }} 59 - {{ $state = "open" }} 60 - {{ end }} 61 62 - <span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm"> 63 - {{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }} 64 - <span class="text-white dark:text-white">{{ $state }}</span> 65 - </span> 66 67 - <span class="ml-1"> 68 - {{ template "user/fragments/picHandleLink" .OwnerDid }} 69 - </span> 70 71 - <span class="before:content-['ยท']"> 72 - {{ template "repo/fragments/time" .Created }} 73 - </span> 74 75 - <span class="before:content-['ยท']"> 76 - {{ $s := "s" }} 77 - {{ if eq .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> 83 </div> 84 - {{ end }} 85 - </div> 86 - 87 - {{ block "pagination" . }} {{ end }} 88 - 89 {{ end }} 90 91 {{ define "pagination" }}
··· 37 {{ end }} 38 39 {{ define "repoAfter" }} 40 + <div class="flex flex-col gap-2 mt-2"> 41 + {{ range .Issues }} 42 + <div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700"> 43 + <div class="pb-2"> 44 + <a 45 + href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" 46 + class="no-underline hover:underline" 47 + > 48 + {{ .Title | description }} 49 + <span class="text-gray-500">#{{ .IssueId }}</span> 50 + </a> 51 + </div> 52 + <p class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 53 + {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 54 + {{ $icon := "ban" }} 55 + {{ $state := "closed" }} 56 + {{ if .Open }} 57 + {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 58 + {{ $icon = "circle-dot" }} 59 + {{ $state = "open" }} 60 + {{ end }} 61 62 + <span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm"> 63 + {{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }} 64 + <span class="text-white dark:text-white">{{ $state }}</span> 65 + </span> 66 67 + <span class="ml-1"> 68 + {{ template "user/fragments/picHandleLink" .Did }} 69 + </span> 70 71 + <span class="before:content-['ยท']"> 72 + {{ template "repo/fragments/time" .Created }} 73 + </span> 74 75 + <span class="before:content-['ยท']"> 76 + {{ $s := "s" }} 77 + {{ if eq (len .Comments) 1 }} 78 + {{ $s = "" }} 79 + {{ end }} 80 + <a href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ len .Comments }} comment{{$s}}</a> 81 + </span> 82 + </p> 83 + </div> 84 + {{ end }} 85 </div> 86 + {{ block "pagination" . }} {{ end }} 87 {{ end }} 88 89 {{ define "pagination" }}
+1 -33
appview/pages/templates/repo/issues/new.html
··· 1 {{ define "title" }}new issue &middot; {{ .RepoInfo.FullName }}{{ end }} 2 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> 37 {{ end }}
··· 1 {{ define "title" }}new issue &middot; {{ .RepoInfo.FullName }}{{ end }} 2 3 {{ define "repoContent" }} 4 + {{ template "repo/issues/fragments/putIssue" . }} 5 {{ end }}
+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
··· 49 class="mr-2" 50 id="domain-{{ . }}" 51 /> 52 - <span class="dark:text-white">{{ . }}</span> 53 </div> 54 {{ else }} 55 <p class="dark:text-white">No knots available.</p>
··· 49 class="mr-2" 50 id="domain-{{ . }}" 51 /> 52 + <label for="domain-{{ . }}" class="dark:text-white">{{ . }}</label> 53 </div> 54 {{ else }} 55 <p class="dark:text-white">No knots available.</p>
+2 -2
appview/pages/templates/repo/pulls/fragments/pullCompareForks.html
··· 19 > 20 <option disabled selected>select a fork</option> 21 {{ range .Forks }} 22 - <option value="{{ .Name }}" {{ if eq .Name $.Selected }}selected{{ end }} class="py-1"> 23 - {{ .Name }} 24 </option> 25 {{ end }} 26 </select>
··· 19 > 20 <option disabled selected>select a fork</option> 21 {{ range .Forks }} 22 + <option value="{{ .Did }}/{{ .Name }}" {{ if eq .Name $.Selected }}selected{{ end }} class="py-1"> 23 + {{ .Did | resolve }}/{{ .Name }} 24 </option> 25 {{ end }} 26 </select>
+1 -1
appview/pages/templates/repo/pulls/fragments/pullHeader.html
··· 17 {{ $icon = "git-merge" }} 18 {{ end }} 19 20 - {{ $owner := resolve .Pull.OwnerDid }} 21 <section class="mt-2"> 22 <div class="flex items-center gap-2"> 23 <div ··· 45 <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 {{ if .Pull.IsForkBased }} 47 {{ if .Pull.PullSource.Repo }} 48 <a href="/{{ $owner }}/{{ .Pull.PullSource.Repo.Name }}" class="no-underline hover:underline">{{ $owner }}/{{ .Pull.PullSource.Repo.Name }}</a>: 49 {{- else -}} 50 <span class="italic">[deleted fork]</span>
··· 17 {{ $icon = "git-merge" }} 18 {{ end }} 19 20 <section class="mt-2"> 21 <div class="flex items-center gap-2"> 22 <div ··· 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"> 45 {{ if .Pull.IsForkBased }} 46 {{ if .Pull.PullSource.Repo }} 47 + {{ $owner := resolve .Pull.PullSource.Repo.Did }} 48 <a href="/{{ $owner }}/{{ .Pull.PullSource.Repo.Name }}" class="no-underline hover:underline">{{ $owner }}/{{ .Pull.PullSource.Repo.Name }}</a>: 49 {{- else -}} 50 <span class="italic">[deleted fork]</span>
+1 -1
appview/pages/templates/repo/pulls/fragments/pullStack.html
··· 52 </div> 53 {{ end }} 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) }} 56 </div> 57 </div> 58 </a>
··· 52 </div> 53 {{ end }} 54 <div class="{{ if not $isCurrent }} pl-6 {{ end }} flex-grow min-w-0 w-full py-2"> 55 + {{ template "repo/pulls/fragments/summarizedPullHeader" (list $pull $pipeline) }} 56 </div> 57 </div> 58 </a>
+1 -1
appview/pages/templates/repo/pulls/fragments/summarizedPullHeader.html
··· 1 - {{ define "repo/pulls/fragments/summarizedHeader" }} 2 {{ $pull := index . 0 }} 3 {{ $pipeline := index . 1 }} 4 {{ with $pull }}
··· 1 + {{ define "repo/pulls/fragments/summarizedPullHeader" }} 2 {{ $pull := index . 0 }} 3 {{ $pipeline := index . 1 }} 4 {{ with $pull }}
+2 -2
appview/pages/templates/repo/pulls/interdiff.html
··· 30 31 {{ define "topbarLayout" }} 32 <header class="px-1 col-span-full" style="z-index: 20;"> 33 - {{ template "layouts/topbar" . }} 34 </header> 35 {{ end }} 36 ··· 55 56 {{ define "footerLayout" }} 57 <footer class="px-1 col-span-full mt-12"> 58 - {{ template "layouts/footer" . }} 59 </footer> 60 {{ end }} 61
··· 30 31 {{ define "topbarLayout" }} 32 <header class="px-1 col-span-full" style="z-index: 20;"> 33 + {{ template "layouts/fragments/topbar" . }} 34 </header> 35 {{ end }} 36 ··· 55 56 {{ define "footerLayout" }} 57 <footer class="px-1 col-span-full mt-12"> 58 + {{ template "layouts/fragments/footer" . }} 59 </footer> 60 {{ end }} 61
+2 -2
appview/pages/templates/repo/pulls/patch.html
··· 36 37 {{ define "topbarLayout" }} 38 <header class="px-1 col-span-full" style="z-index: 20;"> 39 - {{ template "layouts/topbar" . }} 40 </header> 41 {{ end }} 42 ··· 61 62 {{ define "footerLayout" }} 63 <footer class="px-1 col-span-full mt-12"> 64 - {{ template "layouts/footer" . }} 65 </footer> 66 {{ end }} 67
··· 36 37 {{ define "topbarLayout" }} 38 <header class="px-1 col-span-full" style="z-index: 20;"> 39 + {{ template "layouts/fragments/topbar" . }} 40 </header> 41 {{ end }} 42 ··· 61 62 {{ define "footerLayout" }} 63 <footer class="px-1 col-span-full mt-12"> 64 + {{ template "layouts/fragments/footer" . }} 65 </footer> 66 {{ end }} 67
+1 -1
appview/pages/templates/repo/pulls/pulls.html
··· 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 <div class="flex gap-2 items-center px-6"> 146 <div class="flex-grow min-w-0 w-full py-2"> 147 - {{ template "repo/pulls/fragments/summarizedHeader" (list $pull $pipeline) }} 148 </div> 149 </div> 150 </a>
··· 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 <div class="flex gap-2 items-center px-6"> 146 <div class="flex-grow min-w-0 w-full py-2"> 147 + {{ template "repo/pulls/fragments/summarizedPullHeader" (list $pull $pipeline) }} 148 </div> 149 </div> 150 </a>
+6 -1
appview/pages/templates/spindles/fragments/spindleListing.html
··· 30 {{ define "spindleRightSide" }} 31 <div id="right-side" class="flex gap-2"> 32 {{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2 text-sm" }} 33 - {{ if .Verified }} 34 <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 {{ template "spindles/fragments/addMemberModal" . }} 36 {{ else }} 37 <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 {{ block "spindleRetryButton" . }} {{ end }} 39 {{ end }} 40 {{ block "spindleDeleteButton" . }} {{ end }} 41 </div> 42 {{ end }}
··· 30 {{ define "spindleRightSide" }} 31 <div id="right-side" class="flex gap-2"> 32 {{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2 text-sm" }} 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 }} 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> 39 {{ template "spindles/fragments/addMemberModal" . }} 40 {{ else }} 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> 42 {{ block "spindleRetryButton" . }} {{ end }} 43 {{ end }} 44 + 45 {{ block "spindleDeleteButton" . }} {{ end }} 46 </div> 47 {{ end }}
+10 -9
appview/pages/templates/spindles/index.html
··· 1 {{ define "title" }}spindles{{ end }} 2 3 {{ define "content" }} 4 - <div class="px-6 py-4"> 5 - <h1 class="text-xl font-bold dark:text-white">Spindles</h1> 6 </div> 7 8 <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> ··· 15 {{ end }} 16 17 {{ 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> 24 </p> 25 - </section> 26 {{ end }} 27 28 {{ define "list" }}
··· 1 {{ define "title" }}spindles{{ end }} 2 3 {{ define "content" }} 4 + <div class="px-6 py-4 flex items-center justify-between gap-4 align-bottom"> 5 + <h1 class="text-xl font-bold dark:text-white">Spindles</h1> 6 + <span class="flex items-center gap-1"> 7 + {{ i "book" "w-3 h-3" }} 8 + <a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/spindle/hosting.md">docs</a> 9 + </span> 10 </div> 11 12 <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> ··· 19 {{ end }} 20 21 {{ define "about" }} 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. 25 </p> 26 + </section> 27 {{ end }} 28 29 {{ define "list" }}
-4
appview/pages/templates/strings/put.html
··· 1 {{ define "title" }}publish a new string{{ end }} 2 3 - {{ define "topbar" }} 4 - {{ template "layouts/topbar" $ }} 5 - {{ end }} 6 - 7 {{ define "content" }} 8 <div class="px-6 py-2 mb-4"> 9 {{ if eq .Action "new" }}
··· 1 {{ define "title" }}publish a new string{{ end }} 2 3 {{ define "content" }} 4 <div class="px-6 py-2 mb-4"> 5 {{ if eq .Action "new" }}
-4
appview/pages/templates/strings/string.html
··· 8 <meta property="og:description" content="{{ .String.Description }}" /> 9 {{ end }} 10 11 - {{ define "topbar" }} 12 - {{ template "layouts/topbar" $ }} 13 - {{ end }} 14 - 15 {{ define "content" }} 16 {{ $ownerId := didOrHandle .Owner.DID.String .Owner.Handle.String }} 17 <section id="string-header" class="mb-4 py-2 px-6 dark:text-white">
··· 8 <meta property="og:description" content="{{ .String.Description }}" /> 9 {{ end }} 10 11 {{ define "content" }} 12 {{ $ownerId := didOrHandle .Owner.DID.String .Owner.Handle.String }} 13 <section id="string-header" class="mb-4 py-2 px-6 dark:text-white">
-4
appview/pages/templates/strings/timeline.html
··· 1 {{ define "title" }} all strings {{ end }} 2 3 - {{ define "topbar" }} 4 - {{ template "layouts/topbar" $ }} 5 - {{ end }} 6 - 7 {{ define "content" }} 8 {{ block "timeline" $ }}{{ end }} 9 {{ end }}
··· 1 {{ define "title" }} all strings {{ end }} 2 3 {{ define "content" }} 4 {{ block "timeline" $ }}{{ end }} 5 {{ end }}
+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
···
··· 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
···
··· 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
···
··· 1 + {{ define "title" }}tangled &middot; 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>&mdash;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>&mdash;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
··· 8 {{ end }} 9 10 {{ define "content" }} 11 - {{ if .LoggedInUser }} 12 - {{ else }} 13 - {{ block "hero" $ }}{{ end }} 14 - {{ end }} 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> 183 {{ end }}
··· 8 {{ end }} 9 10 {{ define "content" }} 11 + {{ if .LoggedInUser }} 12 + {{ else }} 13 + {{ template "timeline/fragments/hero" . }} 14 + {{ end }} 15 16 + {{ template "timeline/fragments/trending" . }} 17 + {{ template "timeline/fragments/timeline" . }} 18 {{ end }}
+2 -4
appview/pages/templates/user/completeSignup.html
··· 29 </head> 30 <body class="flex items-center justify-center min-h-screen"> 31 <main class="max-w-md px-6 -mt-4"> 32 - <h1 33 - class="text-center text-2xl font-semibold italic dark:text-white" 34 - > 35 - tangled 36 </h1> 37 <h2 class="text-center text-xl italic dark:text-white"> 38 tightly-knit social coding.
··· 29 </head> 30 <body class="flex items-center justify-center min-h-screen"> 31 <main class="max-w-md px-6 -mt-4"> 32 + <h1 class="flex place-content-center text-2xl font-semibold italic dark:text-white" > 33 + {{ template "fragments/logotype" }} 34 </h1> 35 <h2 class="text-center text-xl italic dark:text-white"> 36 tightly-knit social coding.
+4 -16
appview/pages/templates/user/followers.html
··· 1 {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท followers {{ end }} 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> 19 {{ end }} 20 21 {{ define "followers" }}
··· 1 {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท followers {{ end }} 2 3 + {{ define "profileContent" }} 4 + <div id="all-followers" class="md:col-span-8 order-2 md:order-2"> 5 + {{ block "followers" . }}{{ end }} 6 + </div> 7 {{ end }} 8 9 {{ define "followers" }}
+4 -16
appview/pages/templates/user/following.html
··· 1 {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท following {{ end }} 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> 19 {{ end }} 20 21 {{ define "following" }}
··· 1 {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท following {{ end }} 2 3 + {{ define "profileContent" }} 4 + <div id="all-following" class="md:col-span-8 order-2 md:order-2"> 5 + {{ block "following" . }}{{ end }} 6 + </div> 7 {{ end }} 8 9 {{ define "following" }}
+1 -1
appview/pages/templates/user/fragments/editBio.html
··· 13 <label class="m-0 p-0" for="description">bio</label> 14 <textarea 15 type="text" 16 - class="py-1 px-1 w-full" 17 name="description" 18 rows="3" 19 placeholder="write a bio">{{ $description }}</textarea>
··· 13 <label class="m-0 p-0" for="description">bio</label> 14 <textarea 15 type="text" 16 + class="p-2 w-full" 17 name="description" 18 rows="3" 19 placeholder="write a bio">{{ $description }}</textarea>
+1 -1
appview/pages/templates/user/fragments/picHandle.html
··· 1 {{ define "user/fragments/picHandle" }} 2 <img 3 src="{{ tinyAvatar . }}" 4 - alt="{{ . }}" 5 class="rounded-full h-6 w-6 mr-1 border border-gray-300 dark:border-gray-700" 6 /> 7 {{ . | truncateAt30 }}
··· 1 {{ define "user/fragments/picHandle" }} 2 <img 3 src="{{ tinyAvatar . }}" 4 + alt="" 5 class="rounded-full h-6 w-6 mr-1 border border-gray-300 dark:border-gray-700" 6 /> 7 {{ . | truncateAt30 }}
+2 -4
appview/pages/templates/user/fragments/profileCard.html
··· 1 {{ define "user/fragments/profileCard" }} 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 <div class="grid grid-cols-3 md:grid-cols-1 gap-1 items-center"> 5 <div id="avatar" class="col-span-1 flex justify-center items-center"> 6 <div class="w-3/4 aspect-square relative"> ··· 85 <div id="update-profile" class="text-red-400 dark:text-red-500"></div> 86 </div> 87 </div> 88 - </div> 89 {{ end }} 90 91 {{ define "followerFollowing" }} ··· 94 {{ with $root }} 95 <div class="flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm"> 96 <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 97 - <span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .FollowersCount }} followers</a></span> 98 <span class="select-none after:content-['ยท']"></span> 99 - <span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .FollowingCount }} following</a></span> 100 </div> 101 {{ end }} 102 {{ end }}
··· 1 {{ define "user/fragments/profileCard" }} 2 {{ $userIdent := didOrHandle .UserDid .UserHandle }} 3 <div class="grid grid-cols-3 md:grid-cols-1 gap-1 items-center"> 4 <div id="avatar" class="col-span-1 flex justify-center items-center"> 5 <div class="w-3/4 aspect-square relative"> ··· 84 <div id="update-profile" class="text-red-400 dark:text-red-500"></div> 85 </div> 86 </div> 87 {{ end }} 88 89 {{ define "followerFollowing" }} ··· 92 {{ with $root }} 93 <div class="flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm"> 94 <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 95 + <span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .Stats.FollowersCount }} followers</a></span> 96 <span class="select-none after:content-['ยท']"></span> 97 + <span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .Stats.FollowingCount }} following</a></span> 98 </div> 99 {{ end }} 100 {{ end }}
+1 -2
appview/pages/templates/user/fragments/repoCard.html
··· 36 <div class="text-gray-400 text-sm font-mono inline-flex gap-4 mt-auto"> 37 {{ with .Language }} 38 <div class="flex gap-2 items-center text-sm"> 39 - <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> 41 <span>{{ . }}</span> 42 </div> 43 {{ end }}
··· 36 <div class="text-gray-400 text-sm font-mono inline-flex gap-4 mt-auto"> 37 {{ with .Language }} 38 <div class="flex gap-2 items-center text-sm"> 39 + {{ template "repo/fragments/languageBall" . }} 40 <span>{{ . }}</span> 41 </div> 42 {{ end }}
+2 -2
appview/pages/templates/user/login.html
··· 13 </head> 14 <body class="flex items-center justify-center min-h-screen"> 15 <main class="max-w-md px-6 -mt-4"> 16 - <h1 class="text-center text-2xl font-semibold italic dark:text-white" > 17 - tangled 18 </h1> 19 <h2 class="text-center text-xl italic dark:text-white"> 20 tightly-knit social coding.
··· 13 </head> 14 <body class="flex items-center justify-center min-h-screen"> 15 <main class="max-w-md px-6 -mt-4"> 16 + <h1 class="flex place-content-center text-2xl font-semibold italic dark:text-white" > 17 + {{ template "fragments/logotype" }} 18 </h1> 19 <h2 class="text-center text-xl italic dark:text-white"> 20 tightly-knit social coding.
+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
··· 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
··· 1 {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท repos {{ end }} 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> 19 {{ end }} 20 21 {{ define "ownRepos" }} 22 - <p class="text-sm font-bold p-2 dark:text-white">ALL REPOSITORIES</p> 23 <div id="repos" class="grid grid-cols-1 gap-4 mb-6"> 24 {{ range .Repos }} 25 - {{ template "user/fragments/repoCard" (list $ . false) }} 26 {{ else }} 27 <p class="px-6 dark:text-white">This user does not have any repos yet.</p> 28 {{ end }}
··· 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 "ownRepos" . }}{{ end }} 6 + </div> 7 {{ end }} 8 9 {{ define "ownRepos" }} 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 $ . false) }} 14 + </div> 15 {{ else }} 16 <p class="px-6 dark:text-white">This user does not have any repos yet.</p> 17 {{ end }}
+2 -2
appview/pages/templates/user/settings/emails.html
··· 4 <div class="p-6"> 5 <p class="text-xl font-bold dark:text-white">Settings</p> 6 </div> 7 - <div class="bg-white dark:bg-gray-800"> 8 - <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6 p-6"> 9 <div class="col-span-1"> 10 {{ template "user/settings/fragments/sidebar" . }} 11 </div>
··· 4 <div class="p-6"> 5 <p class="text-xl font-bold dark:text-white">Settings</p> 6 </div> 7 + <div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 8 + <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6"> 9 <div class="col-span-1"> 10 {{ template "user/settings/fragments/sidebar" . }} 11 </div>
+2 -2
appview/pages/templates/user/settings/keys.html
··· 4 <div class="p-6"> 5 <p class="text-xl font-bold dark:text-white">Settings</p> 6 </div> 7 - <div class="bg-white dark:bg-gray-800"> 8 - <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6 p-6"> 9 <div class="col-span-1"> 10 {{ template "user/settings/fragments/sidebar" . }} 11 </div>
··· 4 <div class="p-6"> 5 <p class="text-xl font-bold dark:text-white">Settings</p> 6 </div> 7 + <div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 8 + <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6"> 9 <div class="col-span-1"> 10 {{ template "user/settings/fragments/sidebar" . }} 11 </div>
+2 -2
appview/pages/templates/user/settings/profile.html
··· 4 <div class="p-6"> 5 <p class="text-xl font-bold dark:text-white">Settings</p> 6 </div> 7 - <div class="bg-white dark:bg-gray-800"> 8 - <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6 p-6"> 9 <div class="col-span-1"> 10 {{ template "user/settings/fragments/sidebar" . }} 11 </div>
··· 4 <div class="p-6"> 5 <p class="text-xl font-bold dark:text-white">Settings</p> 6 </div> 7 + <div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 8 + <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6"> 9 <div class="col-span-1"> 10 {{ template "user/settings/fragments/sidebar" . }} 11 </div>
+3 -1
appview/pages/templates/user/signup.html
··· 13 </head> 14 <body class="flex items-center justify-center min-h-screen"> 15 <main class="max-w-md px-6 -mt-4"> 16 - <h1 class="text-center text-2xl font-semibold italic dark:text-white" >tangled</h1> 17 <h2 class="text-center text-xl italic dark:text-white">tightly-knit social coding.</h2> 18 <form 19 class="mt-4 max-w-sm mx-auto"
··· 13 </head> 14 <body class="flex items-center justify-center min-h-screen"> 15 <main class="max-w-md px-6 -mt-4"> 16 + <h1 class="flex place-content-center text-2xl font-semibold italic dark:text-white" > 17 + {{ template "fragments/logotype" }} 18 + </h1> 19 <h2 class="text-center text-xl italic dark:text-white">tightly-knit social coding.</h2> 20 <form 21 class="mt-4 max-w-sm mx-auto"
+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
···
··· 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
··· 58 59 func (n *posthogNotifier) NewIssue(ctx context.Context, issue *db.Issue) { 60 err := n.client.Enqueue(posthog.Capture{ 61 - DistinctId: issue.OwnerDid, 62 Event: "new_issue", 63 Properties: posthog.Properties{ 64 "repo_at": issue.RepoAt.String(),
··· 58 59 func (n *posthogNotifier) NewIssue(ctx context.Context, issue *db.Issue) { 60 err := n.client.Enqueue(posthog.Capture{ 61 + DistinctId: issue.Did, 62 Event: "new_issue", 63 Properties: posthog.Properties{ 64 "repo_at": issue.RepoAt.String(),
+252 -104
appview/pulls/pulls.go
··· 2 3 import ( 4 "database/sql" 5 "errors" 6 "fmt" 7 "log" ··· 21 "tangled.sh/tangled.sh/core/appview/reporesolver" 22 "tangled.sh/tangled.sh/core/appview/xrpcclient" 23 "tangled.sh/tangled.sh/core/idresolver" 24 - "tangled.sh/tangled.sh/core/knotclient" 25 "tangled.sh/tangled.sh/core/patchutil" 26 "tangled.sh/tangled.sh/core/tid" 27 "tangled.sh/tangled.sh/core/types" ··· 99 mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 100 resubmitResult := pages.Unknown 101 if user.Did == pull.OwnerDid { 102 - resubmitResult = s.resubmitCheck(f, pull, stack) 103 } 104 105 s.pages.PullActionsFragment(w, pages.PullActionsParams{ ··· 154 mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 155 resubmitResult := pages.Unknown 156 if user != nil && user.Did == pull.OwnerDid { 157 - resubmitResult = s.resubmitCheck(f, pull, stack) 158 } 159 160 repoInfo := f.RepoInfo(user) ··· 282 return result 283 } 284 285 - func (s *Pulls) resubmitCheck(f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) pages.ResubmitResult { 286 if pull.State == db.PullMerged || pull.State == db.PullDeleted || pull.PullSource == nil { 287 return pages.Unknown 288 } ··· 307 repoName = f.Name 308 } 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 314 } 315 316 - result, err := us.Branch(ownerDid, repoName, pull.PullSource.Branch) 317 if err != nil { 318 log.Println("failed to reach knotserver", err) 319 return pages.Unknown 320 } 321 322 latestSourceRev := pull.Submissions[pull.LastRoundNumber()].SourceRev 323 324 if pull.IsStacked() && stack != nil { ··· 326 latestSourceRev = top.Submissions[top.LastRoundNumber()].SourceRev 327 } 328 329 - if latestSourceRev != result.Branch.Hash { 330 return pages.ShouldResubmit 331 } 332 ··· 605 defer tx.Rollback() 606 607 createdAt := time.Now().Format(time.RFC3339) 608 - ownerDid := user.Did 609 610 pullAt, err := db.GetPullAt(s.db, f.RepoAt(), pull.PullId) 611 if err != nil { ··· 614 return 615 } 616 617 - atUri := f.RepoAt().String() 618 client, err := s.oauth.AuthorizedClient(r) 619 if err != nil { 620 log.Println("failed to get authorized client", err) ··· 627 Rkey: tid.TID(), 628 Record: &lexutil.LexiconTypeDecoder{ 629 Val: &tangled.RepoPullComment{ 630 - Repo: &atUri, 631 Pull: string(pullAt), 632 - Owner: &ownerDid, 633 Body: body, 634 CreatedAt: createdAt, 635 }, ··· 682 683 switch r.Method { 684 case http.MethodGet: 685 - us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 686 if err != nil { 687 - log.Printf("failed to create unsigned client for %s", f.Knot) 688 - s.pages.Error503(w) 689 return 690 } 691 692 - result, err := us.Branches(f.OwnerDid(), f.Name) 693 - if err != nil { 694 - log.Println("failed to fetch branches", err) 695 return 696 } 697 ··· 756 return 757 } 758 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 - } 765 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 771 } 772 773 if !caps.PullRequests.FormatPatch { 774 s.pages.Notice(w, "pull", "This knot doesn't support format-patch. Unfortunately, there is no fallback for now.") ··· 810 sourceBranch string, 811 isStacked bool, 812 ) { 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 819 } 820 821 - comparison, err := ksClient.Compare(f.OwnerDid(), f.Name, targetBranch, sourceBranch) 822 if err != nil { 823 log.Println("failed to compare", err) 824 s.pages.Notice(w, "pull", err.Error()) 825 return 826 } 827 ··· 854 } 855 856 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) 858 if errors.Is(err, sql.ErrNoRows) { 859 s.pages.Notice(w, "pull", "No such fork.") 860 return ··· 870 oauth.WithLxm(tangled.RepoHiddenRefNSID), 871 oauth.WithDev(s.config.Core.Dev), 872 ) 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 886 resp, err := tangled.RepoHiddenRef( 887 r.Context(), ··· 912 // hiddenRef: hidden/feature-1/main (on repo-fork) 913 // targetBranch: main (on repo-1) 914 // sourceBranch: feature-1 (on repo-fork) 915 - comparison, err := us.Compare(user.Did, fork.Name, hiddenRef, sourceBranch) 916 if err != nil { 917 log.Println("failed to compare across branches", err) 918 s.pages.Notice(w, "pull", err.Error()) 919 return 920 } 921 ··· 1038 Rkey: rkey, 1039 Record: &lexutil.LexiconTypeDecoder{ 1040 Val: &tangled.RepoPull{ 1041 - Title: title, 1042 - PullId: int64(pullId), 1043 - TargetRepo: string(f.RepoAt()), 1044 - TargetBranch: targetBranch, 1045 - Patch: patch, 1046 - Source: recordPullSource, 1047 }, 1048 }, 1049 }) ··· 1211 return 1212 } 1213 1214 - us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 1215 if err != nil { 1216 - log.Printf("failed to create unsigned client for %s", f.Knot) 1217 - s.pages.Error503(w) 1218 return 1219 } 1220 1221 - result, err := us.Branches(f.OwnerDid(), f.Name) 1222 - if err != nil { 1223 - log.Println("failed to reach knotserver", err) 1224 return 1225 } 1226 ··· 1274 } 1275 1276 forkVal := r.URL.Query().Get("fork") 1277 - 1278 // fork repo 1279 - repo, err := db.GetRepo(s.db, user.Did, forkVal) 1280 if err != nil { 1281 log.Println("failed to get repo", user.Did, forkVal) 1282 return 1283 } 1284 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 1290 } 1291 1292 - sourceResult, err := sourceBranchesClient.Branches(user.Did, repo.Name) 1293 if err != nil { 1294 - log.Println("failed to reach knotserver for source branches", err) 1295 return 1296 } 1297 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) 1301 s.pages.Error503(w) 1302 return 1303 } 1304 1305 - targetResult, err := targetBranchesClient.Branches(f.OwnerDid(), f.Name) 1306 if err != nil { 1307 - log.Println("failed to reach knotserver for target branches", err) 1308 return 1309 } 1310 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) 1314 }) 1315 1316 s.pages.PullCompareForkBranchesFragment(w, pages.PullCompareForkBranchesParams{ 1317 RepoInfo: f.RepoInfo(user), 1318 - SourceBranches: sourceBranches, 1319 - TargetBranches: targetResult.Branches, 1320 }) 1321 } 1322 ··· 1411 return 1412 } 1413 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 1419 } 1420 1421 - comparison, err := ksClient.Compare(f.OwnerDid(), f.Name, pull.TargetBranch, pull.PullSource.Branch) 1422 if err != nil { 1423 log.Printf("compare request failed: %s", err) 1424 s.pages.Notice(w, "resubmit-error", err.Error()) 1425 return 1426 } 1427 ··· 1461 } 1462 1463 // extract patch by performing compare 1464 - ksClient, err := knotclient.NewUnsignedClient(forkRepo.Knot, s.config.Core.Dev) 1465 if err != nil { 1466 - log.Printf("failed to create client for %s: %s", forkRepo.Knot, err) 1467 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1468 return 1469 } ··· 1499 return 1500 } 1501 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 - } 1509 1510 sourceRev := comparison.Rev2 1511 patch := comparison.Patch ··· 1609 SwapRecord: ex.Cid, 1610 Record: &lexutil.LexiconTypeDecoder{ 1611 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, 1618 }, 1619 }, 1620 })
··· 2 3 import ( 4 "database/sql" 5 + "encoding/json" 6 "errors" 7 "fmt" 8 "log" ··· 22 "tangled.sh/tangled.sh/core/appview/reporesolver" 23 "tangled.sh/tangled.sh/core/appview/xrpcclient" 24 "tangled.sh/tangled.sh/core/idresolver" 25 "tangled.sh/tangled.sh/core/patchutil" 26 "tangled.sh/tangled.sh/core/tid" 27 "tangled.sh/tangled.sh/core/types" ··· 99 mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 100 resubmitResult := pages.Unknown 101 if user.Did == pull.OwnerDid { 102 + resubmitResult = s.resubmitCheck(r, f, pull, stack) 103 } 104 105 s.pages.PullActionsFragment(w, pages.PullActionsParams{ ··· 154 mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 155 resubmitResult := pages.Unknown 156 if user != nil && user.Did == pull.OwnerDid { 157 + resubmitResult = s.resubmitCheck(r, f, pull, stack) 158 } 159 160 repoInfo := f.RepoInfo(user) ··· 282 return result 283 } 284 285 + func (s *Pulls) resubmitCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) pages.ResubmitResult { 286 if pull.State == db.PullMerged || pull.State == db.PullDeleted || pull.PullSource == nil { 287 return pages.Unknown 288 } ··· 307 repoName = f.Name 308 } 309 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, 317 } 318 319 + repo := fmt.Sprintf("%s/%s", ownerDid, repoName) 320 + branchResp, err := tangled.RepoBranch(r.Context(), xrpcc, pull.PullSource.Branch, repo) 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 + } 326 log.Println("failed to reach knotserver", err) 327 return pages.Unknown 328 } 329 330 + targetBranch := branchResp 331 + 332 latestSourceRev := pull.Submissions[pull.LastRoundNumber()].SourceRev 333 334 if pull.IsStacked() && stack != nil { ··· 336 latestSourceRev = top.Submissions[top.LastRoundNumber()].SourceRev 337 } 338 339 + if latestSourceRev != targetBranch.Hash { 340 return pages.ShouldResubmit 341 } 342 ··· 615 defer tx.Rollback() 616 617 createdAt := time.Now().Format(time.RFC3339) 618 619 pullAt, err := db.GetPullAt(s.db, f.RepoAt(), pull.PullId) 620 if err != nil { ··· 623 return 624 } 625 626 client, err := s.oauth.AuthorizedClient(r) 627 if err != nil { 628 log.Println("failed to get authorized client", err) ··· 635 Rkey: tid.TID(), 636 Record: &lexutil.LexiconTypeDecoder{ 637 Val: &tangled.RepoPullComment{ 638 Pull: string(pullAt), 639 Body: body, 640 CreatedAt: createdAt, 641 }, ··· 688 689 switch r.Method { 690 case http.MethodGet: 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) 702 if err != nil { 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) 709 return 710 } 711 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) 716 return 717 } 718 ··· 777 return 778 } 779 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 + // } 786 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 + }, 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 + // } 815 816 if !caps.PullRequests.FormatPatch { 817 s.pages.Notice(w, "pull", "This knot doesn't support format-patch. Unfortunately, there is no fallback for now.") ··· 853 sourceBranch string, 854 isStacked bool, 855 ) { 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, 863 } 864 865 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 866 + xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, targetBranch, sourceBranch) 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 + } 873 log.Println("failed to compare", err) 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.") 882 return 883 } 884 ··· 911 } 912 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) { 914 + repoString := strings.SplitN(forkRepo, "/", 2) 915 + forkOwnerDid := repoString[0] 916 + repoName := repoString[1] 917 + fork, err := db.GetForkByDid(s.db, forkOwnerDid, repoName) 918 if errors.Is(err, sql.ErrNoRows) { 919 s.pages.Notice(w, "pull", "No such fork.") 920 return ··· 930 oauth.WithLxm(tangled.RepoHiddenRefNSID), 931 oauth.WithDev(s.config.Core.Dev), 932 ) 933 934 resp, err := tangled.RepoHiddenRef( 935 r.Context(), ··· 960 // hiddenRef: hidden/feature-1/main (on repo-fork) 961 // targetBranch: main (on repo-1) 962 // sourceBranch: feature-1 (on repo-fork) 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) 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 + } 980 log.Println("failed to compare across branches", err) 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.") 989 return 990 } 991 ··· 1108 Rkey: rkey, 1109 Record: &lexutil.LexiconTypeDecoder{ 1110 Val: &tangled.RepoPull{ 1111 + Title: title, 1112 + Target: &tangled.RepoPull_Target{ 1113 + Repo: string(f.RepoAt()), 1114 + Branch: targetBranch, 1115 + }, 1116 + Patch: patch, 1117 + Source: recordPullSource, 1118 }, 1119 }, 1120 }) ··· 1282 return 1283 } 1284 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) 1296 if err != nil { 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) 1303 return 1304 } 1305 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) 1310 return 1311 } 1312 ··· 1360 } 1361 1362 forkVal := r.URL.Query().Get("fork") 1363 + repoString := strings.SplitN(forkVal, "/", 2) 1364 + forkOwnerDid := repoString[0] 1365 + forkName := repoString[1] 1366 // fork repo 1367 + repo, err := db.GetRepo(s.db, forkOwnerDid, forkName) 1368 if err != nil { 1369 log.Println("failed to get repo", user.Did, forkVal) 1370 return 1371 } 1372 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, 1380 } 1381 1382 + sourceRepo := fmt.Sprintf("%s/%s", forkOwnerDid, repo.Name) 1383 + sourceXrpcBytes, err := tangled.RepoBranches(r.Context(), sourceXrpcc, "", 0, sourceRepo) 1384 if err != nil { 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) 1391 return 1392 } 1393 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) 1398 s.pages.Error503(w) 1399 return 1400 } 1401 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) 1413 if err != nil { 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) 1420 return 1421 } 1422 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) 1433 }) 1434 1435 s.pages.PullCompareForkBranchesFragment(w, pages.PullCompareForkBranchesParams{ 1436 RepoInfo: f.RepoInfo(user), 1437 + SourceBranches: sourceBranches.Branches, 1438 + TargetBranches: targetBranches.Branches, 1439 }) 1440 } 1441 ··· 1530 return 1531 } 1532 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, 1540 } 1541 1542 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1543 + xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, pull.TargetBranch, pull.PullSource.Branch) 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 + } 1550 log.Printf("compare request failed: %s", err) 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.") 1559 return 1560 } 1561 ··· 1595 } 1596 1597 // extract patch by performing compare 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) 1605 if err != nil { 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) 1619 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1620 return 1621 } ··· 1651 return 1652 } 1653 1654 + // Use the fork comparison we already made 1655 + comparison := forkComparison 1656 1657 sourceRev := comparison.Rev2 1658 patch := comparison.Patch ··· 1756 SwapRecord: ex.Cid, 1757 Record: &lexutil.LexiconTypeDecoder{ 1758 Val: &tangled.RepoPull{ 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, 1766 }, 1767 }, 1768 })
+26 -8
appview/repo/artifact.go
··· 1 package repo 2 3 import ( 4 "fmt" 5 "log" 6 "net/http" ··· 9 10 comatproto "github.com/bluesky-social/indigo/api/atproto" 11 lexutil "github.com/bluesky-social/indigo/lex/util" 12 "github.com/dustin/go-humanize" 13 "github.com/go-chi/chi/v5" 14 "github.com/go-git/go-git/v5/plumbing" ··· 17 "tangled.sh/tangled.sh/core/appview/db" 18 "tangled.sh/tangled.sh/core/appview/pages" 19 "tangled.sh/tangled.sh/core/appview/reporesolver" 20 - "tangled.sh/tangled.sh/core/knotclient" 21 "tangled.sh/tangled.sh/core/tid" 22 "tangled.sh/tangled.sh/core/types" 23 ) ··· 33 return 34 } 35 36 - tag, err := rp.resolveTag(f, tagParam) 37 if err != nil { 38 log.Println("failed to resolve tag", err) 39 rp.pages.Notice(w, "upload", "failed to upload artifact, error in tag resolution") ··· 140 return 141 } 142 143 - tag, err := rp.resolveTag(f, tagParam) 144 if err != nil { 145 log.Println("failed to resolve tag", err) 146 rp.pages.Notice(w, "upload", "failed to upload artifact, error in tag resolution") ··· 259 w.Write([]byte{}) 260 } 261 262 - func (rp *Repo) resolveTag(f *reporesolver.ResolvedRepo, tagParam string) (*types.TagReference, error) { 263 tagParam, err := url.QueryUnescape(tagParam) 264 if err != nil { 265 return nil, err 266 } 267 268 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 269 - if err != nil { 270 - return nil, err 271 } 272 273 - result, err := us.Tags(f.OwnerDid(), f.Name) 274 if err != nil { 275 log.Println("failed to reach knotserver", err) 276 return nil, err 277 } 278
··· 1 package repo 2 3 import ( 4 + "context" 5 + "encoding/json" 6 "fmt" 7 "log" 8 "net/http" ··· 11 12 comatproto "github.com/bluesky-social/indigo/api/atproto" 13 lexutil "github.com/bluesky-social/indigo/lex/util" 14 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 15 "github.com/dustin/go-humanize" 16 "github.com/go-chi/chi/v5" 17 "github.com/go-git/go-git/v5/plumbing" ··· 20 "tangled.sh/tangled.sh/core/appview/db" 21 "tangled.sh/tangled.sh/core/appview/pages" 22 "tangled.sh/tangled.sh/core/appview/reporesolver" 23 + "tangled.sh/tangled.sh/core/appview/xrpcclient" 24 "tangled.sh/tangled.sh/core/tid" 25 "tangled.sh/tangled.sh/core/types" 26 ) ··· 36 return 37 } 38 39 + tag, err := rp.resolveTag(r.Context(), f, tagParam) 40 if err != nil { 41 log.Println("failed to resolve tag", err) 42 rp.pages.Notice(w, "upload", "failed to upload artifact, error in tag resolution") ··· 143 return 144 } 145 146 + tag, err := rp.resolveTag(r.Context(), f, tagParam) 147 if err != nil { 148 log.Println("failed to resolve tag", err) 149 rp.pages.Notice(w, "upload", "failed to upload artifact, error in tag resolution") ··· 262 w.Write([]byte{}) 263 } 264 265 + func (rp *Repo) resolveTag(ctx context.Context, f *reporesolver.ResolvedRepo, tagParam string) (*types.TagReference, error) { 266 tagParam, err := url.QueryUnescape(tagParam) 267 if err != nil { 268 return nil, err 269 } 270 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, 278 } 279 280 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 281 + xrpcBytes, err := tangled.RepoTags(ctx, xrpcc, "", 0, repo) 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 + } 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) 294 return nil, err 295 } 296
+7 -2
appview/repo/feed.go
··· 9 "time" 10 11 "tangled.sh/tangled.sh/core/appview/db" 12 "tangled.sh/tangled.sh/core/appview/reporesolver" 13 14 "github.com/bluesky-social/indigo/atproto/syntax" ··· 23 return nil, err 24 } 25 26 - issues, err := db.GetIssuesWithLimit(rp.db, feedLimitPerType, db.FilterEq("repo_at", f.RepoAt())) 27 if err != nil { 28 return nil, err 29 } ··· 104 } 105 106 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) 108 if err != nil { 109 return nil, err 110 }
··· 9 "time" 10 11 "tangled.sh/tangled.sh/core/appview/db" 12 + "tangled.sh/tangled.sh/core/appview/pagination" 13 "tangled.sh/tangled.sh/core/appview/reporesolver" 14 15 "github.com/bluesky-social/indigo/atproto/syntax" ··· 24 return nil, err 25 } 26 27 + issues, err := db.GetIssuesPaginated( 28 + rp.db, 29 + pagination.Page{Limit: feedLimitPerType}, 30 + db.FilterEq("repo_at", f.RepoAt()), 31 + ) 32 if err != nil { 33 return nil, err 34 } ··· 109 } 110 111 func (rp *Repo) createIssueItem(ctx context.Context, issue db.Issue, f *reporesolver.ResolvedRepo) (*feeds.Item, error) { 112 + owner, err := rp.idResolver.ResolveIdent(ctx, issue.Did) 113 if err != nil { 114 return nil, err 115 }
+207 -22
appview/repo/index.go
··· 1 package repo 2 3 import ( 4 "log" 5 "net/http" 6 "slices" 7 "sort" 8 "strings" 9 10 "tangled.sh/tangled.sh/core/appview/commitverify" 11 "tangled.sh/tangled.sh/core/appview/db" 12 "tangled.sh/tangled.sh/core/appview/pages" 13 "tangled.sh/tangled.sh/core/appview/reporesolver" 14 - "tangled.sh/tangled.sh/core/knotclient" 15 "tangled.sh/tangled.sh/core/types" 16 17 "github.com/go-chi/chi/v5" ··· 27 return 28 } 29 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 35 } 36 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 42 } 43 44 tagMap := make(map[string][]string) ··· 98 log.Println(err) 99 } 100 101 - user := rp.oauth.GetUser(r) 102 - repoInfo := f.RepoInfo(user) 103 - 104 // TODO: a bit dirty 105 - languageInfo, err := rp.getLanguageInfo(f, us, result.Ref, ref == "") 106 if err != nil { 107 log.Printf("failed to compute language percentages: %s", err) 108 // non-fatal ··· 135 } 136 137 func (rp *Repo) getLanguageInfo( 138 f *reporesolver.ResolvedRepo, 139 - us *knotclient.UnsignedClient, 140 currentRef string, 141 isDefaultRef bool, 142 ) ([]types.RepoLanguageDetails, error) { ··· 148 ) 149 150 if err != nil || langs == nil { 151 - // non-fatal, fetch langs from ks 152 - ls, err := us.RepoLanguages(f.OwnerDid(), f.Name, currentRef) 153 if err != nil { 154 return nil, err 155 } 156 - if ls == nil { 157 return nil, nil 158 } 159 160 - for l, s := range ls.Languages { 161 langs = append(langs, db.RepoLanguage{ 162 RepoAt: f.RepoAt(), 163 Ref: currentRef, 164 IsDefaultRef: isDefaultRef, 165 - Language: l, 166 - Bytes: s, 167 }) 168 } 169 ··· 206 207 return languageStats, nil 208 }
··· 1 package repo 2 3 import ( 4 + "errors" 5 + "fmt" 6 "log" 7 "net/http" 8 "slices" 9 "sort" 10 "strings" 11 + "sync" 12 + "time" 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" 20 "tangled.sh/tangled.sh/core/appview/commitverify" 21 "tangled.sh/tangled.sh/core/appview/db" 22 "tangled.sh/tangled.sh/core/appview/pages" 23 + "tangled.sh/tangled.sh/core/appview/pages/markup" 24 "tangled.sh/tangled.sh/core/appview/reporesolver" 25 + "tangled.sh/tangled.sh/core/appview/xrpcclient" 26 "tangled.sh/tangled.sh/core/types" 27 28 "github.com/go-chi/chi/v5" ··· 38 return 39 } 40 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, 48 } 49 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 + } 69 } 70 71 tagMap := make(map[string][]string) ··· 125 log.Println(err) 126 } 127 128 // TODO: a bit dirty 129 + languageInfo, err := rp.getLanguageInfo(r.Context(), f, xrpcc, result.Ref, ref == "") 130 if err != nil { 131 log.Printf("failed to compute language percentages: %s", err) 132 // non-fatal ··· 159 } 160 161 func (rp *Repo) getLanguageInfo( 162 + ctx context.Context, 163 f *reporesolver.ResolvedRepo, 164 + xrpcc *indigoxrpc.Client, 165 currentRef string, 166 isDefaultRef bool, 167 ) ([]types.RepoLanguageDetails, error) { ··· 173 ) 174 175 if err != nil || langs == nil { 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) 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 + } 184 return nil, err 185 } 186 + 187 + if ls == nil || ls.Languages == nil { 188 return nil, nil 189 } 190 191 + for _, lang := range ls.Languages { 192 langs = append(langs, db.RepoLanguage{ 193 RepoAt: f.RepoAt(), 194 Ref: currentRef, 195 IsDefaultRef: isDefaultRef, 196 + Language: lang.Name, 197 + Bytes: lang.Size, 198 }) 199 } 200 ··· 237 238 return languageStats, nil 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
··· 19 20 comatproto "github.com/bluesky-social/indigo/api/atproto" 21 lexutil "github.com/bluesky-social/indigo/lex/util" 22 "tangled.sh/tangled.sh/core/api/tangled" 23 "tangled.sh/tangled.sh/core/appview/commitverify" 24 "tangled.sh/tangled.sh/core/appview/config" ··· 31 xrpcclient "tangled.sh/tangled.sh/core/appview/xrpcclient" 32 "tangled.sh/tangled.sh/core/eventconsumer" 33 "tangled.sh/tangled.sh/core/idresolver" 34 - "tangled.sh/tangled.sh/core/knotclient" 35 "tangled.sh/tangled.sh/core/patchutil" 36 "tangled.sh/tangled.sh/core/rbac" 37 "tangled.sh/tangled.sh/core/tid" ··· 92 return 93 } 94 95 - var uri string 96 - if rp.config.Core.Dev { 97 - uri = "http" 98 - } else { 99 - uri = "https" 100 } 101 - url := fmt.Sprintf("%s://%s/%s/%s/archive/%s.tar.gz", uri, f.Knot, f.OwnerDid(), f.Name, url.PathEscape(refParam)) 102 103 - http.Redirect(w, r, url, http.StatusFound) 104 } 105 106 func (rp *Repo) RepoLog(w http.ResponseWriter, r *http.Request) { ··· 120 121 ref := chi.URLParam(r, "ref") 122 123 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 124 if err != nil { 125 - log.Println("failed to create unsigned client", err) 126 return 127 } 128 129 - repolog, err := us.Log(f.OwnerDid(), f.Name, ref, page) 130 - if err != nil { 131 rp.pages.Error503(w) 132 - log.Println("failed to reach knotserver", err) 133 return 134 } 135 136 - tagResult, err := us.Tags(f.OwnerDid(), f.Name) 137 if err != nil { 138 - rp.pages.Error503(w) 139 - log.Println("failed to reach knotserver", err) 140 - return 141 } 142 143 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() 148 } 149 - tagMap[hash] = append(tagMap[hash], tag.Name) 150 } 151 152 - branchResult, err := us.Branches(f.OwnerDid(), f.Name) 153 if err != nil { 154 - rp.pages.Error503(w) 155 - log.Println("failed to reach knotserver", err) 156 - return 157 } 158 159 - for _, branch := range branchResult.Branches { 160 - hash := branch.Hash 161 - tagMap[hash] = append(tagMap[hash], branch.Name) 162 } 163 164 user := rp.oauth.GetUser(r) 165 166 - emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(repolog.Commits), true) 167 if err != nil { 168 log.Println("failed to fetch email to did mapping", err) 169 } 170 171 - vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, repolog.Commits) 172 if err != nil { 173 log.Println(err) 174 } ··· 176 repoInfo := f.RepoInfo(user) 177 178 var shas []string 179 - for _, c := range repolog.Commits { 180 shas = append(shas, c.Hash.String()) 181 } 182 pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas) ··· 189 LoggedInUser: user, 190 TagMap: tagMap, 191 RepoInfo: repoInfo, 192 - RepoLogResponse: *repolog, 193 EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap), 194 VerifiedCommits: vc, 195 Pipelines: pipelines, ··· 301 return 302 } 303 ref := chi.URLParam(r, "ref") 304 - protocol := "http" 305 - if !rp.config.Core.Dev { 306 - protocol = "https" 307 - } 308 309 var diffOpts types.DiffOpts 310 if d := r.URL.Query().Get("diff"); d == "split" { ··· 316 return 317 } 318 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 324 } 325 326 - body, err := io.ReadAll(resp.Body) 327 if err != nil { 328 - log.Printf("Error reading response body: %v", err) 329 return 330 } 331 332 var result types.RepoCommitResponse 333 - err = json.Unmarshal(body, &result) 334 - if err != nil { 335 - log.Println("failed to parse response:", err) 336 return 337 } 338 ··· 378 379 ref := chi.URLParam(r, "ref") 380 treePath := chi.URLParam(r, "*") 381 - protocol := "http" 382 - if !rp.config.Core.Dev { 383 - protocol = "https" 384 - } 385 386 // if the tree path has a trailing slash, let's strip it 387 // so we don't 404 388 treePath = strings.TrimSuffix(treePath, "/") 389 390 - resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, treePath)) 391 if err != nil { 392 - rp.pages.Error503(w) 393 - log.Println("failed to reach knotserver", err) 394 return 395 } 396 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 403 } 404 405 - body, err := io.ReadAll(resp.Body) 406 - if err != nil { 407 - log.Printf("Error reading response body: %v", err) 408 - return 409 } 410 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 416 } 417 418 // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated, ··· 451 return 452 } 453 454 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 455 if err != nil { 456 - log.Println("failed to create unsigned client", err) 457 return 458 } 459 460 - result, err := us.Tags(f.OwnerDid(), f.Name) 461 - if err != nil { 462 rp.pages.Error503(w) 463 - log.Println("failed to reach knotserver", err) 464 return 465 } 466 ··· 496 rp.pages.RepoTags(w, pages.RepoTagsParams{ 497 LoggedInUser: user, 498 RepoInfo: f.RepoInfo(user), 499 - RepoTagsResponse: *result, 500 ArtifactMap: artifactMap, 501 DanglingArtifacts: danglingArtifacts, 502 }) ··· 509 return 510 } 511 512 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 513 if err != nil { 514 - log.Println("failed to create unsigned client", err) 515 return 516 } 517 518 - result, err := us.Branches(f.OwnerDid(), f.Name) 519 - if err != nil { 520 rp.pages.Error503(w) 521 - log.Println("failed to reach knotserver", err) 522 return 523 } 524 ··· 528 rp.pages.RepoBranches(w, pages.RepoBranchesParams{ 529 LoggedInUser: user, 530 RepoInfo: f.RepoInfo(user), 531 - RepoBranchesResponse: *result, 532 }) 533 } 534 ··· 541 542 ref := chi.URLParam(r, "ref") 543 filePath := chi.URLParam(r, "*") 544 - protocol := "http" 545 if !rp.config.Core.Dev { 546 - protocol = "https" 547 } 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 553 } 554 555 - if resp.StatusCode == http.StatusNotFound { 556 rp.pages.Error404(w) 557 return 558 } 559 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 - } 572 573 var breadcrumbs [][]string 574 breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)}) ··· 581 showRendered := false 582 renderToggle := false 583 584 - if markup.GetFormat(result.Path) == markup.FormatMarkdown { 585 renderToggle = true 586 showRendered = r.URL.Query().Get("code") != "true" 587 } ··· 591 var isVideo bool 592 var contentSrc string 593 594 - if result.IsBinary { 595 - ext := strings.ToLower(filepath.Ext(result.Path)) 596 switch ext { 597 case ".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp": 598 isImage = true ··· 602 unsupported = true 603 } 604 605 - // fetch the actual binary content like in RepoBlobRaw 606 607 - blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Name, ref, filePath) 608 contentSrc = blobURL 609 if !rp.config.Core.Dev { 610 contentSrc = markup.GenerateCamoURL(rp.config.Camo.Host, rp.config.Camo.SharedSecret, blobURL) 611 } 612 } 613 614 user := rp.oauth.GetUser(r) 615 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, 626 }) 627 } 628 ··· 637 ref := chi.URLParam(r, "ref") 638 filePath := chi.URLParam(r, "*") 639 640 - protocol := "http" 641 if !rp.config.Core.Dev { 642 - protocol = "https" 643 } 644 645 - blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, filePath) 646 647 req, err := http.NewRequest("GET", blobURL, nil) 648 if err != nil { ··· 685 return 686 } 687 688 - if strings.Contains(contentType, "text/plain") { 689 w.Header().Set("Content-Type", "text/plain; charset=utf-8") 690 w.Write(body) 691 } else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") { 692 w.Header().Set("Content-Type", contentType) 693 w.Write(body) 694 } else { ··· 698 } 699 } 700 701 // modify the spindle configured for this repo 702 func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) { 703 user := rp.oauth.GetUser(r) ··· 1201 f, err := rp.repoResolver.Resolve(r) 1202 user := rp.oauth.GetUser(r) 1203 1204 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 1205 if err != nil { 1206 - log.Println("failed to create unsigned client", err) 1207 return 1208 } 1209 1210 - result, err := us.Branches(f.OwnerDid(), f.Name) 1211 - if err != nil { 1212 rp.pages.Error503(w) 1213 - log.Println("failed to reach knotserver", err) 1214 return 1215 } 1216 ··· 1581 return 1582 } 1583 1584 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 1585 if err != nil { 1586 - log.Printf("failed to create unsigned client for %s", f.Knot) 1587 - rp.pages.Error503(w) 1588 return 1589 } 1590 1591 - result, err := us.Branches(f.OwnerDid(), f.Name) 1592 - if err != nil { 1593 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1594 - log.Println("failed to reach knotserver", err) 1595 return 1596 } 1597 - branches := result.Branches 1598 1599 sortBranches(branches) 1600 ··· 1618 head = queryHead 1619 } 1620 1621 - tags, err := us.Tags(f.OwnerDid(), f.Name) 1622 if err != nil { 1623 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1624 - log.Println("failed to reach knotserver", err) 1625 return 1626 } 1627 ··· 1673 return 1674 } 1675 1676 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 1677 if err != nil { 1678 - log.Printf("failed to create unsigned client for %s", f.Knot) 1679 - rp.pages.Error503(w) 1680 return 1681 } 1682 1683 - branches, err := us.Branches(f.OwnerDid(), f.Name) 1684 - if err != nil { 1685 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1686 - log.Println("failed to reach knotserver", err) 1687 return 1688 } 1689 1690 - tags, err := us.Tags(f.OwnerDid(), f.Name) 1691 if err != nil { 1692 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1693 - log.Println("failed to reach knotserver", err) 1694 return 1695 } 1696 1697 - formatPatch, err := us.Compare(f.OwnerDid(), f.Name, base, head) 1698 if err != nil { 1699 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1700 - log.Println("failed to compare", err) 1701 return 1702 } 1703 diff := patchutil.AsNiceDiff(formatPatch.Patch, base) 1704 1705 repoinfo := f.RepoInfo(user)
··· 19 20 comatproto "github.com/bluesky-social/indigo/api/atproto" 21 lexutil "github.com/bluesky-social/indigo/lex/util" 22 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 23 "tangled.sh/tangled.sh/core/api/tangled" 24 "tangled.sh/tangled.sh/core/appview/commitverify" 25 "tangled.sh/tangled.sh/core/appview/config" ··· 32 xrpcclient "tangled.sh/tangled.sh/core/appview/xrpcclient" 33 "tangled.sh/tangled.sh/core/eventconsumer" 34 "tangled.sh/tangled.sh/core/idresolver" 35 "tangled.sh/tangled.sh/core/patchutil" 36 "tangled.sh/tangled.sh/core/rbac" 37 "tangled.sh/tangled.sh/core/tid" ··· 92 return 93 } 94 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 114 } 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))) 121 122 + // Write the archive data directly 123 + w.Write(archiveBytes) 124 } 125 126 func (rp *Repo) RepoLog(w http.ResponseWriter, r *http.Request) { ··· 140 141 ref := chi.URLParam(r, "ref") 142 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) 162 if err != nil { 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) 169 return 170 } 171 172 + var xrpcResp types.RepoLogResponse 173 + if err := json.Unmarshal(xrpcBytes, &xrpcResp); err != nil { 174 + log.Println("failed to decode XRPC response", err) 175 rp.pages.Error503(w) 176 return 177 } 178 179 + tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 180 if err != nil { 181 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 182 + log.Println("failed to call XRPC repo.tags", xrpcerr) 183 + rp.pages.Error503(w) 184 + return 185 + } 186 } 187 188 tagMap := make(map[string][]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 + } 195 } 196 } 197 198 + branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 199 if err != nil { 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 + } 205 } 206 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 + } 214 } 215 216 user := rp.oauth.GetUser(r) 217 218 + emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(xrpcResp.Commits), true) 219 if err != nil { 220 log.Println("failed to fetch email to did mapping", err) 221 } 222 223 + vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, xrpcResp.Commits) 224 if err != nil { 225 log.Println(err) 226 } ··· 228 repoInfo := f.RepoInfo(user) 229 230 var shas []string 231 + for _, c := range xrpcResp.Commits { 232 shas = append(shas, c.Hash.String()) 233 } 234 pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas) ··· 241 LoggedInUser: user, 242 TagMap: tagMap, 243 RepoInfo: repoInfo, 244 + RepoLogResponse: xrpcResp, 245 EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap), 246 VerifiedCommits: vc, 247 Pipelines: pipelines, ··· 353 return 354 } 355 ref := chi.URLParam(r, "ref") 356 357 var diffOpts types.DiffOpts 358 if d := r.URL.Query().Get("diff"); d == "split" { ··· 364 return 365 } 366 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, 374 } 375 376 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 377 + xrpcBytes, err := tangled.RepoDiff(r.Context(), xrpcc, ref, repo) 378 if err != nil { 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) 385 return 386 } 387 388 var result types.RepoCommitResponse 389 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 390 + log.Println("failed to decode XRPC response", err) 391 + rp.pages.Error503(w) 392 return 393 } 394 ··· 434 435 ref := chi.URLParam(r, "ref") 436 treePath := chi.URLParam(r, "*") 437 438 // if the tree path has a trailing slash, let's strip it 439 // so we don't 404 440 treePath = strings.TrimSuffix(treePath, "/") 441 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) 453 if err != nil { 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) 460 return 461 } 462 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 485 } 486 487 + result := types.RepoTreeResponse{ 488 + Ref: xrpcResp.Ref, 489 + Files: files, 490 } 491 492 + if xrpcResp.Parent != nil { 493 + result.Parent = *xrpcResp.Parent 494 + } 495 + if xrpcResp.Dotdot != nil { 496 + result.DotDot = *xrpcResp.Dotdot 497 } 498 499 // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated, ··· 532 return 533 } 534 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) 546 if err != nil { 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) 553 return 554 } 555 556 + var result types.RepoTagsResponse 557 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 558 + log.Println("failed to decode XRPC response", err) 559 rp.pages.Error503(w) 560 return 561 } 562 ··· 592 rp.pages.RepoTags(w, pages.RepoTagsParams{ 593 LoggedInUser: user, 594 RepoInfo: f.RepoInfo(user), 595 + RepoTagsResponse: result, 596 ArtifactMap: artifactMap, 597 DanglingArtifacts: danglingArtifacts, 598 }) ··· 605 return 606 } 607 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) 619 if err != nil { 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) 626 return 627 } 628 629 + var result types.RepoBranchesResponse 630 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 631 + log.Println("failed to decode XRPC response", err) 632 rp.pages.Error503(w) 633 return 634 } 635 ··· 639 rp.pages.RepoBranches(w, pages.RepoBranchesParams{ 640 LoggedInUser: user, 641 RepoInfo: f.RepoInfo(user), 642 + RepoBranchesResponse: result, 643 }) 644 } 645 ··· 652 653 ref := chi.URLParam(r, "ref") 654 filePath := chi.URLParam(r, "*") 655 + 656 + scheme := "http" 657 if !rp.config.Core.Dev { 658 + scheme = "https" 659 } 660 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 661 + xrpcc := &indigoxrpc.Client{ 662 + Host: host, 663 } 664 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 + } 673 rp.pages.Error404(w) 674 return 675 } 676 677 + // Use XRPC response directly instead of converting to internal types 678 679 var breadcrumbs [][]string 680 breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)}) ··· 687 showRendered := false 688 renderToggle := false 689 690 + if markup.GetFormat(resp.Path) == markup.FormatMarkdown { 691 renderToggle = true 692 showRendered = r.URL.Query().Get("code") != "true" 693 } ··· 697 var isVideo bool 698 var contentSrc string 699 700 + if resp.IsBinary != nil && *resp.IsBinary { 701 + ext := strings.ToLower(filepath.Ext(resp.Path)) 702 switch ext { 703 case ".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp": 704 isImage = true ··· 708 unsupported = true 709 } 710 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)) 715 716 contentSrc = blobURL 717 if !rp.config.Core.Dev { 718 contentSrc = markup.GenerateCamoURL(rp.config.Camo.Host, rp.config.Camo.SharedSecret, blobURL) 719 } 720 } 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 + 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 + 742 rp.pages.RepoBlob(w, pages.RepoBlobParams{ 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, 757 }) 758 } 759 ··· 768 ref := chi.URLParam(r, "ref") 769 filePath := chi.URLParam(r, "*") 770 771 + scheme := "http" 772 if !rp.config.Core.Dev { 773 + scheme = "https" 774 } 775 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)) 779 780 req, err := http.NewRequest("GET", blobURL, nil) 781 if err != nil { ··· 818 return 819 } 820 821 + if strings.HasPrefix(contentType, "text/") || isTextualMimeType(contentType) { 822 + // serve all textual content as text/plain 823 w.Header().Set("Content-Type", "text/plain; charset=utf-8") 824 w.Write(body) 825 } else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") { 826 + // serve images and videos with their original content type 827 w.Header().Set("Content-Type", contentType) 828 w.Write(body) 829 } else { ··· 833 } 834 } 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 + 853 // modify the spindle configured for this repo 854 func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) { 855 user := rp.oauth.GetUser(r) ··· 1353 f, err := rp.repoResolver.Resolve(r) 1354 user := rp.oauth.GetUser(r) 1355 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) 1367 if err != nil { 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) 1374 return 1375 } 1376 1377 + var result types.RepoBranchesResponse 1378 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 1379 + log.Println("failed to decode XRPC response", err) 1380 rp.pages.Error503(w) 1381 return 1382 } 1383 ··· 1748 return 1749 } 1750 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) 1762 if err != nil { 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.") 1769 return 1770 } 1771 1772 + var branchResult types.RepoBranchesResponse 1773 + if err := json.Unmarshal(branchBytes, &branchResult); err != nil { 1774 + log.Println("failed to decode XRPC branches response", err) 1775 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1776 return 1777 } 1778 + branches := branchResult.Branches 1779 1780 sortBranches(branches) 1781 ··· 1799 head = queryHead 1800 } 1801 1802 + tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 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 + } 1809 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 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.") 1817 return 1818 } 1819 ··· 1865 return 1866 } 1867 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) 1880 if err != nil { 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.") 1887 return 1888 } 1889 1890 + var branches types.RepoBranchesResponse 1891 + if err := json.Unmarshal(branchBytes, &branches); err != nil { 1892 + log.Println("failed to decode XRPC branches response", err) 1893 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1894 return 1895 } 1896 1897 + tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 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 + } 1904 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1905 return 1906 } 1907 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) 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 + } 1922 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1923 return 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 + 1933 diff := patchutil.AsNiceDiff(formatPatch.Patch, base) 1934 1935 repoinfo := f.RepoInfo(user)
+11 -27
appview/serververify/verify.go
··· 4 "context" 5 "errors" 6 "fmt" 7 - "io" 8 - "net/http" 9 - "strings" 10 - "time" 11 12 "tangled.sh/tangled.sh/core/appview/db" 13 "tangled.sh/tangled.sh/core/rbac" 14 ) 15 ··· 24 scheme = "http" 25 } 26 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") 40 } 41 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) 45 } 46 47 - did := strings.TrimSpace(string(body)) 48 - if did == "" { 49 - return "", fmt.Errorf("empty DID in /owner response") 50 - } 51 - 52 - return did, nil 53 } 54 55 type OwnerMismatch struct { ··· 65 func RunVerification(ctx context.Context, domain, expectedOwner string, dev bool) error { 66 observedOwner, err := fetchOwner(ctx, domain, dev) 67 if err != nil { 68 - return fmt.Errorf("%w: %w", FetchError, err) 69 } 70 71 if observedOwner != expectedOwner {
··· 4 "context" 5 "errors" 6 "fmt" 7 8 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 9 + "tangled.sh/tangled.sh/core/api/tangled" 10 "tangled.sh/tangled.sh/core/appview/db" 11 + "tangled.sh/tangled.sh/core/appview/xrpcclient" 12 "tangled.sh/tangled.sh/core/rbac" 13 ) 14 ··· 23 scheme = "http" 24 } 25 26 + host := fmt.Sprintf("%s://%s", scheme, domain) 27 + xrpcc := &indigoxrpc.Client{ 28 + Host: host, 29 } 30 31 + res, err := tangled.Owner(ctx, xrpcc) 32 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 33 + return "", xrpcerr 34 } 35 36 + return res.Owner, nil 37 } 38 39 type OwnerMismatch struct { ··· 49 func RunVerification(ctx context.Context, domain, expectedOwner string, dev bool) error { 50 observedOwner, err := fetchOwner(ctx, domain, dev) 51 if err != nil { 52 + return err 53 } 54 55 if observedOwner != expectedOwner {
+4 -3
appview/spindles/spindles.go
··· 16 "tangled.sh/tangled.sh/core/appview/oauth" 17 "tangled.sh/tangled.sh/core/appview/pages" 18 "tangled.sh/tangled.sh/core/appview/serververify" 19 "tangled.sh/tangled.sh/core/idresolver" 20 "tangled.sh/tangled.sh/core/rbac" 21 "tangled.sh/tangled.sh/core/tid" ··· 404 if err != nil { 405 l.Error("verification failed", "err", err) 406 407 - if errors.Is(err, serververify.FetchError) { 408 - s.Pages.Notice(w, noticeId, "Failed to verify knot, unable to fetch owner.") 409 return 410 } 411 ··· 442 } 443 444 w.Header().Set("HX-Reswap", "outerHTML") 445 - s.Pages.SpindleListing(w, pages.SpindleListingParams{verifiedSpindle[0]}) 446 } 447 448 func (s *Spindles) addMember(w http.ResponseWriter, r *http.Request) {
··· 16 "tangled.sh/tangled.sh/core/appview/oauth" 17 "tangled.sh/tangled.sh/core/appview/pages" 18 "tangled.sh/tangled.sh/core/appview/serververify" 19 + "tangled.sh/tangled.sh/core/appview/xrpcclient" 20 "tangled.sh/tangled.sh/core/idresolver" 21 "tangled.sh/tangled.sh/core/rbac" 22 "tangled.sh/tangled.sh/core/tid" ··· 405 if err != nil { 406 l.Error("verification failed", "err", err) 407 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!") 410 return 411 } 412 ··· 443 } 444 445 w.Header().Set("HX-Reswap", "outerHTML") 446 + s.Pages.SpindleListing(w, pages.SpindleListingParams{Spindle: verifiedSpindle[0]}) 447 } 448 449 func (s *Spindles) addMember(w http.ResponseWriter, r *http.Request) {
+197 -137
appview/state/profile.go
··· 17 "github.com/gorilla/feeds" 18 "tangled.sh/tangled.sh/core/api/tangled" 19 "tangled.sh/tangled.sh/core/appview/db" 20 - "tangled.sh/tangled.sh/core/appview/oauth" 21 "tangled.sh/tangled.sh/core/appview/pages" 22 ) 23 24 func (s *State) Profile(w http.ResponseWriter, r *http.Request) { 25 tabVal := r.URL.Query().Get("tab") 26 switch tabVal { 27 - case "": 28 - s.profileHomePage(w, r) 29 case "repos": 30 s.reposPage(w, r) 31 case "followers": 32 s.followersPage(w, r) 33 case "following": 34 s.followingPage(w, r) 35 } 36 } 37 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 { 45 didOrHandle := chi.URLParam(r, "user") 46 if didOrHandle == "" { 47 - http.Error(w, "bad request", http.StatusBadRequest) 48 - return nil 49 } 50 51 ident, ok := r.Context().Value("resolvedId").(identity.Identity) 52 if !ok { 53 - log.Printf("malformed middleware") 54 - w.WriteHeader(http.StatusInternalServerError) 55 - return nil 56 } 57 did := ident.DID.String() 58 59 profile, err := db.GetProfile(s.db, did) 60 if err != nil { 61 - log.Printf("getting profile data for %s: %s", did, err) 62 - s.pages.Error500(w) 63 - return nil 64 } 65 66 followStats, err := db.GetFollowerFollowingCount(s.db, did) 67 if err != nil { 68 - log.Printf("getting follow stats for %s: %s", did, err) 69 } 70 71 loggedInUser := s.oauth.GetUser(r) ··· 74 followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, did) 75 } 76 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, 85 FollowersCount: followStats.Followers, 86 FollowingCount: followStats.Following, 87 }, 88 - } 89 } 90 91 - func (s *State) profileHomePage(w http.ResponseWriter, r *http.Request) { 92 - pageWithProfile := s.profilePage(w, r) 93 - if pageWithProfile == nil { 94 return 95 } 96 97 - id := pageWithProfile.Id 98 repos, err := db.GetRepos( 99 s.db, 100 0, 101 - db.FilterEq("did", id.DID), 102 ) 103 if err != nil { 104 - log.Printf("getting repos for %s: %s", id.DID, err) 105 } 106 107 - profile := pageWithProfile.Card.Profile 108 // filter out ones that are pinned 109 pinnedRepos := []db.Repo{} 110 for i, r := range repos { 111 // if this is a pinned repo, add it 112 - if slices.Contains(profile.PinnedRepos[:], r.RepoAt()) { 113 pinnedRepos = append(pinnedRepos, r) 114 } 115 116 // if there are no saved pins, add the first 4 repos 117 - if profile.IsPinnedReposEmpty() && i < 4 { 118 pinnedRepos = append(pinnedRepos, r) 119 } 120 } 121 122 - collaboratingRepos, err := db.CollaboratingIn(s.db, id.DID.String()) 123 if err != nil { 124 - log.Printf("getting collaborating repos for %s: %s", id.DID, err) 125 } 126 127 pinnedCollaboratingRepos := []db.Repo{} 128 for _, r := range collaboratingRepos { 129 // if this is a pinned repo, add it 130 - if slices.Contains(profile.PinnedRepos[:], r.RepoAt()) { 131 pinnedCollaboratingRepos = append(pinnedCollaboratingRepos, r) 132 } 133 } 134 135 - timeline, err := db.MakeProfileTimeline(s.db, id.DID.String()) 136 if err != nil { 137 - log.Printf("failed to create profile timeline for %s: %s", id.DID, err) 138 } 139 140 - var didsToResolve []string 141 - for _, r := range collaboratingRepos { 142 - didsToResolve = append(didsToResolve, r.Did) 143 } 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 - } 158 159 - now := time.Now() 160 - startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC) 161 - punchcard, err := db.MakePunchcard( 162 s.db, 163 - db.FilterEq("did", id.DID), 164 - db.FilterGte("date", startOfYear.Format(time.DateOnly)), 165 - db.FilterLte("date", now.Format(time.DateOnly)), 166 ) 167 if err != nil { 168 - log.Println("failed to get punchcard for did", "did", id.DID, "err", err) 169 } 170 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, 178 }) 179 } 180 181 - func (s *State) reposPage(w http.ResponseWriter, r *http.Request) { 182 - pageWithProfile := s.profilePage(w, r) 183 - if pageWithProfile == nil { 184 return 185 } 186 187 - id := pageWithProfile.Id 188 repos, err := db.GetRepos( 189 s.db, 190 0, 191 - db.FilterEq("did", id.DID), 192 ) 193 if err != nil { 194 - log.Printf("getting repos for %s: %s", id.DID, err) 195 } 196 197 - s.pages.ReposPage(w, pages.ReposPageParams{ 198 - LoggedInUser: pageWithProfile.LoggedInUser, 199 Repos: repos, 200 - Card: pageWithProfile.Card, 201 }) 202 } 203 204 type FollowsPageParams struct { 205 - LoggedInUser *oauth.User 206 - Follows []pages.FollowCard 207 - Card pages.ProfileCard 208 } 209 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 214 } 215 216 - id := pageWithProfile.Id 217 - loggedInUser := pageWithProfile.LoggedInUser 218 219 - follows, err := fetchFollows(s.db, id.DID.String()) 220 if err != nil { 221 - log.Printf("getting followers for %s: %s", id.DID, err) 222 - return FollowsPageParams{}, err 223 } 224 225 if len(follows) == 0 { 226 - return FollowsPageParams{ 227 - LoggedInUser: loggedInUser, 228 - Follows: []pages.FollowCard{}, 229 - Card: pageWithProfile.Card, 230 - }, nil 231 } 232 233 followDids := make([]string, 0, len(follows)) ··· 237 238 profiles, err := db.GetProfiles(s.db, db.FilterIn("did", followDids)) 239 if err != nil { 240 - log.Printf("getting profile for %s: %s", followDids, err) 241 - return FollowsPageParams{}, err 242 } 243 244 followStatsMap, err := db.GetFollowerFollowingCounts(s.db, followDids) ··· 246 log.Printf("getting follow counts for %s: %s", followDids, err) 247 } 248 249 - var loggedInUserFollowing map[string]struct{} 250 if loggedInUser != nil { 251 following, err := db.GetFollowing(s.db, loggedInUser.Did) 252 if err != nil { 253 - return FollowsPageParams{}, err 254 } 255 - if len(following) > 0 { 256 - loggedInUserFollowing = make(map[string]struct{}, len(following)) 257 - for _, follow := range following { 258 - loggedInUserFollowing[follow.SubjectDid] = struct{}{} 259 - } 260 } 261 } 262 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 - } 269 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 - } 276 } 277 var profile *db.Profile 278 if p, exists := profiles[did]; exists { 279 profile = p ··· 281 profile = &db.Profile{} 282 profile.Did = did 283 } 284 - followCards = append(followCards, pages.FollowCard{ 285 UserDid: did, 286 FollowStatus: followStatus, 287 FollowersCount: followStats.Followers, 288 FollowingCount: followStats.Following, 289 Profile: profile, 290 - }) 291 } 292 293 - return FollowsPageParams{ 294 - LoggedInUser: loggedInUser, 295 - Follows: followCards, 296 - Card: pageWithProfile.Card, 297 - }, nil 298 } 299 300 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 }) 302 if err != nil { 303 s.pages.Notice(w, "all-followers", "Failed to load followers") 304 return 305 } 306 307 - s.pages.FollowersPage(w, pages.FollowersPageParams{ 308 - LoggedInUser: followPage.LoggedInUser, 309 Followers: followPage.Follows, 310 Card: followPage.Card, 311 }) 312 } 313 314 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 }) 316 if err != nil { 317 s.pages.Notice(w, "all-following", "Failed to load following") 318 return 319 } 320 321 - s.pages.FollowingPage(w, pages.FollowingPageParams{ 322 - LoggedInUser: followPage.LoggedInUser, 323 Following: followPage.Follows, 324 Card: followPage.Card, 325 }) ··· 408 409 func (s *State) addIssueItems(ctx context.Context, feed *feeds.Feed, issues []*db.Issue, author *feeds.Author) error { 410 for _, issue := range issues { 411 - owner, err := s.idResolver.ResolveIdent(ctx, issue.Metadata.Repo.Did) 412 if err != nil { 413 return err 414 } ··· 440 441 func (s *State) createIssueItem(issue *db.Issue, owner *identity.Identity, author *feeds.Author) *feeds.Item { 442 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"}, 445 Created: issue.Created, 446 Author: author, 447 } ··· 642 log.Printf("getting profile data for %s: %s", user.Did, err) 643 } 644 645 - repos, err := db.GetAllReposByDid(s.db, user.Did) 646 if err != nil { 647 log.Printf("getting repos for %s: %s", user.Did, err) 648 }
··· 17 "github.com/gorilla/feeds" 18 "tangled.sh/tangled.sh/core/api/tangled" 19 "tangled.sh/tangled.sh/core/appview/db" 20 "tangled.sh/tangled.sh/core/appview/pages" 21 ) 22 23 func (s *State) Profile(w http.ResponseWriter, r *http.Request) { 24 tabVal := r.URL.Query().Get("tab") 25 switch tabVal { 26 case "repos": 27 s.reposPage(w, r) 28 case "followers": 29 s.followersPage(w, r) 30 case "following": 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) 38 } 39 } 40 41 + func (s *State) profile(r *http.Request) (*pages.ProfileCard, error) { 42 didOrHandle := chi.URLParam(r, "user") 43 if didOrHandle == "" { 44 + return nil, fmt.Errorf("empty DID or handle") 45 } 46 47 ident, ok := r.Context().Value("resolvedId").(identity.Identity) 48 if !ok { 49 + return nil, fmt.Errorf("failed to resolve ID") 50 } 51 did := ident.DID.String() 52 53 profile, err := db.GetProfile(s.db, did) 54 if err != 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) 71 } 72 73 followStats, err := db.GetFollowerFollowingCount(s.db, did) 74 if err != nil { 75 + return nil, fmt.Errorf("failed to get follower stats: %w", err) 76 } 77 78 loggedInUser := s.oauth.GetUser(r) ··· 81 followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, did) 82 } 83 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, 105 FollowersCount: followStats.Followers, 106 FollowingCount: followStats.Following, 107 }, 108 + Punchcard: punchcard, 109 + }, nil 110 } 111 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) 119 return 120 } 121 + l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 122 123 repos, err := db.GetRepos( 124 s.db, 125 0, 126 + db.FilterEq("did", profile.UserDid), 127 ) 128 if err != nil { 129 + l.Error("failed to fetch repos", "err", err) 130 } 131 132 // filter out ones that are pinned 133 pinnedRepos := []db.Repo{} 134 for i, r := range repos { 135 // if this is a pinned repo, add it 136 + if slices.Contains(profile.Profile.PinnedRepos[:], r.RepoAt()) { 137 pinnedRepos = append(pinnedRepos, r) 138 } 139 140 // if there are no saved pins, add the first 4 repos 141 + if profile.Profile.IsPinnedReposEmpty() && i < 4 { 142 pinnedRepos = append(pinnedRepos, r) 143 } 144 } 145 146 + collaboratingRepos, err := db.CollaboratingIn(s.db, profile.UserDid) 147 if err != nil { 148 + l.Error("failed to fetch collaborating repos", "err", err) 149 } 150 151 pinnedCollaboratingRepos := []db.Repo{} 152 for _, r := range collaboratingRepos { 153 // if this is a pinned repo, add it 154 + if slices.Contains(profile.Profile.PinnedRepos[:], r.RepoAt()) { 155 pinnedCollaboratingRepos = append(pinnedCollaboratingRepos, r) 156 } 157 } 158 159 + timeline, err := db.MakeProfileTimeline(s.db, profile.UserDid) 160 if err != nil { 161 + l.Error("failed to create timeline", "err", err) 162 } 163 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 181 } 182 + l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 183 184 + repos, err := db.GetRepos( 185 s.db, 186 + 0, 187 + db.FilterEq("did", profile.UserDid), 188 ) 189 if err != nil { 190 + l.Error("failed to get repos", "err", err) 191 + s.pages.Error500(w) 192 + return 193 } 194 195 + err = s.pages.ProfileRepos(w, pages.ProfileReposParams{ 196 + LoggedInUser: s.oauth.GetUser(r), 197 + Repos: repos, 198 + Card: profile, 199 }) 200 } 201 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) 209 return 210 } 211 + l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 212 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 + 224 repos, err := db.GetRepos( 225 s.db, 226 0, 227 + db.FilterIn("at_uri", repoAts), 228 ) 229 if err != nil { 230 + l.Error("failed to get repos", "err", err) 231 + s.pages.Error500(w) 232 + return 233 } 234 235 + err = s.pages.ProfileStarred(w, pages.ProfileStarredParams{ 236 + LoggedInUser: s.oauth.GetUser(r), 237 Repos: repos, 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, 264 }) 265 } 266 267 type FollowsPageParams struct { 268 + Follows []pages.FollowCard 269 + Card *pages.ProfileCard 270 } 271 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 282 } 283 + l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 284 285 + loggedInUser := s.oauth.GetUser(r) 286 + params := FollowsPageParams{ 287 + Card: profile, 288 + } 289 290 + follows, err := fetchFollows(s.db, profile.UserDid) 291 if err != nil { 292 + l.Error("failed to fetch follows", "err", err) 293 + return &params, err 294 } 295 296 if len(follows) == 0 { 297 + return &params, nil 298 } 299 300 followDids := make([]string, 0, len(follows)) ··· 304 305 profiles, err := db.GetProfiles(s.db, db.FilterIn("did", followDids)) 306 if err != nil { 307 + l.Error("failed to get profiles", "followDids", followDids, "err", err) 308 + return &params, err 309 } 310 311 followStatsMap, err := db.GetFollowerFollowingCounts(s.db, followDids) ··· 313 log.Printf("getting follow counts for %s: %s", followDids, err) 314 } 315 316 + loggedInUserFollowing := make(map[string]struct{}) 317 if loggedInUser != nil { 318 following, err := db.GetFollowing(s.db, loggedInUser.Did) 319 if err != nil { 320 + l.Error("failed to get follow list", "err", err, "loggedInUser", loggedInUser.Did) 321 + return &params, err 322 } 323 + loggedInUserFollowing = make(map[string]struct{}, len(following)) 324 + for _, follow := range following { 325 + loggedInUserFollowing[follow.SubjectDid] = struct{}{} 326 } 327 } 328 329 + followCards := make([]pages.FollowCard, len(follows)) 330 + for i, did := range followDids { 331 + followStats := followStatsMap[did] 332 followStatus := db.IsNotFollowing 333 + if _, exists := loggedInUserFollowing[did]; exists { 334 + followStatus = db.IsFollowing 335 + } else if loggedInUser != nil && loggedInUser.Did == did { 336 + followStatus = db.IsSelf 337 } 338 + 339 var profile *db.Profile 340 if p, exists := profiles[did]; exists { 341 profile = p ··· 343 profile = &db.Profile{} 344 profile.Did = did 345 } 346 + followCards[i] = pages.FollowCard{ 347 UserDid: did, 348 FollowStatus: followStatus, 349 FollowersCount: followStats.Followers, 350 FollowingCount: followStats.Following, 351 Profile: profile, 352 + } 353 } 354 355 + params.Follows = followCards 356 + 357 + return &params, nil 358 } 359 360 func (s *State) followersPage(w http.ResponseWriter, r *http.Request) { 361 + followPage, err := s.followPage(r, db.GetFollowers, func(f db.Follow) string { return f.UserDid }) 362 if err != nil { 363 s.pages.Notice(w, "all-followers", "Failed to load followers") 364 return 365 } 366 367 + s.pages.ProfileFollowers(w, pages.ProfileFollowersParams{ 368 + LoggedInUser: s.oauth.GetUser(r), 369 Followers: followPage.Follows, 370 Card: followPage.Card, 371 }) 372 } 373 374 func (s *State) followingPage(w http.ResponseWriter, r *http.Request) { 375 + followPage, err := s.followPage(r, db.GetFollowing, func(f db.Follow) string { return f.SubjectDid }) 376 if err != nil { 377 s.pages.Notice(w, "all-following", "Failed to load following") 378 return 379 } 380 381 + s.pages.ProfileFollowing(w, pages.ProfileFollowingParams{ 382 + LoggedInUser: s.oauth.GetUser(r), 383 Following: followPage.Follows, 384 Card: followPage.Card, 385 }) ··· 468 469 func (s *State) addIssueItems(ctx context.Context, feed *feeds.Feed, issues []*db.Issue, author *feeds.Author) error { 470 for _, issue := range issues { 471 + owner, err := s.idResolver.ResolveIdent(ctx, issue.Repo.Did) 472 if err != nil { 473 return err 474 } ··· 500 501 func (s *State) createIssueItem(issue *db.Issue, owner *identity.Identity, author *feeds.Author) *feeds.Item { 502 return &feeds.Item{ 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"}, 505 Created: issue.Created, 506 Author: author, 507 } ··· 702 log.Printf("getting profile data for %s: %s", user.Did, err) 703 } 704 705 + repos, err := db.GetRepos(s.db, 0, db.FilterEq("did", user.Did)) 706 if err != nil { 707 log.Printf("getting repos for %s: %s", user.Did, err) 708 }
+4 -2
appview/state/router.go
··· 111 112 r.Handle("/static/*", s.pages.Static()) 113 114 - r.Get("/", s.Timeline) 115 116 r.Route("/repo", func(r chi.Router) { 117 r.Route("/new", func(r chi.Router) { ··· 230 } 231 232 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) 234 return issues.Router(mw) 235 } 236
··· 111 112 r.Handle("/static/*", s.pages.Static()) 113 114 + r.Get("/", s.HomeOrTimeline) 115 + r.Get("/timeline", s.Timeline) 116 + r.With(middleware.AuthMiddleware(s.oauth)).Get("/upgradeBanner", s.UpgradeBanner) 117 118 r.Route("/repo", func(r chi.Router) { 119 r.Route("/new", func(r chi.Router) { ··· 232 } 233 234 func (s *State) IssuesRouter(mw *middleware.Middleware) http.Handler { 235 + issues := issues.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.notifier, s.validator) 236 return issues.Router(mw) 237 } 238
+78 -4
appview/state/state.go
··· 28 "tangled.sh/tangled.sh/core/appview/pages" 29 posthogService "tangled.sh/tangled.sh/core/appview/posthog" 30 "tangled.sh/tangled.sh/core/appview/reporesolver" 31 xrpcclient "tangled.sh/tangled.sh/core/appview/xrpcclient" 32 "tangled.sh/tangled.sh/core/eventconsumer" 33 "tangled.sh/tangled.sh/core/idresolver" ··· 53 knotstream *eventconsumer.Consumer 54 spindlestream *eventconsumer.Consumer 55 logger *slog.Logger 56 } 57 58 func Make(ctx context.Context, config *config.Config) (*State, error) { ··· 73 } 74 75 pgs := pages.NewPages(config, res) 76 - 77 cache := cache.New(config.Redis.Addr) 78 sess := session.New(cache) 79 - 80 oauth := oauth.NewOAuth(config, sess) 81 82 posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint}) 83 if err != nil { ··· 99 tangled.SpindleMemberNSID, 100 tangled.SpindleNSID, 101 tangled.StringNSID, 102 }, 103 nil, 104 slog.Default(), ··· 119 IdResolver: res, 120 Config: config, 121 Logger: tlog.New("ingester"), 122 } 123 err = jc.StartJetstream(ctx, ingester.Ingest()) 124 if err != nil { ··· 158 knotstream, 159 spindlestream, 160 slog.Default(), 161 } 162 163 return state, nil 164 } 165 166 func (s *State) Favicon(w http.ResponseWriter, r *http.Request) { 167 w.Header().Set("Content-Type", "image/svg+xml") 168 w.Header().Set("Cache-Control", "public, max-age=31536000") // one year ··· 190 }) 191 } 192 193 func (s *State) Timeline(w http.ResponseWriter, r *http.Request) { 194 user := s.oauth.GetUser(r) 195 196 - timeline, err := db.MakeTimeline(s.db) 197 if err != nil { 198 log.Println(err) 199 s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") ··· 213 }) 214 } 215 216 func (s *State) Keys(w http.ResponseWriter, r *http.Request) { 217 user := chi.URLParam(r, "user") 218 user = strings.TrimPrefix(user, "@") ··· 241 242 for _, k := range pubKeys { 243 key := strings.TrimRight(k.Key, "\n") 244 - w.Write([]byte(fmt.Sprintln(key))) 245 } 246 } 247
··· 28 "tangled.sh/tangled.sh/core/appview/pages" 29 posthogService "tangled.sh/tangled.sh/core/appview/posthog" 30 "tangled.sh/tangled.sh/core/appview/reporesolver" 31 + "tangled.sh/tangled.sh/core/appview/validator" 32 xrpcclient "tangled.sh/tangled.sh/core/appview/xrpcclient" 33 "tangled.sh/tangled.sh/core/eventconsumer" 34 "tangled.sh/tangled.sh/core/idresolver" ··· 54 knotstream *eventconsumer.Consumer 55 spindlestream *eventconsumer.Consumer 56 logger *slog.Logger 57 + validator *validator.Validator 58 } 59 60 func Make(ctx context.Context, config *config.Config) (*State, error) { ··· 75 } 76 77 pgs := pages.NewPages(config, res) 78 cache := cache.New(config.Redis.Addr) 79 sess := session.New(cache) 80 oauth := oauth.NewOAuth(config, sess) 81 + validator := validator.New(d) 82 83 posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint}) 84 if err != nil { ··· 100 tangled.SpindleMemberNSID, 101 tangled.SpindleNSID, 102 tangled.StringNSID, 103 + tangled.RepoIssueNSID, 104 + tangled.RepoIssueCommentNSID, 105 }, 106 nil, 107 slog.Default(), ··· 122 IdResolver: res, 123 Config: config, 124 Logger: tlog.New("ingester"), 125 + Validator: validator, 126 } 127 err = jc.StartJetstream(ctx, ingester.Ingest()) 128 if err != nil { ··· 162 knotstream, 163 spindlestream, 164 slog.Default(), 165 + validator, 166 } 167 168 return state, nil 169 } 170 171 + func (s *State) Close() error { 172 + // other close up logic goes here 173 + return s.db.Close() 174 + } 175 + 176 func (s *State) Favicon(w http.ResponseWriter, r *http.Request) { 177 w.Header().Set("Content-Type", "image/svg+xml") 178 w.Header().Set("Cache-Control", "public, max-age=31536000") // one year ··· 200 }) 201 } 202 203 + func (s *State) HomeOrTimeline(w http.ResponseWriter, r *http.Request) { 204 + if s.oauth.GetUser(r) != nil { 205 + s.Timeline(w, r) 206 + return 207 + } 208 + s.Home(w, r) 209 + } 210 + 211 func (s *State) Timeline(w http.ResponseWriter, r *http.Request) { 212 user := s.oauth.GetUser(r) 213 214 + timeline, err := db.MakeTimeline(s.db, 50) 215 if err != nil { 216 log.Println(err) 217 s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") ··· 231 }) 232 } 233 234 + func (s *State) UpgradeBanner(w http.ResponseWriter, r *http.Request) { 235 + user := s.oauth.GetUser(r) 236 + l := s.logger.With("handler", "UpgradeBanner") 237 + l = l.With("did", user.Did) 238 + l = l.With("handle", user.Handle) 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 + 290 func (s *State) Keys(w http.ResponseWriter, r *http.Request) { 291 user := chi.URLParam(r, "user") 292 user = strings.TrimPrefix(user, "@") ··· 315 316 for _, k := range pubKeys { 317 key := strings.TrimRight(k.Key, "\n") 318 + fmt.Fprintln(w, key) 319 } 320 } 321
+1 -59
appview/strings/strings.go
··· 5 "log/slog" 6 "net/http" 7 "path" 8 - "slices" 9 "strconv" 10 "time" 11 ··· 161 } 162 163 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 - }) 222 } 223 224 func (s *Strings) edit(w http.ResponseWriter, r *http.Request) {
··· 5 "log/slog" 6 "net/http" 7 "path" 8 "strconv" 9 "time" 10 ··· 160 } 161 162 func (s *Strings) dashboard(w http.ResponseWriter, r *http.Request) { 163 + http.Redirect(w, r, fmt.Sprintf("/%s?tab=strings", chi.URLParam(r, "user")), http.StatusFound) 164 } 165 166 func (s *Strings) edit(w http.ResponseWriter, r *http.Request) {
+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
···
··· 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
··· 4 "bytes" 5 "context" 6 "errors" 7 - "fmt" 8 "io" 9 "net/http" 10 ··· 12 "github.com/bluesky-social/indigo/xrpc" 13 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 14 oauth "tangled.sh/icyphox.sh/atproto-oauth" 15 ) 16 17 type Client struct { ··· 115 116 var xrpcerr *indigoxrpc.Error 117 if ok := errors.As(err, &xrpcerr); !ok { 118 - return fmt.Errorf("Recieved invalid XRPC error response.") 119 } 120 121 switch xrpcerr.StatusCode { 122 case http.StatusNotFound: 123 - return fmt.Errorf("XRPC is unsupported on this knot, consider upgrading your knot.") 124 case http.StatusUnauthorized: 125 - return fmt.Errorf("Unauthorized XRPC request.") 126 default: 127 - return fmt.Errorf("Failed to perform operation. Try again later.") 128 } 129 }
··· 4 "bytes" 5 "context" 6 "errors" 7 "io" 8 "net/http" 9 ··· 11 "github.com/bluesky-social/indigo/xrpc" 12 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 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") 21 ) 22 23 type Client struct { ··· 121 122 var xrpcerr *indigoxrpc.Error 123 if ok := errors.As(err, &xrpcerr); !ok { 124 + return ErrXrpcInvalid 125 } 126 127 switch xrpcerr.StatusCode { 128 case http.StatusNotFound: 129 + return ErrXrpcUnsupported 130 case http.StatusUnauthorized: 131 + return ErrXrpcUnauthorized 132 default: 133 + return ErrXrpcFailed 134 } 135 }
+3
cmd/appview/main.go
··· 23 } 24 25 state, err := state.Make(ctx, c) 26 27 if err != nil { 28 log.Fatal(err)
··· 23 } 24 25 state, err := state.Make(ctx, c) 26 + defer func() { 27 + log.Println(state.Close()) 28 + }() 29 30 if err != nil { 31 log.Fatal(err)
+5 -4
cmd/gen.go
··· 18 tangled.FeedReaction{}, 19 tangled.FeedStar{}, 20 tangled.GitRefUpdate{}, 21 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 tangled.GraphFollow{}, 27 tangled.Knot{}, 28 tangled.KnotMember{}, ··· 47 tangled.RepoPullComment{}, 48 tangled.RepoPull_Source{}, 49 tangled.RepoPullStatus{}, 50 tangled.Spindle{}, 51 tangled.SpindleMember{}, 52 tangled.String{},
··· 18 tangled.FeedReaction{}, 19 tangled.FeedStar{}, 20 tangled.GitRefUpdate{}, 21 + tangled.GitRefUpdate_CommitCountBreakdown{}, 22 + tangled.GitRefUpdate_IndividualEmailCommitCount{}, 23 + tangled.GitRefUpdate_LangBreakdown{}, 24 + tangled.GitRefUpdate_IndividualLanguageSize{}, 25 tangled.GitRefUpdate_Meta{}, 26 tangled.GraphFollow{}, 27 tangled.Knot{}, 28 tangled.KnotMember{}, ··· 47 tangled.RepoPullComment{}, 48 tangled.RepoPull_Source{}, 49 tangled.RepoPullStatus{}, 50 + tangled.RepoPull_Target{}, 51 tangled.Spindle{}, 52 tangled.SpindleMember{}, 53 tangled.String{},
+3 -3
docs/contributing.md
··· 11 ### message format 12 13 ``` 14 - <service/top-level directory>: <affected package/directory>: <short summary of change> 15 16 17 Optional longer description can go here, if necessary. Explain what the ··· 23 Here are some examples: 24 25 ``` 26 - appview: state: fix token expiry check in middleware 27 28 The previous check did not account for clock drift, leading to premature 29 token invalidation. 30 ``` 31 32 ``` 33 - knotserver: git/service: improve error checking in upload-pack 34 ``` 35 36
··· 11 ### message format 12 13 ``` 14 + <service/top-level directory>/<affected package/directory>: <short summary of change> 15 16 17 Optional longer description can go here, if necessary. Explain what the ··· 23 Here are some examples: 24 25 ``` 26 + appview/state: fix token expiry check in middleware 27 28 The previous check did not account for clock drift, leading to premature 29 token invalidation. 30 ``` 31 32 ``` 33 + knotserver/git/service: improve error checking in upload-pack 34 ``` 35 36
+53 -12
docs/hacking.md
··· 48 redis-server 49 ``` 50 51 - ## running a knot 52 53 An end-to-end knot setup requires setting up a machine with 54 `sshd`, `AuthorizedKeysCommand`, and git user, which is 55 quite cumbersome. So the nix flake provides a 56 `nixosConfiguration` to do so. 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. 61 62 - If you don't want to [set up a spindle](#running-a-spindle), 63 - you can use any placeholder value. 64 65 - You can now start a lightweight NixOS VM like so: 66 67 ```bash 68 nix run --impure .#vm ··· 74 with `ssh` exposed on port 2222. 75 76 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. 81 82 You can push repositories to this VM with this ssh config 83 block on your main machine: ··· 97 git push local-dev main 98 ``` 99 100 - ## running a spindle 101 102 The above VM should already be running a spindle on 103 `localhost:6555`. Head to http://localhost:3000/spindles and ··· 119 # litecli has a nicer REPL interface: 120 litecli /var/lib/spindle/spindle.db 121 ```
··· 48 redis-server 49 ``` 50 51 + ## running knots and spindles 52 53 An end-to-end knot setup requires setting up a machine with 54 `sshd`, `AuthorizedKeysCommand`, and git user, which is 55 quite cumbersome. So the nix flake provides a 56 `nixosConfiguration` to do so. 57 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`. 90 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). 96 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: 103 104 ```bash 105 nix run --impure .#vm ··· 111 with `ssh` exposed on port 2222. 112 113 Once the services are running, head to 114 + http://localhost:3000/knots and hit verify. It should 115 + verify the ownership of the services instantly if everything 116 + went smoothly. 117 118 You can push repositories to this VM with this ssh config 119 block on your main machine: ··· 133 git push local-dev main 134 ``` 135 136 + ### running a spindle 137 138 The above VM should already be running a spindle on 139 `localhost:6555`. Head to http://localhost:3000/spindles and ··· 155 # litecli has a nicer REPL interface: 156 litecli /var/lib/spindle/spindle.db 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
··· 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
···
··· 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
··· 1 - # spindle pipeline manifest 2 3 - Spindle pipelines are defined under the `.tangled/workflows` directory in a 4 - repo. Generally: 5 6 - * Pipelines are defined in YAML. 7 - * Workflows can run using different *engines*. 8 9 - The most barebones workflow looks like this: 10 11 ```yaml 12 when: 13 - - event: ["push"] 14 branch: ["main"] 15 16 engine: "nixery" 17 18 - # optional 19 clone: 20 skip: false 21 - depth: 50 22 - submodules: true 23 ``` 24 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: 28 29 - ## `nixery` 30 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: 35 36 ```yaml 37 - # build_and_test.yaml 38 - when: 39 - - event: ["push", "pull_request"] 40 - branch: ["main", "develop"] 41 - - event: ["manual"] 42 - 43 dependencies: 44 - ## from nixpkgs 45 nixpkgs: 46 - nodejs 47 - ## custom registry 48 - git+https://tangled.sh/@oppi.li/statix: 49 - - statix 50 51 - steps: 52 - - name: "Install dependencies" 53 - command: "npm install" 54 - environment: 55 - NODE_ENV: "development" 56 - CI: "true" 57 58 - - name: "Run linter" 59 - command: "npm run lint" 60 61 - - name: "Run tests" 62 - command: "npm test" 63 - environment: 64 - NODE_ENV: "test" 65 - JEST_WORKERS: "2" 66 67 - - name: "Build application" 68 command: "npm run build" 69 environment: 70 NODE_ENV: "production" 71 72 - environment: 73 - BUILD_NUMBER: "123" 74 - GIT_BRANCH: "main" 75 76 - ## current repository is cloned and checked out at the target ref 77 - ## by default. 78 clone: 79 skip: false 80 - depth: 50 81 - submodules: true 82 - ``` 83 84 - ## git push options 85 86 - These are push options that can be used with the `--push-option (-o)` flag of git push: 87 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.
··· 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: 6 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. 13 14 + ## Trigger 15 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: 25 26 ```yaml 27 when: 28 + - event: ["push", "manual"] 29 + branch: ["main", "develop"] 30 + - event: ["pull_request"] 31 branch: ["main"] 32 + ``` 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 43 engine: "nixery" 44 + ``` 45 + 46 + ## Clone options 47 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 57 clone: 58 skip: false 59 + depth: 1 60 + submodules: false 61 ``` 62 63 + ## Dependencies 64 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. 66 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: 68 69 ```yaml 70 dependencies: 71 + # nixpkgs 72 nixpkgs: 73 - nodejs 74 + - go 75 + # custom registry 76 + git+https://tangled.sh/@example.com/my_pkg: 77 + - my_pkg 78 + ``` 79 80 + Now these dependencies are available to use in your workflow! 81 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 + ``` 95 96 + ## Steps 97 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" 114 command: "npm run build" 115 environment: 116 NODE_ENV: "production" 117 + ``` 118 119 + ## Complete workflow 120 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 133 clone: 134 skip: false 135 + depth: 1 136 + submodules: false 137 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 146 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 + ``` 164 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
··· 39 github.com/stretchr/testify v1.10.0 40 github.com/urfave/cli/v3 v3.3.3 41 github.com/whyrusleeping/cbor-gen v0.3.1 42 - github.com/yuin/goldmark v1.4.15 43 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc 44 golang.org/x/crypto v0.40.0 45 golang.org/x/net v0.42.0 ··· 154 github.com/vmihailenco/go-tinylfu v0.2.2 // indirect 155 github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect 156 github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect 157 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 158 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 159 go.opentelemetry.io/auto/sdk v1.1.0 // indirect
··· 39 github.com/stretchr/testify v1.10.0 40 github.com/urfave/cli/v3 v3.3.3 41 github.com/whyrusleeping/cbor-gen v0.3.1 42 + github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57 43 + github.com/yuin/goldmark v1.7.12 44 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc 45 golang.org/x/crypto v0.40.0 46 golang.org/x/net v0.42.0 ··· 155 github.com/vmihailenco/go-tinylfu v0.2.2 // indirect 156 github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect 157 github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect 158 + github.com/wyatt915/treeblood v0.1.15 // indirect 159 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 160 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 161 go.opentelemetry.io/auto/sdk v1.1.0 // indirect
+6 -1
go.sum
··· 426 github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw= 427 github.com/whyrusleeping/cbor-gen v0.3.1 h1:82ioxmhEYut7LBVGhGq8xoRkXPLElVuh5mV67AFfdv0= 428 github.com/whyrusleeping/cbor-gen v0.3.1/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= 429 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 430 github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 431 github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 432 github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 433 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 github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 436 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ= 437 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I= 438 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA=
··· 426 github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw= 427 github.com/whyrusleeping/cbor-gen v0.3.1 h1:82ioxmhEYut7LBVGhGq8xoRkXPLElVuh5mV67AFfdv0= 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= 433 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 434 github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 435 github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 436 github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 437 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 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= 441 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ= 442 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I= 443 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA=
+1 -1
input.css
··· 90 } 91 92 label { 93 - @apply block mb-2 text-gray-900 text-sm font-bold py-2 uppercase dark:text-gray-100; 94 } 95 input { 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;
··· 90 } 91 92 label { 93 + @apply block text-gray-900 text-sm font-bold py-2 uppercase dark:text-gray-100; 94 } 95 input { 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
··· 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
··· 27 Dev bool `env:"DEV, default=false"` 28 } 29 30 func (s Server) Did() syntax.DID { 31 return syntax.DID(fmt.Sprintf("did:web:%s", s.Hostname)) 32 } ··· 34 type Config struct { 35 Repo Repo `env:",prefix=KNOT_REPO_"` 36 Server Server `env:",prefix=KNOT_SERVER_"` 37 AppViewEndpoint string `env:"APPVIEW_ENDPOINT, default=https://tangled.sh"` 38 } 39
··· 27 Dev bool `env:"DEV, default=false"` 28 } 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 + 36 func (s Server) Did() syntax.DID { 37 return syntax.DID(fmt.Sprintf("did:web:%s", s.Hostname)) 38 } ··· 40 type Config struct { 41 Repo Repo `env:",prefix=KNOT_REPO_"` 42 Server Server `env:",prefix=KNOT_SERVER_"` 43 + Git Git `env:",prefix=KNOT_GIT_"` 44 AppViewEndpoint string `env:"APPVIEW_ENDPOINT, default=https://tangled.sh"` 45 } 46
+40
knotserver/db/pubkeys.go
··· 1 package db 2 3 import ( 4 "time" 5 6 "tangled.sh/tangled.sh/core/api/tangled" ··· 99 100 return keys, nil 101 }
··· 1 package db 2 3 import ( 4 + "strconv" 5 "time" 6 7 "tangled.sh/tangled.sh/core/api/tangled" ··· 100 101 return keys, nil 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
··· 15 WriteBufferSize: 1024, 16 } 17 18 - func (h *Handle) Events(w http.ResponseWriter, r *http.Request) { 19 l := h.l.With("handler", "OpLog") 20 l.Debug("received new connection") 21 ··· 83 } 84 } 85 86 - func (h *Handle) streamOps(conn *websocket.Conn, cursor *int64) error { 87 events, err := h.db.GetEvents(*cursor) 88 if err != nil { 89 h.l.Error("failed to fetch events from db", "err", err, "cursor", cursor)
··· 15 WriteBufferSize: 1024, 16 } 17 18 + func (h *Knot) Events(w http.ResponseWriter, r *http.Request) { 19 l := h.l.With("handler", "OpLog") 20 l.Debug("received new connection") 21 ··· 83 } 84 } 85 86 + func (h *Knot) streamOps(conn *websocket.Conn, cursor *int64) error { 87 events, err := h.db.GetEvents(*cursor) 88 if err != nil { 89 h.l.Error("failed to fetch events from db", "err", err, "cursor", cursor)
-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
··· 12 "github.com/dgraph-io/ristretto" 13 "github.com/go-git/go-git/v5" 14 "github.com/go-git/go-git/v5/plumbing" 15 - "tangled.sh/tangled.sh/core/patchutil" 16 ) 17 18 type MergeCheckCache struct { ··· 86 87 // MergeOptions specifies the configuration for a merge operation 88 type MergeOptions struct { 89 - CommitMessage string 90 - CommitBody string 91 - AuthorName string 92 - AuthorEmail string 93 - FormatPatch bool 94 } 95 96 func (e ErrMerge) Error() string { ··· 143 return tmpDir, nil 144 } 145 146 - func (g *GitRepo) applyPatch(tmpDir, patchFile string, checkOnly bool, opts *MergeOptions) error { 147 var stderr bytes.Buffer 148 - var cmd *exec.Cmd 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 161 } 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 - } 171 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 - } 176 177 - commitArgs := []string{"-C", tmpDir, "commit"} 178 179 - // Set author if provided 180 - authorName := opts.AuthorName 181 - authorEmail := opts.AuthorEmail 182 183 - if authorEmail == "" { 184 - authorEmail = "noreply@tangled.sh" 185 - } 186 187 - if authorName == "" { 188 - authorName = "Tangled" 189 - } 190 191 - if authorName != "" { 192 - commitArgs = append(commitArgs, "--author", fmt.Sprintf("%s <%s>", authorName, authorEmail)) 193 - } 194 195 - commitArgs = append(commitArgs, "-m", opts.CommitMessage) 196 197 - if opts.CommitBody != "" { 198 - commitArgs = append(commitArgs, "-m", opts.CommitBody) 199 - } 200 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) 205 } 206 } 207 208 cmd.Stderr = &stderr 209 210 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 return fmt.Errorf("patch application failed: %s", stderr.String()) 221 } 222 ··· 227 if val, ok := mergeCheckCache.Get(g, patchData, targetBranch); ok { 228 return val 229 } 230 - 231 - var opts MergeOptions 232 - opts.FormatPatch = patchutil.IsFormatPatch(string(patchData)) 233 234 patchFile, err := g.createTempFileWithPatch(patchData) 235 if err != nil { ··· 249 } 250 defer os.RemoveAll(tmpDir) 251 252 - result := g.applyPatch(tmpDir, patchFile, true, &opts) 253 mergeCheckCache.Set(g, patchData, targetBranch, result) 254 return result 255 } 256 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 { 262 patchFile, err := g.createTempFileWithPatch(patchData) 263 if err != nil { 264 return &ErrMerge{ ··· 277 } 278 defer os.RemoveAll(tmpDir) 279 280 - if err := g.applyPatch(tmpDir, patchFile, false, opts); err != nil { 281 return err 282 } 283
··· 12 "github.com/dgraph-io/ristretto" 13 "github.com/go-git/go-git/v5" 14 "github.com/go-git/go-git/v5/plumbing" 15 ) 16 17 type MergeCheckCache struct { ··· 85 86 // MergeOptions specifies the configuration for a merge operation 87 type MergeOptions struct { 88 + CommitMessage string 89 + CommitBody string 90 + AuthorName string 91 + AuthorEmail string 92 + CommitterName string 93 + CommitterEmail string 94 + FormatPatch bool 95 } 96 97 func (e ErrMerge) Error() string { ··· 144 return tmpDir, nil 145 } 146 147 + func (g *GitRepo) checkPatch(tmpDir, patchFile string) error { 148 var stderr bytes.Buffer 149 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, 160 } 161 + } 162 + return nil 163 + } 164 165 + func (g *GitRepo) applyPatch(tmpDir, patchFile string, opts MergeOptions) error { 166 + var stderr bytes.Buffer 167 + var cmd *exec.Cmd 168 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() 173 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 + } 184 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 + } 189 190 + commitArgs := []string{"-C", tmpDir, "commit"} 191 192 + // Set author if provided 193 + authorName := opts.AuthorName 194 + authorEmail := opts.AuthorEmail 195 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 200 201 + commitArgs = append(commitArgs, "-m", opts.CommitMessage) 202 203 + if opts.CommitBody != "" { 204 + commitArgs = append(commitArgs, "-m", opts.CommitBody) 205 } 206 + 207 + cmd = exec.Command("git", commitArgs...) 208 } 209 210 cmd.Stderr = &stderr 211 212 if err := cmd.Run(); err != nil { 213 return fmt.Errorf("patch application failed: %s", stderr.String()) 214 } 215 ··· 220 if val, ok := mergeCheckCache.Get(g, patchData, targetBranch); ok { 221 return val 222 } 223 224 patchFile, err := g.createTempFileWithPatch(patchData) 225 if err != nil { ··· 239 } 240 defer os.RemoveAll(tmpDir) 241 242 + result := g.checkPatch(tmpDir, patchFile) 243 mergeCheckCache.Set(g, patchData, targetBranch, result) 244 return result 245 } 246 247 + func (g *GitRepo) MergeWithOptions(patchData []byte, targetBranch string, opts MergeOptions) error { 248 patchFile, err := g.createTempFileWithPatch(patchData) 249 if err != nil { 250 return &ErrMerge{ ··· 263 } 264 defer os.RemoveAll(tmpDir) 265 266 + if err := g.applyPatch(tmpDir, patchFile, opts); err != nil { 267 return err 268 } 269
+9 -10
knotserver/git/post_receive.go
··· 145 } 146 147 func (m RefUpdateMeta) AsRecord() tangled.GitRefUpdate_Meta { 148 - var byEmail []*tangled.GitRefUpdate_Meta_CommitCount_ByEmail_Elem 149 for e, v := range m.CommitCount.ByEmail { 150 - byEmail = append(byEmail, &tangled.GitRefUpdate_Meta_CommitCount_ByEmail_Elem{ 151 Email: e, 152 Count: int64(v), 153 }) 154 } 155 156 - var langs []*tangled.GitRefUpdate_Pair 157 for lang, size := range m.LangBreakdown { 158 - langs = append(langs, &tangled.GitRefUpdate_Pair{ 159 Lang: lang, 160 Size: size, 161 }) 162 } 163 - langBreakdown := &tangled.GitRefUpdate_Meta_LangBreakdown{ 164 - Inputs: langs, 165 - } 166 167 return tangled.GitRefUpdate_Meta{ 168 - CommitCount: &tangled.GitRefUpdate_Meta_CommitCount{ 169 ByEmail: byEmail, 170 }, 171 - IsDefaultRef: m.IsDefaultRef, 172 - LangBreakdown: langBreakdown, 173 } 174 }
··· 145 } 146 147 func (m RefUpdateMeta) AsRecord() tangled.GitRefUpdate_Meta { 148 + var byEmail []*tangled.GitRefUpdate_IndividualEmailCommitCount 149 for e, v := range m.CommitCount.ByEmail { 150 + byEmail = append(byEmail, &tangled.GitRefUpdate_IndividualEmailCommitCount{ 151 Email: e, 152 Count: int64(v), 153 }) 154 } 155 156 + var langs []*tangled.GitRefUpdate_IndividualLanguageSize 157 for lang, size := range m.LangBreakdown { 158 + langs = append(langs, &tangled.GitRefUpdate_IndividualLanguageSize{ 159 Lang: lang, 160 Size: size, 161 }) 162 } 163 164 return tangled.GitRefUpdate_Meta{ 165 + CommitCount: &tangled.GitRefUpdate_CommitCountBreakdown{ 166 ByEmail: byEmail, 167 }, 168 + IsDefaultRef: m.IsDefaultRef, 169 + LangBreakdown: &tangled.GitRefUpdate_LangBreakdown{ 170 + Inputs: langs, 171 + }, 172 } 173 }
+4 -4
knotserver/git.go
··· 13 "tangled.sh/tangled.sh/core/knotserver/git/service" 14 ) 15 16 - func (d *Handle) InfoRefs(w http.ResponseWriter, r *http.Request) { 17 did := chi.URLParam(r, "did") 18 name := chi.URLParam(r, "name") 19 repoName, err := securejoin.SecureJoin(did, name) ··· 56 } 57 } 58 59 - func (d *Handle) UploadPack(w http.ResponseWriter, r *http.Request) { 60 did := chi.URLParam(r, "did") 61 name := chi.URLParam(r, "name") 62 repo, err := securejoin.SecureJoin(d.c.Repo.ScanPath, filepath.Join(did, name)) ··· 105 } 106 } 107 108 - func (d *Handle) ReceivePack(w http.ResponseWriter, r *http.Request) { 109 did := chi.URLParam(r, "did") 110 name := chi.URLParam(r, "name") 111 _, err := securejoin.SecureJoin(d.c.Repo.ScanPath, filepath.Join(did, name)) ··· 118 d.RejectPush(w, r, name) 119 } 120 121 - func (d *Handle) RejectPush(w http.ResponseWriter, r *http.Request, unqualifiedRepoName string) { 122 // A text/plain response will cause git to print each line of the body 123 // prefixed with "remote: ". 124 w.Header().Set("content-type", "text/plain; charset=UTF-8")
··· 13 "tangled.sh/tangled.sh/core/knotserver/git/service" 14 ) 15 16 + func (d *Knot) InfoRefs(w http.ResponseWriter, r *http.Request) { 17 did := chi.URLParam(r, "did") 18 name := chi.URLParam(r, "name") 19 repoName, err := securejoin.SecureJoin(did, name) ··· 56 } 57 } 58 59 + func (d *Knot) UploadPack(w http.ResponseWriter, r *http.Request) { 60 did := chi.URLParam(r, "did") 61 name := chi.URLParam(r, "name") 62 repo, err := securejoin.SecureJoin(d.c.Repo.ScanPath, filepath.Join(did, name)) ··· 105 } 106 } 107 108 + func (d *Knot) ReceivePack(w http.ResponseWriter, r *http.Request) { 109 did := chi.URLParam(r, "did") 110 name := chi.URLParam(r, "name") 111 _, err := securejoin.SecureJoin(d.c.Repo.ScanPath, filepath.Join(did, name)) ··· 118 d.RejectPush(w, r, name) 119 } 120 121 + func (d *Knot) RejectPush(w http.ResponseWriter, r *http.Request, unqualifiedRepoName string) { 122 // A text/plain response will cause git to print each line of the body 123 // prefixed with "remote: ". 124 w.Header().Set("content-type", "text/plain; charset=UTF-8")
-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
··· 24 "tangled.sh/tangled.sh/core/workflow" 25 ) 26 27 - func (h *Handle) processPublicKey(ctx context.Context, event *models.Event) error { 28 l := log.FromContext(ctx) 29 raw := json.RawMessage(event.Commit.Record) 30 did := event.Did ··· 46 return nil 47 } 48 49 - func (h *Handle) processKnotMember(ctx context.Context, event *models.Event) error { 50 l := log.FromContext(ctx) 51 raw := json.RawMessage(event.Commit.Record) 52 did := event.Did ··· 86 return nil 87 } 88 89 - func (h *Handle) processPull(ctx context.Context, event *models.Event) error { 90 raw := json.RawMessage(event.Commit.Record) 91 did := event.Did 92 ··· 98 l := log.FromContext(ctx) 99 l = l.With("handler", "processPull") 100 l = l.With("did", did) 101 - l = l.With("target_repo", record.TargetRepo) 102 - l = l.With("target_branch", record.TargetBranch) 103 104 if record.Source == nil { 105 return fmt.Errorf("ignoring pull record: not a branch-based pull request") ··· 109 return fmt.Errorf("ignoring pull record: fork based pull") 110 } 111 112 - repoAt, err := syntax.ParseATURI(record.TargetRepo) 113 if err != nil { 114 return fmt.Errorf("failed to parse ATURI: %w", err) 115 } ··· 178 Action: "create", 179 SourceBranch: record.Source.Branch, 180 SourceSha: record.Source.Sha, 181 - TargetBranch: record.TargetBranch, 182 } 183 184 compiler := workflow.Compiler{ ··· 214 } 215 216 // duplicated from add collaborator 217 - func (h *Handle) processCollaborator(ctx context.Context, event *models.Event) error { 218 raw := json.RawMessage(event.Commit.Record) 219 did := event.Did 220 ··· 275 return h.fetchAndAddKeys(ctx, subjectId.DID.String()) 276 } 277 278 - func (h *Handle) fetchAndAddKeys(ctx context.Context, did string) error { 279 l := log.FromContext(ctx) 280 281 keysEndpoint, err := url.JoinPath(h.c.AppViewEndpoint, "keys", did) ··· 318 return nil 319 } 320 321 - func (h *Handle) processMessages(ctx context.Context, event *models.Event) error { 322 if event.Kind != models.EventKindCommit { 323 return nil 324 }
··· 24 "tangled.sh/tangled.sh/core/workflow" 25 ) 26 27 + func (h *Knot) processPublicKey(ctx context.Context, event *models.Event) error { 28 l := log.FromContext(ctx) 29 raw := json.RawMessage(event.Commit.Record) 30 did := event.Did ··· 46 return nil 47 } 48 49 + func (h *Knot) processKnotMember(ctx context.Context, event *models.Event) error { 50 l := log.FromContext(ctx) 51 raw := json.RawMessage(event.Commit.Record) 52 did := event.Did ··· 86 return nil 87 } 88 89 + func (h *Knot) processPull(ctx context.Context, event *models.Event) error { 90 raw := json.RawMessage(event.Commit.Record) 91 did := event.Did 92 ··· 98 l := log.FromContext(ctx) 99 l = l.With("handler", "processPull") 100 l = l.With("did", did) 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) 108 109 if record.Source == nil { 110 return fmt.Errorf("ignoring pull record: not a branch-based pull request") ··· 114 return fmt.Errorf("ignoring pull record: fork based pull") 115 } 116 117 + repoAt, err := syntax.ParseATURI(record.Target.Repo) 118 if err != nil { 119 return fmt.Errorf("failed to parse ATURI: %w", err) 120 } ··· 183 Action: "create", 184 SourceBranch: record.Source.Branch, 185 SourceSha: record.Source.Sha, 186 + TargetBranch: record.Target.Branch, 187 } 188 189 compiler := workflow.Compiler{ ··· 219 } 220 221 // duplicated from add collaborator 222 + func (h *Knot) processCollaborator(ctx context.Context, event *models.Event) error { 223 raw := json.RawMessage(event.Commit.Record) 224 did := event.Did 225 ··· 280 return h.fetchAndAddKeys(ctx, subjectId.DID.String()) 281 } 282 283 + func (h *Knot) fetchAndAddKeys(ctx context.Context, did string) error { 284 l := log.FromContext(ctx) 285 286 keysEndpoint, err := url.JoinPath(h.c.AppViewEndpoint, "keys", did) ··· 323 return nil 324 } 325 326 + func (h *Knot) processMessages(ctx context.Context, event *models.Event) error { 327 if event.Kind != models.EventKindCommit { 328 return nil 329 }
+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
··· 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
··· 22 Usage: "run a knot server", 23 Action: Run, 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 - `, 38 } 39 } 40
··· 22 Usage: "run a knot server", 23 Action: Run, 24 Description: ` 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 + `, 41 } 42 } 43
+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
··· 67 return 68 } 69 70 - mo := &git.MergeOptions{} 71 if data.AuthorName != nil { 72 mo.AuthorName = *data.AuthorName 73 } ··· 81 mo.CommitMessage = *data.CommitMessage 82 } 83 84 mo.FormatPatch = patchutil.IsFormatPatch(data.Patch) 85 86 err = gr.MergeWithOptions([]byte(data.Patch), data.Branch, mo)
··· 67 return 68 } 69 70 + mo := git.MergeOptions{} 71 if data.AuthorName != nil { 72 mo.AuthorName = *data.AuthorName 73 } ··· 81 mo.CommitMessage = *data.CommitMessage 82 } 83 84 + mo.CommitterName = x.Config.Git.UserName 85 + mo.CommitterEmail = x.Config.Git.UserEmail 86 mo.FormatPatch = patchutil.IsFormatPatch(data.Patch) 87 88 err = gr.MergeWithOptions([]byte(data.Patch), data.Branch, mo)
+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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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 + }
+99
knotserver/xrpc/repo_tags.go
···
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + "strconv" 7 + 8 + "github.com/go-git/go-git/v5/plumbing" 9 + "github.com/go-git/go-git/v5/plumbing/object" 10 + 11 + "tangled.sh/tangled.sh/core/knotserver/git" 12 + "tangled.sh/tangled.sh/core/types" 13 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 14 + ) 15 + 16 + func (x *Xrpc) RepoTags(w http.ResponseWriter, r *http.Request) { 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 + cursor := r.URL.Query().Get("cursor") 25 + 26 + limit := 50 // default 27 + if limitStr := r.URL.Query().Get("limit"); limitStr != "" { 28 + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { 29 + limit = l 30 + } 31 + } 32 + 33 + gr, err := git.Open(repoPath, "") 34 + if err != nil { 35 + x.Logger.Error("failed to open", "error", err) 36 + writeError(w, xrpcerr.NewXrpcError( 37 + xrpcerr.WithTag("RepoNotFound"), 38 + xrpcerr.WithMessage("repository not found"), 39 + ), http.StatusNotFound) 40 + return 41 + } 42 + 43 + tags, err := gr.Tags() 44 + if err != nil { 45 + x.Logger.Warn("getting tags", "error", err.Error()) 46 + tags = []object.Tag{} 47 + } 48 + 49 + rtags := []*types.TagReference{} 50 + for _, tag := range tags { 51 + var target *object.Tag 52 + if tag.Target != plumbing.ZeroHash { 53 + target = &tag 54 + } 55 + tr := types.TagReference{ 56 + Tag: target, 57 + } 58 + 59 + tr.Reference = types.Reference{ 60 + Name: tag.Name, 61 + Hash: tag.Hash.String(), 62 + } 63 + 64 + if tag.Message != "" { 65 + tr.Message = tag.Message 66 + } 67 + 68 + rtags = append(rtags, &tr) 69 + } 70 + 71 + // apply pagination manually 72 + offset := 0 73 + if cursor != "" { 74 + if o, err := strconv.Atoi(cursor); err == nil && o >= 0 && o < len(rtags) { 75 + offset = o 76 + } 77 + } 78 + 79 + // calculate end index 80 + end := min(offset+limit, len(rtags)) 81 + 82 + paginatedTags := rtags[offset:end] 83 + 84 + // Create response using existing types.RepoTagsResponse 85 + response := types.RepoTagsResponse{ 86 + Tags: paginatedTags, 87 + } 88 + 89 + // Write JSON response directly 90 + w.Header().Set("Content-Type", "application/json") 91 + if err := json.NewEncoder(w).Encode(response); err != nil { 92 + x.Logger.Error("failed to encode response", "error", err) 93 + writeError(w, xrpcerr.NewXrpcError( 94 + xrpcerr.WithTag("InternalServerError"), 95 + xrpcerr.WithMessage("failed to encode response"), 96 + ), http.StatusInternalServerError) 97 + return 98 + } 99 + }
+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
···
··· 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
··· 4 "encoding/json" 5 "log/slog" 6 "net/http" 7 8 "tangled.sh/tangled.sh/core/api/tangled" 9 "tangled.sh/tangled.sh/core/idresolver" 10 "tangled.sh/tangled.sh/core/jetstream" ··· 50 // - we can calculate on PR submit/resubmit/gitRefUpdate etc. 51 // - use ETags on clients to keep requests to a minimum 52 r.Post("/"+tangled.RepoMergeCheckNSID, x.MergeCheck) 53 return r 54 } 55 56 func writeError(w http.ResponseWriter, e xrpcerr.XrpcError, status int) {
··· 4 "encoding/json" 5 "log/slog" 6 "net/http" 7 + "net/url" 8 + "strings" 9 10 + securejoin "github.com/cyphar/filepath-securejoin" 11 "tangled.sh/tangled.sh/core/api/tangled" 12 "tangled.sh/tangled.sh/core/idresolver" 13 "tangled.sh/tangled.sh/core/jetstream" ··· 53 // - we can calculate on PR submit/resubmit/gitRefUpdate etc. 54 // - use ETags on clients to keep requests to a minimum 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 + 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 142 } 143 144 func writeError(w http.ResponseWriter, e xrpcerr.XrpcError, status int) {
+158
legal/privacy.md
···
··· 1 + # Privacy Policy 2 + 3 + **Last updated:** January 15, 2025 4 + 5 + This Privacy Policy describes how Tangled ("we," "us," or "our") 6 + collects, uses, and shares your personal information when you use our 7 + platform and services (the "Service"). 8 + 9 + ## 1. Information We Collect 10 + 11 + ### Account Information 12 + 13 + When you create an account, we collect: 14 + 15 + - Your chosen username 16 + - Email address 17 + - Profile information you choose to provide 18 + - Authentication data 19 + 20 + ### Content and Activity 21 + 22 + We store: 23 + 24 + - Code repositories and associated metadata 25 + - Issues, pull requests, and comments 26 + - Activity logs and usage patterns 27 + - Public keys for authentication 28 + 29 + ## 2. Data Location and Hosting 30 + 31 + ### EU Data Hosting 32 + 33 + **All Tangled service data is hosted within the European Union.** 34 + Specifically: 35 + 36 + - **Personal Data Servers (PDS):** Accounts hosted on Tangled PDS 37 + (*.tngl.sh) are located in Finland 38 + - **Application Data:** All other service data is stored on EU-based 39 + servers 40 + - **Data Processing:** All data processing occurs within EU 41 + jurisdiction 42 + 43 + ### External PDS Notice 44 + 45 + **Important:** If your account is hosted on Bluesky's PDS or other 46 + self-hosted Personal Data Servers (not *.tngl.sh), we do not control 47 + that data. The data protection, storage location, and privacy 48 + practices for such accounts are governed by the respective PDS 49 + provider's policies, not this Privacy Policy. We only control data 50 + processing within our own services and infrastructure. 51 + 52 + ## 3. Third-Party Data Processors 53 + 54 + We only share your data with the following third-party processors: 55 + 56 + ### Resend (Email Services) 57 + 58 + - **Purpose:** Sending transactional emails (account verification, 59 + notifications) 60 + - **Data Shared:** Email address and necessary message content 61 + 62 + ### Cloudflare (Image Caching) 63 + 64 + - **Purpose:** Caching and optimizing image delivery 65 + - **Data Shared:** Public images and associated metadata for caching 66 + purposes 67 + 68 + ### Posthog (Usage Metrics Tracking) 69 + 70 + - **Purpose:** Tracking usage and platform metrics 71 + - **Data Shared:** Anonymous usage data, IP addresses, DIDs, and browser 72 + information 73 + 74 + ## 4. How We Use Your Information 75 + 76 + We use your information to: 77 + 78 + - Provide and maintain the Service 79 + - Process your transactions and requests 80 + - Send you technical notices and support messages 81 + - Improve and develop new features 82 + - Ensure security and prevent fraud 83 + - Comply with legal obligations 84 + 85 + ## 5. Data Sharing and Disclosure 86 + 87 + We do not sell, trade, or rent your personal information. We may share 88 + your information only in the following circumstances: 89 + 90 + - With the third-party processors listed above 91 + - When required by law or legal process 92 + - To protect our rights, property, or safety, or that of our users 93 + - In connection with a merger, acquisition, or sale of assets (with 94 + appropriate protections) 95 + 96 + ## 6. Data Security 97 + 98 + We implement appropriate technical and organizational measures to 99 + protect your personal information against unauthorized access, 100 + alteration, disclosure, or destruction. However, no method of 101 + transmission over the Internet is 100% secure. 102 + 103 + ## 7. Data Retention 104 + 105 + We retain your personal information for as long as necessary to provide 106 + the Service and fulfill the purposes outlined in this Privacy Policy, 107 + unless a longer retention period is required by law. 108 + 109 + ## 8. Your Rights 110 + 111 + Under applicable data protection laws, you have the right to: 112 + 113 + - Access your personal information 114 + - Correct inaccurate information 115 + - Request deletion of your information 116 + - Object to processing of your information 117 + - Data portability 118 + - Withdraw consent (where applicable) 119 + 120 + ## 9. Cookies and Tracking 121 + 122 + We use cookies and similar technologies to: 123 + 124 + - Maintain your login session 125 + - Remember your preferences 126 + - Analyze usage patterns to improve the Service 127 + 128 + You can control cookie settings through your browser preferences. 129 + 130 + ## 10. Children's Privacy 131 + 132 + The Service is not intended for children under 16 years of age. We do 133 + not knowingly collect personal information from children under 16. If 134 + we become aware that we have collected such information, we will take 135 + steps to delete it. 136 + 137 + ## 11. International Data Transfers 138 + 139 + While all our primary data processing occurs within the EU, some of our 140 + third-party processors may process data outside the EU. When this 141 + occurs, we ensure appropriate safeguards are in place, such as Standard 142 + Contractual Clauses or adequacy decisions. 143 + 144 + ## 12. Changes to This Privacy Policy 145 + 146 + We may update this Privacy Policy from time to time. We will notify you 147 + of any changes by posting the new Privacy Policy on this page and 148 + updating the "Last updated" date. 149 + 150 + ## 13. Contact Information 151 + 152 + If you have any questions about this Privacy Policy or wish to exercise 153 + your rights, please contact us through our platform or via email. 154 + 155 + --- 156 + 157 + This Privacy Policy complies with the EU General Data Protection 158 + Regulation (GDPR) and other applicable data protection laws.
+109
legal/terms.md
···
··· 1 + # Terms of Service 2 + 3 + **Last updated:** January 15, 2025 4 + 5 + Welcome to Tangled. These Terms of Service ("Terms") govern your access 6 + to and use of the Tangled platform and services (the "Service") 7 + operated by us ("Tangled," "we," "us," or "our"). 8 + 9 + ## 1. Acceptance of Terms 10 + 11 + By accessing or using our Service, you agree to be bound by these Terms. 12 + If you disagree with any part of these terms, then you may not access 13 + the Service. 14 + 15 + ## 2. Account Registration 16 + 17 + To use certain features of the Service, you must register for an 18 + account. You agree to provide accurate, current, and complete 19 + information during the registration process and to update such 20 + information to keep it accurate, current, and complete. 21 + 22 + ## 3. Account Termination 23 + 24 + > **Important Notice** 25 + > 26 + > **We reserve the right to terminate, suspend, or restrict access to 27 + > your account at any time, for any reason, or for no reason at all, at 28 + > our sole discretion.** This includes, but is not limited to, 29 + > termination for violation of these Terms, inappropriate conduct, spam, 30 + > abuse, or any other behavior we deem harmful to the Service or other 31 + > users. 32 + > 33 + > Account termination may result in the loss of access to your 34 + > repositories, data, and other content associated with your account. We 35 + > are not obligated to provide advance notice of termination, though we 36 + > may do so in our discretion. 37 + 38 + ## 4. Acceptable Use 39 + 40 + You agree not to use the Service to: 41 + 42 + - Violate any applicable laws or regulations 43 + - Infringe upon the rights of others 44 + - Upload, store, or share content that is illegal, harmful, threatening, 45 + abusive, harassing, defamatory, vulgar, obscene, or otherwise 46 + objectionable 47 + - Engage in spam, phishing, or other deceptive practices 48 + - Attempt to gain unauthorized access to the Service or other users' 49 + accounts 50 + - Interfere with or disrupt the Service or servers connected to the 51 + Service 52 + 53 + ## 5. Content and Intellectual Property 54 + 55 + You retain ownership of the content you upload to the Service. By 56 + uploading content, you grant us a non-exclusive, worldwide, royalty-free 57 + license to use, reproduce, modify, and distribute your content as 58 + necessary to provide the Service. 59 + 60 + ## 6. Privacy 61 + 62 + Your privacy is important to us. Please review our [Privacy 63 + Policy](/privacy), which also governs your use of the Service. 64 + 65 + ## 7. Disclaimers 66 + 67 + The Service is provided on an "AS IS" and "AS AVAILABLE" basis. We make 68 + no warranties, expressed or implied, and hereby disclaim and negate all 69 + other warranties including without limitation, implied warranties or 70 + conditions of merchantability, fitness for a particular purpose, or 71 + non-infringement of intellectual property or other violation of rights. 72 + 73 + ## 8. Limitation of Liability 74 + 75 + In no event shall Tangled, nor its directors, employees, partners, 76 + agents, suppliers, or affiliates, be liable for any indirect, 77 + incidental, special, consequential, or punitive damages, including 78 + without limitation, loss of profits, data, use, goodwill, or other 79 + intangible losses, resulting from your use of the Service. 80 + 81 + ## 9. Indemnification 82 + 83 + You agree to defend, indemnify, and hold harmless Tangled and its 84 + affiliates, officers, directors, employees, and agents from and against 85 + any and all claims, damages, obligations, losses, liabilities, costs, 86 + or debt, and expenses (including attorney's fees). 87 + 88 + ## 10. Governing Law 89 + 90 + These Terms shall be interpreted and governed by the laws of Finland, 91 + without regard to its conflict of law provisions. 92 + 93 + ## 11. Changes to Terms 94 + 95 + We reserve the right to modify or replace these Terms at any time. If a 96 + revision is material, we will try to provide at least 30 days notice 97 + prior to any new terms taking effect. 98 + 99 + ## 12. Contact Information 100 + 101 + If you have any questions about these Terms of Service, please contact 102 + us through our platform or via email. 103 + 104 + --- 105 + 106 + These terms are effective as of the last updated date shown above and 107 + will remain in effect except with respect to any changes in their 108 + provisions in the future, which will be in effect immediately after 109 + being posted on this page.
+59 -52
lexicons/git/refUpdate.json
··· 51 "maxLength": 40 52 }, 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 - } 101 } 102 } 103 } 104 }, 105 - "pair": { 106 "type": "object", 107 - "required": [ 108 - "lang", 109 - "size" 110 - ], 111 "properties": { 112 "lang": { 113 "type": "string" 114 }, 115 "size": { 116 "type": "integer" 117 } 118 }
··· 51 "maxLength": 40 52 }, 53 "meta": { 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" 86 } 87 } 88 } 89 }, 90 + "individualLanguageSize": { 91 "type": "object", 92 + "required": ["lang", "size"], 93 "properties": { 94 "lang": { 95 "type": "string" 96 }, 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": { 123 "type": "integer" 124 } 125 }
+4 -11
lexicons/issue/comment.json
··· 19 "type": "string", 20 "format": "at-uri" 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 "body": { 34 "type": "string" 35 }, 36 "createdAt": { 37 "type": "string", 38 "format": "datetime" 39 } 40 } 41 }
··· 19 "type": "string", 20 "format": "at-uri" 21 }, 22 "body": { 23 "type": "string" 24 }, 25 "createdAt": { 26 "type": "string", 27 "format": "datetime" 28 + }, 29 + "replyTo": { 30 + "type": "string", 31 + "format": "at-uri" 32 } 33 } 34 }
+1 -14
lexicons/issue/issue.json
··· 9 "key": "tid", 10 "record": { 11 "type": "object", 12 - "required": [ 13 - "repo", 14 - "issueId", 15 - "owner", 16 - "title", 17 - "createdAt" 18 - ], 19 "properties": { 20 "repo": { 21 "type": "string", 22 "format": "at-uri" 23 - }, 24 - "issueId": { 25 - "type": "integer" 26 - }, 27 - "owner": { 28 - "type": "string", 29 - "format": "did" 30 }, 31 "title": { 32 "type": "string"
··· 9 "key": "tid", 10 "record": { 11 "type": "object", 12 + "required": ["repo", "title", "createdAt"], 13 "properties": { 14 "repo": { 15 "type": "string", 16 "format": "at-uri" 17 }, 18 "title": { 19 "type": "string"
+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
···
··· 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
···
··· 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
··· 19 "type": "string", 20 "format": "at-uri" 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 "body": { 34 "type": "string" 35 },
··· 19 "type": "string", 20 "format": "at-uri" 21 }, 22 "body": { 23 "type": "string" 24 },
+20 -12
lexicons/pulls/pull.json
··· 10 "record": { 11 "type": "object", 12 "required": [ 13 - "targetRepo", 14 - "targetBranch", 15 - "pullId", 16 "title", 17 "patch", 18 "createdAt" 19 ], 20 "properties": { 21 - "targetRepo": { 22 - "type": "string", 23 - "format": "at-uri" 24 - }, 25 - "targetBranch": { 26 - "type": "string" 27 - }, 28 - "pullId": { 29 - "type": "integer" 30 }, 31 "title": { 32 "type": "string" ··· 45 "type": "string", 46 "format": "datetime" 47 } 48 } 49 } 50 },
··· 10 "record": { 11 "type": "object", 12 "required": [ 13 + "target", 14 "title", 15 "patch", 16 "createdAt" 17 ], 18 "properties": { 19 + "target": { 20 + "type": "ref", 21 + "ref": "#target" 22 }, 23 "title": { 24 "type": "string" ··· 37 "type": "string", 38 "format": "datetime" 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" 56 } 57 } 58 },
+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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
··· 34 }, 35 "description": { 36 "type": "string", 37 - "format": "datetime", 38 "minGraphemes": 1, 39 "maxGraphemes": 140 40 },
··· 34 }, 35 "description": { 36 "type": "string", 37 "minGraphemes": 1, 38 "maxGraphemes": 140 39 },
+43
lexicons/repo/tags.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.tags", 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 tags 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 + }
+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
··· 425 [mod."github.com/whyrusleeping/cbor-gen"] 426 version = "v0.3.1" 427 hash = "sha256-PAd8M2Z8t6rVRBII+Rg8Bz+QaJIwbW64bfyqsv31kgc=" 428 [mod."github.com/yuin/goldmark"] 429 - version = "v1.4.15" 430 - hash = "sha256-MvSOT6dwf5hVYkIg4MnqMpsy5ZtWZ7amAE7Zo9HkEa0=" 431 [mod."github.com/yuin/goldmark-highlighting/v2"] 432 version = "v2.0.0-20230729083705-37449abec8cc" 433 hash = "sha256-HpiwU7jIeDUAg2zOpTIiviQir8dpRPuXYh2nqFFccpg="
··· 425 [mod."github.com/whyrusleeping/cbor-gen"] 426 version = "v0.3.1" 427 hash = "sha256-PAd8M2Z8t6rVRBII+Rg8Bz+QaJIwbW64bfyqsv31kgc=" 428 + [mod."github.com/wyatt915/goldmark-treeblood"] 429 + version = "v0.0.0-20250825231212-5dcbdb2f4b57" 430 + hash = "sha256-IZEsUXTBTsNgWoD7vqRUc9aFCCHNjzk1IUmI9O+NCnM=" 431 + [mod."github.com/wyatt915/treeblood"] 432 + version = "v0.1.15" 433 + hash = "sha256-hb99exdkoY2Qv8WdDxhwgPXGbEYimUr6wFtPXEvcO9g=" 434 [mod."github.com/yuin/goldmark"] 435 + version = "v1.7.12" 436 + hash = "sha256-thLYBS4woL2X5qRdo7vP+xCvjlGRDU0jXtDCUt6vvWM=" 437 [mod."github.com/yuin/goldmark-highlighting/v2"] 438 version = "v2.0.0-20230729083705-37449abec8cc" 439 hash = "sha256-HpiwU7jIeDUAg2zOpTIiviQir8dpRPuXYh2nqFFccpg="
+16
nix/modules/spindle.nix
··· 55 description = "DID of owner (required)"; 56 }; 57 58 secrets = { 59 provider = mkOption { 60 type = types.str; ··· 108 "SPINDLE_SERVER_JETSTREAM=${cfg.server.jetstreamEndpoint}" 109 "SPINDLE_SERVER_DEV=${lib.boolToString cfg.server.dev}" 110 "SPINDLE_SERVER_OWNER=${cfg.server.owner}" 111 "SPINDLE_SERVER_SECRETS_PROVIDER=${cfg.server.secrets.provider}" 112 "SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=${cfg.server.secrets.openbao.proxyAddr}" 113 "SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=${cfg.server.secrets.openbao.mount}"
··· 55 description = "DID of owner (required)"; 56 }; 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 + 72 secrets = { 73 provider = mkOption { 74 type = types.str; ··· 122 "SPINDLE_SERVER_JETSTREAM=${cfg.server.jetstreamEndpoint}" 123 "SPINDLE_SERVER_DEV=${lib.boolToString cfg.server.dev}" 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}" 127 "SPINDLE_SERVER_SECRETS_PROVIDER=${cfg.server.secrets.provider}" 128 "SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=${cfg.server.secrets.openbao.proxyAddr}" 129 "SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=${cfg.server.secrets.openbao.mount}"
+17 -12
nix/pkgs/knot-unwrapped.nix
··· 3 modules, 4 sqlite-lib, 5 src, 6 - }: 7 - buildGoApplication { 8 - pname = "knot"; 9 - version = "0.1.0"; 10 - inherit src modules; 11 12 - doCheck = false; 13 14 - subPackages = ["cmd/knot"]; 15 - tags = ["libsqlite3"]; 16 17 - env.CGO_CFLAGS = "-I ${sqlite-lib}/include "; 18 - env.CGO_LDFLAGS = "-L ${sqlite-lib}/lib"; 19 - CGO_ENABLED = 1; 20 - }
··· 3 modules, 4 sqlite-lib, 5 src, 6 + }: let 7 + version = "1.9.0-alpha"; 8 + in 9 + buildGoApplication { 10 + pname = "knot"; 11 + inherit src version modules; 12 + 13 + doCheck = false; 14 15 + subPackages = ["cmd/knot"]; 16 + tags = ["libsqlite3"]; 17 18 + ldflags = [ 19 + "-X tangled.sh/tangled.sh/core/knotserver/xrpc.version=${version}" 20 + ]; 21 22 + env.CGO_CFLAGS = "-I ${sqlite-lib}/include "; 23 + env.CGO_LDFLAGS = "-L ${sqlite-lib}/lib"; 24 + CGO_ENABLED = 1; 25 + }
+2
nix/vm.nix
··· 89 hostname = "localhost:6555"; 90 listenAddr = "0.0.0.0:6555"; 91 dev = true; 92 secrets = { 93 provider = "sqlite"; 94 };
··· 89 hostname = "localhost:6555"; 90 listenAddr = "0.0.0.0:6555"; 91 dev = true; 92 + queueSize = 100; 93 + maxJobCount = 2; 94 secrets = { 95 provider = "sqlite"; 96 };
+1 -1
patchutil/combinediff.go
··· 119 // we have f1 and f2, combine them 120 combined, err := combineFiles(f1, f2) 121 if err != nil { 122 - fmt.Println(err) 123 } 124 125 // combined can be nil commit 2 reverted all changes from commit 1
··· 119 // we have f1 and f2, combine them 120 combined, err := combineFiles(f1, f2) 121 if err != nil { 122 + // fmt.Println(err) 123 } 124 125 // combined can be nil commit 2 reverted all changes from commit 1
+2
spindle/config/config.go
··· 17 Owner string `env:"OWNER, required"` 18 Secrets Secrets `env:",prefix=SECRETS_"` 19 LogDir string `env:"LOG_DIR, default=/var/log/spindle"` 20 } 21 22 func (s Server) Did() syntax.DID {
··· 17 Owner string `env:"OWNER, required"` 18 Secrets Secrets `env:",prefix=SECRETS_"` 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 22 } 23 24 func (s Server) Did() syntax.DID {
+2 -4
spindle/server.go
··· 100 return err 101 } 102 103 - jq := queue.NewQueue(100, 5) 104 105 collections := []string{ 106 tangled.SpindleMemberNSID, ··· 202 w.Write(motd) 203 }) 204 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 mux.HandleFunc("/logs/{knot}/{rkey}/{name}", s.Logs) 209 210 mux.Mount("/xrpc", s.XrpcRouter())
··· 100 return err 101 } 102 103 + jq := queue.NewQueue(cfg.Server.QueueSize, cfg.Server.MaxJobCount) 104 + logger.Info("initialized queue", "queueSize", cfg.Server.QueueSize, "numWorkers", cfg.Server.MaxJobCount) 105 106 collections := []string{ 107 tangled.SpindleMemberNSID, ··· 203 w.Write(motd) 204 }) 205 mux.HandleFunc("/events", s.Events) 206 mux.HandleFunc("/logs/{knot}/{rkey}/{name}", s.Logs) 207 208 mux.Mount("/xrpc", s.XrpcRouter())
+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
··· 35 func (x *Xrpc) Router() http.Handler { 36 r := chi.NewRouter() 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) 41 42 return r 43 }
··· 35 func (x *Xrpc) Router() http.Handler { 36 r := chi.NewRouter() 37 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) 48 49 return r 50 }
+5
xrpc/errors/errors.go
··· 51 WithMessage("actor DID not supplied"), 52 ) 53 54 var AuthError = func(err error) XrpcError { 55 return NewXrpcError( 56 WithTag("Auth"),
··· 51 WithMessage("actor DID not supplied"), 52 ) 53 54 + var OwnerNotFoundError = NewXrpcError( 55 + WithTag("OwnerNotFound"), 56 + WithMessage("owner not set for this service"), 57 + ) 58 + 59 var AuthError = func(err error) XrpcError { 60 return NewXrpcError( 61 WithTag("Auth"),